Recover Pi sessions after Copilot 413 (#995)

This commit is contained in:
Mohamed Boudra
2026-05-13 23:00:22 +08:00
committed by GitHub
parent bc329a2d85
commit 68fae16740
4 changed files with 315 additions and 4 deletions

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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");
});
});

View File

@@ -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));
}