Merge branch 'main' of github.com:getpaseo/paseo
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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
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 pino from "pino";
|
||||||
|
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
@@ -13,12 +13,46 @@ import {
|
|||||||
type PiDirectSessionAdapter,
|
type PiDirectSessionAdapter,
|
||||||
} from "./pi-direct-agent.js";
|
} 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 {
|
return {
|
||||||
sessionId: "pi-session-1",
|
sessionId: "pi-session-1",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
model: undefined,
|
model: undefined,
|
||||||
messages: [],
|
messages: options.messages ?? [],
|
||||||
extensionRunner: undefined,
|
extensionRunner: undefined,
|
||||||
promptTemplates: [],
|
promptTemplates: [],
|
||||||
resourceLoader: {
|
resourceLoader: {
|
||||||
@@ -27,7 +61,7 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
|||||||
agent: {
|
agent: {
|
||||||
state: {
|
state: {
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
errorMessage: null,
|
errorMessage: options.errorMessage ?? null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionManager: {
|
sessionManager: {
|
||||||
@@ -36,6 +70,7 @@ function createPiSession(prompt: () => Promise<void>): PiDirectSessionAdapter {
|
|||||||
},
|
},
|
||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
prompt,
|
prompt,
|
||||||
|
compact: options.compact ?? vi.fn(async () => undefined),
|
||||||
abort: vi.fn(),
|
abort: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
getSessionStats: 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 () => {
|
test("setModel creates a minimal model for new ids under a known provider", async () => {
|
||||||
const sdkSession = createPiSession(async () => undefined);
|
const sdkSession = createPiSession(async () => undefined);
|
||||||
const session = new PiDirectAgentSession(
|
const session = new PiDirectAgentSession(
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
resolveBinaryVersion,
|
resolveBinaryVersion,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
|
import { applyPiSessionRecoveryPolicy } from "./pi-session-recovery-policy.js";
|
||||||
|
|
||||||
const PI_PROVIDER = "pi";
|
const PI_PROVIDER = "pi";
|
||||||
const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium";
|
const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium";
|
||||||
@@ -116,6 +117,7 @@ export type PiDirectSessionAdapter = Pick<
|
|||||||
PiAgentSession,
|
PiAgentSession,
|
||||||
| "abort"
|
| "abort"
|
||||||
| "agent"
|
| "agent"
|
||||||
|
| "compact"
|
||||||
| "dispose"
|
| "dispose"
|
||||||
| "extensionRunner"
|
| "extensionRunner"
|
||||||
| "getSessionStats"
|
| "getSessionStats"
|
||||||
@@ -1096,6 +1098,19 @@ export class PiDirectAgentSession implements AgentSession {
|
|||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
this.activeTurnId = turnId;
|
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
|
void this.session
|
||||||
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
|
.prompt(payload.text, payload.images ? { images: payload.images } : undefined)
|
||||||
.catch((error) => {
|
.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