mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Recover Pi sessions after Copilot 413 (#995)
This commit is contained in:
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
@@ -13,12 +13,46 @@ import {
|
||||
type PiDirectSessionAdapter,
|
||||
} from "./pi-direct-agent.js";
|
||||
|
||||
function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
||||
function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "openai-responses",
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createPiSession(
|
||||
prompt: () => Promise<void>,
|
||||
options: {
|
||||
compact?: () => Promise<void>;
|
||||
messages?: PiDirectSessionAdapter["messages"];
|
||||
errorMessage?: string | null;
|
||||
} = {},
|
||||
): PiDirectSessionAdapter {
|
||||
return {
|
||||
sessionId: "pi-session-1",
|
||||
thinkingLevel: "medium",
|
||||
model: undefined,
|
||||
messages: [],
|
||||
messages: options.messages ?? [],
|
||||
extensionRunner: undefined,
|
||||
promptTemplates: [],
|
||||
resourceLoader: {
|
||||
@@ -27,7 +61,7 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
||||
agent: {
|
||||
state: {
|
||||
systemPrompt: "",
|
||||
errorMessage: null,
|
||||
errorMessage: options.errorMessage ?? null,
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
@@ -36,6 +70,7 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
||||
},
|
||||
subscribe: vi.fn(),
|
||||
prompt,
|
||||
compact: options.compact ?? vi.fn(async () => undefined),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(() => ({})),
|
||||
@@ -95,6 +130,61 @@ describe("PiDirectAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("compacts stale Copilot 413 sessions before prompting again", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const sdkSession = createPiSession(
|
||||
vi.fn(async () => {
|
||||
callOrder.push("prompt");
|
||||
}),
|
||||
{
|
||||
messages: [createPiAssistantErrorMessage("413 failed to parse request")],
|
||||
errorMessage: "413 failed to parse request",
|
||||
compact: vi.fn(async () => {
|
||||
callOrder.push("compact");
|
||||
}),
|
||||
},
|
||||
);
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(sdkSession),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
|
||||
await session.startTurn("continue");
|
||||
|
||||
expect(sdkSession.compact).toHaveBeenCalledTimes(1);
|
||||
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(callOrder).toEqual(["compact", "prompt"]);
|
||||
});
|
||||
|
||||
test("does not compact other Pi errors before prompting", async () => {
|
||||
const sdkSession = createPiSession(
|
||||
vi.fn(async () => undefined),
|
||||
{
|
||||
messages: [createPiAssistantErrorMessage("413 unrelated provider error")],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("should not compact");
|
||||
}),
|
||||
},
|
||||
);
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(sdkSession),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
|
||||
await session.startTurn("continue");
|
||||
|
||||
expect(sdkSession.compact).not.toHaveBeenCalled();
|
||||
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("setModel creates a minimal model for new ids under a known provider", async () => {
|
||||
const sdkSession = createPiSession(async () => undefined);
|
||||
const session = new PiDirectAgentSession(
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { applyPiSessionRecoveryPolicy } from "./pi-session-recovery-policy.js";
|
||||
|
||||
const PI_PROVIDER = "pi";
|
||||
const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium";
|
||||
@@ -116,6 +117,7 @@ export type PiDirectSessionAdapter = Pick<
|
||||
PiAgentSession,
|
||||
| "abort"
|
||||
| "agent"
|
||||
| "compact"
|
||||
| "dispose"
|
||||
| "extensionRunner"
|
||||
| "getSessionStats"
|
||||
@@ -1096,6 +1098,19 @@ export class PiDirectAgentSession implements AgentSession {
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
|
||||
try {
|
||||
await applyPiSessionRecoveryPolicy(this.session);
|
||||
} catch (error) {
|
||||
this.activeTurnId = null;
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
error: toDiagnosticErrorMessage(error),
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
void this.session
|
||||
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
applyPiSessionRecoveryPolicy,
|
||||
type PiSessionRecoveryPolicySession,
|
||||
} from "./pi-session-recovery-policy.js";
|
||||
|
||||
function createRecoverySession(
|
||||
options: {
|
||||
messages?: PiSessionRecoveryPolicySession["messages"];
|
||||
errorMessage?: string | null;
|
||||
compact?: () => Promise<void>;
|
||||
} = {},
|
||||
): PiSessionRecoveryPolicySession {
|
||||
return {
|
||||
messages: options.messages ?? [],
|
||||
agent: {
|
||||
state: {
|
||||
errorMessage: options.errorMessage ?? null,
|
||||
},
|
||||
},
|
||||
compact: options.compact ?? vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyPiSessionRecoveryPolicy", () => {
|
||||
test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("uses Pi agent error state when message history has no matching assistant error", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [{ role: "user" }],
|
||||
errorMessage: " 413 failed to parse request ",
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not compact unrelated Pi errors", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 unrelated provider error",
|
||||
},
|
||||
],
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: false });
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats already-compacted sessions as recovered", async () => {
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("Already compacted");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({
|
||||
applied: true,
|
||||
policyId: "piCopilot413",
|
||||
});
|
||||
});
|
||||
|
||||
test("surfaces unexpected compaction failures", async () => {
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("disk exploded");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
|
||||
|
||||
export interface PiSessionRecoveryPolicySession {
|
||||
readonly messages: readonly PiRecoveryMessage[];
|
||||
readonly agent: {
|
||||
readonly state: {
|
||||
readonly errorMessage?: string | null;
|
||||
};
|
||||
};
|
||||
compact(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface PiRecoveryMessage {
|
||||
readonly role: string;
|
||||
readonly stopReason?: string;
|
||||
readonly errorMessage?: string | null;
|
||||
}
|
||||
|
||||
type PiSessionRecoveryPolicyId = "piCopilot413";
|
||||
|
||||
interface PiSessionRecoveryPolicy {
|
||||
readonly id: PiSessionRecoveryPolicyId;
|
||||
shouldRecover(session: PiSessionRecoveryPolicySession): boolean;
|
||||
recover(session: PiSessionRecoveryPolicySession): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiSessionRecoveryResult {
|
||||
readonly applied: boolean;
|
||||
readonly policyId?: PiSessionRecoveryPolicyId;
|
||||
}
|
||||
|
||||
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
|
||||
// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow.
|
||||
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
|
||||
|
||||
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
|
||||
{
|
||||
id: "piCopilot413",
|
||||
shouldRecover: shouldCompactForPiCopilot413,
|
||||
recover: compactPiSession,
|
||||
},
|
||||
];
|
||||
|
||||
export async function applyPiSessionRecoveryPolicy(
|
||||
session: PiSessionRecoveryPolicySession,
|
||||
): Promise<PiSessionRecoveryResult> {
|
||||
const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session));
|
||||
if (!policy) {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
await policy.recover(session);
|
||||
return { applied: true, policyId: policy.id };
|
||||
}
|
||||
|
||||
function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean {
|
||||
return (
|
||||
isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) ||
|
||||
isPiCopilotShort413Overflow(session.agent.state.errorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean {
|
||||
const normalized = errorMessage?.trim();
|
||||
return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false;
|
||||
}
|
||||
|
||||
function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null {
|
||||
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = session.messages[index];
|
||||
if (message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise<void> {
|
||||
try {
|
||||
await session.compact();
|
||||
} catch (error) {
|
||||
if (!isHarmlessPiCompactionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isHarmlessPiCompactionError(error: unknown): boolean {
|
||||
return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error));
|
||||
}
|
||||
Reference in New Issue
Block a user