diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts index 20de3cbbb..b75bdf2b0 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts @@ -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): 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, + options: { + compact?: () => Promise; + 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): PiDirectSessionAdapter { agent: { state: { systemPrompt: "", - errorMessage: null, + errorMessage: options.errorMessage ?? null, }, }, sessionManager: { @@ -36,6 +70,7 @@ function createPiSession(prompt: () => Promise): 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( diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts index 1c692ef12..79a6528d9 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.ts @@ -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) => { diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts new file mode 100644 index 000000000..302b7f075 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts @@ -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; + } = {}, +): 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"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts new file mode 100644 index 000000000..c52ff093c --- /dev/null +++ b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts @@ -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; +} + +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; +} + +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 { + 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 { + 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)); +}