Skip to content

Commit 30fce90

Browse files
committed
test(coding-agent): cover provider-error goal recovery
Exercise the real AgentSession path from terminal provider failure through direct-input reactivation and completion while preserving deliberate blocks. Add an isolated RPC QA scenario that proves the same lifecycle through the source CLI without real credentials or provider traffic.
1 parent a2632b7 commit 30fce90

2 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4+
import { join } from "node:path";
5+
import { createChecks, evidenceDir, guardRealAuth, installCleanupHooks, makeSandbox } from "./lib/common.mjs";
6+
import { startFakeModelServer } from "./lib/fake-model-server.mjs";
7+
import { API_PRESETS, hermeticEnv, writeMockModelsJson } from "./lib/mock-loop-support.mjs";
8+
import { RpcQaClient } from "./lib/rpc-qa-client.mjs";
9+
10+
const PROVIDER_ERROR_REASON = "provider error ended the turn (retries exhausted)";
11+
const INTENTIONAL_REASON = "waiting on an explicit user decision";
12+
const TURNS = [
13+
{ toolCalls: [{ id: "qa-create-goal", name: "create_goal", args: { objective: "Survive a provider outage" } }] },
14+
{ error: { status: 400, message: "SENPI_QA_TERMINAL_PROVIDER_ERROR" } },
15+
{ toolCalls: [{ id: "qa-get-reactivated-goal", name: "get_goal", args: {} }] },
16+
{
17+
toolCalls: [
18+
{
19+
id: "qa-intentional-block",
20+
name: "update_goal",
21+
args: { status: "blocked", reason: INTENTIONAL_REASON },
22+
},
23+
],
24+
},
25+
{ text: "SENPI-QA-INTENTIONAL-BLOCK-SET" },
26+
{ toolCalls: [{ id: "qa-get-still-blocked-goal", name: "get_goal", args: {} }] },
27+
{ text: "SENPI-QA-INTENTIONAL-BLOCK-PRESERVED" },
28+
];
29+
30+
function flag(name) {
31+
const index = process.argv.indexOf(name);
32+
return index >= 0 ? process.argv[index + 1] : undefined;
33+
}
34+
35+
function findJsonFiles(root) {
36+
if (!existsSync(root)) return [];
37+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
38+
const path = join(root, entry.name);
39+
if (entry.isDirectory()) return findJsonFiles(path);
40+
return entry.isFile() && entry.name.endsWith(".json") ? [path] : [];
41+
});
42+
}
43+
44+
function readGoal(agentDir) {
45+
const root = join(agentDir, "extensions", "goal");
46+
const files = findJsonFiles(root);
47+
if (files.length !== 1) throw new Error(`Expected one goal under ${root}, found ${files.length}`);
48+
const parsed = JSON.parse(readFileSync(files[0], "utf8"));
49+
const goal = parsed?.goal ?? parsed;
50+
if (!goal || typeof goal.status !== "string") throw new Error(`Invalid goal record in ${files[0]}`);
51+
return goal;
52+
}
53+
54+
function toolGoal(events, toolCallId) {
55+
const event = events.find(
56+
(candidate) => candidate.type === "tool_execution_end" && candidate.toolCallId === toolCallId,
57+
);
58+
const text = event?.result?.content?.find((part) => part.type === "text")?.text;
59+
if (typeof text !== "string") throw new Error(`Missing result for ${toolCallId}`);
60+
const goal = JSON.parse(text)?.goal;
61+
if (!goal || typeof goal.status !== "string") throw new Error(`Invalid goal result for ${toolCallId}`);
62+
return goal;
63+
}
64+
65+
function safeRequests(requests) {
66+
return requests.map((request, index) => ({
67+
index: index + 1,
68+
method: request.method,
69+
url: request.url,
70+
model: request.model,
71+
messageCount: Array.isArray(request.messages) ? request.messages.length : null,
72+
}));
73+
}
74+
75+
function writeJson(path, value) {
76+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
77+
}
78+
79+
async function runPrompt(client, message) {
80+
const afterIndex = client.events.length;
81+
const terminalPromise = client.waitForEvent(
82+
(event) => event.type === "agent_end" || event.type === "agent_aborted",
83+
afterIndex,
84+
60_000,
85+
);
86+
const acknowledgement = await client.send({ type: "prompt", message }, 15_000);
87+
return { acknowledgement, terminal: await terminalPromise };
88+
}
89+
90+
async function main() {
91+
if (!process.argv.includes("--self-test")) {
92+
process.stderr.write("usage: goal-provider-error-recovery.mjs --self-test [--evidence SLUG]\n");
93+
process.exitCode = 2;
94+
return;
95+
}
96+
97+
installCleanupHooks();
98+
const checks = createChecks("goal-provider-error-recovery.mjs --self-test");
99+
const evidence = evidenceDir(flag("--evidence") ?? "goal-provider-error-recovery");
100+
const authGuard = guardRealAuth();
101+
const box = makeSandbox("senpi-qa-goal-provider-error-recovery");
102+
const preset = API_PRESETS["openai-completions"];
103+
const observed = { terminals: [], goals: {}, requestCount: 0, localhostOnly: false };
104+
let server;
105+
let client;
106+
let rpcExitCode = null;
107+
let realAuthUnchanged = false;
108+
109+
try {
110+
server = await startFakeModelServer({ turns: TURNS });
111+
writeMockModelsJson(box.agentDir, server, "openai-completions", {}, {
112+
retry: {
113+
enabled: false,
114+
maxRetries: 0,
115+
baseDelayMs: 0,
116+
provider: { maxRetries: 0, maxRetryDelayMs: 0 },
117+
fallbackChains: {},
118+
},
119+
});
120+
client = new RpcQaClient({
121+
env: hermeticEnv(box.env),
122+
cwd: box.cwd,
123+
extraArgs: ["--provider", preset.provider, "--model", preset.modelId],
124+
});
125+
126+
const state = await client.send({ type: "get_state" });
127+
checks.ok("RPC booted in the isolated sandbox", state.success === true && state.command === "get_state");
128+
129+
const first = await runPrompt(client, "Create the scripted recovery goal and begin it.");
130+
observed.terminals.push(first.terminal.type);
131+
checks.ok(
132+
"provider-error turn ended through agent_end",
133+
first.acknowledgement.success === true && first.terminal.type === "agent_end",
134+
);
135+
observed.goals.providerError = readGoal(box.agentDir);
136+
checks.ok("provider error stopped after exactly two localhost requests", server.requests.length === 2);
137+
checks.ok(
138+
"provider error persisted the mechanical blocked reason",
139+
observed.goals.providerError.status === "blocked" &&
140+
observed.goals.providerError.blockedReason === PROVIDER_ERROR_REASON,
141+
);
142+
143+
const second = await runPrompt(
144+
client,
145+
"Retry the goal now, inspect its status, then apply the scripted intentional block.",
146+
);
147+
observed.terminals.push(second.terminal.type);
148+
checks.ok(
149+
"next direct prompt was accepted",
150+
second.acknowledgement.success === true && second.terminal.type === "agent_end",
151+
);
152+
observed.goals.reactivated = toolGoal(client.events, "qa-get-reactivated-goal");
153+
checks.ok(
154+
"get_goal observed active before the next model action",
155+
observed.goals.reactivated.status === "active" &&
156+
observed.goals.reactivated.blockedReason === undefined,
157+
);
158+
observed.goals.intentional = readGoal(box.agentDir);
159+
checks.ok(
160+
"model-authored update_goal persisted an intentional block",
161+
observed.goals.intentional.status === "blocked" &&
162+
observed.goals.intentional.blockedReason === INTENTIONAL_REASON,
163+
);
164+
165+
const third = await runPrompt(
166+
client,
167+
"Inspect the intentionally blocked goal without explicitly resuming it.",
168+
);
169+
observed.terminals.push(third.terminal.type);
170+
checks.ok(
171+
"later direct prompt was accepted",
172+
third.acknowledgement.success === true && third.terminal.type === "agent_end",
173+
);
174+
observed.goals.toolPreserved = toolGoal(client.events, "qa-get-still-blocked-goal");
175+
observed.goals.preserved = readGoal(box.agentDir);
176+
checks.ok(
177+
"get_goal observed the intentional block unchanged",
178+
observed.goals.toolPreserved.status === "blocked" &&
179+
observed.goals.toolPreserved.blockedReason === INTENTIONAL_REASON &&
180+
observed.goals.preserved.id === observed.goals.intentional.id &&
181+
observed.goals.preserved.status === "blocked" &&
182+
observed.goals.preserved.blockedReason === INTENTIONAL_REASON &&
183+
Number.isFinite(observed.goals.intentional.blockedAt) &&
184+
observed.goals.preserved.blockedAt === observed.goals.intentional.blockedAt,
185+
);
186+
187+
observed.requestCount = server.requests.length;
188+
checks.ok("scripted run made exactly seven localhost provider requests", observed.requestCount === 7);
189+
observed.localhostOnly =
190+
server.origin.startsWith("http://127.0.0.1:") &&
191+
server.requests.every(
192+
(request) => request.method === "POST" && request.url?.endsWith("/chat/completions"),
193+
);
194+
checks.ok("zero real provider calls", observed.localhostOnly, server.origin);
195+
} catch (error) {
196+
checks.ok("scenario completed without an exception", false, error instanceof Error ? error.message : String(error));
197+
} finally {
198+
if (client) {
199+
client.close();
200+
try {
201+
rpcExitCode = await client.waitForExit(5_000);
202+
} catch {
203+
client.kill();
204+
rpcExitCode = await client.waitForExit(5_000).catch(() => null);
205+
}
206+
checks.ok("RPC process exited after stdin closed", rpcExitCode === 0, `exitCode=${rpcExitCode}`);
207+
}
208+
if (server) await server.stop();
209+
try {
210+
realAuthUnchanged = authGuard.assertUnchanged();
211+
} catch {
212+
realAuthUnchanged = false;
213+
}
214+
checks.ok("real auth unchanged", realAuthUnchanged, authGuard.path);
215+
box.cleanup();
216+
checks.ok("isolated sandbox removed", !existsSync(box.dir), box.dir);
217+
}
218+
219+
const pass = checks.finish();
220+
writeJson(join(evidence, "summary.json"), {
221+
pass,
222+
terminalEvents: observed.terminals,
223+
providerRequestCount: observed.requestCount,
224+
providerErrorStatus: observed.goals.providerError?.status ?? null,
225+
reactivatedStatus: observed.goals.reactivated?.status ?? null,
226+
intentionalStatus: observed.goals.preserved?.status ?? null,
227+
blockedAtUnchanged:
228+
observed.goals.intentional?.blockedAt === observed.goals.preserved?.blockedAt,
229+
localhostOnly: observed.localhostOnly,
230+
realAuthUnchanged,
231+
rpcExitCode,
232+
serverStopped: server !== undefined,
233+
sandboxRemoved: !existsSync(box.dir),
234+
});
235+
writeFileSync(
236+
join(evidence, "rpc-events.jsonl"),
237+
`${(client?.events ?? []).map((event) => JSON.stringify(event)).join("\n")}\n`,
238+
);
239+
for (const [name, goal] of Object.entries(observed.goals)) {
240+
writeJson(join(evidence, `goal-${name}.json`), goal);
241+
}
242+
writeJson(join(evidence, "mock-request-summary.json"), safeRequests(server?.requests ?? []));
243+
process.stdout.write(`Evidence: ${evidence}\n`);
244+
process.exitCode = pass ? 0 : 1;
245+
}
246+
247+
await main();
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import goalExtension from "../../src/core/extensions/builtin/goal/index.ts";
4+
import { createGoal, readGoal, updateGoal } from "../../src/core/extensions/builtin/goal/store.ts";
5+
import { goalStoreRef } from "../../src/core/extensions/builtin/goal/store-ref.ts";
6+
import type { GoalStatus } from "../../src/core/extensions/builtin/goal/types.ts";
7+
import type { ExtensionAPI } from "../../src/core/extensions/types.ts";
8+
import { GOAL_CONTINUATION_MESSAGE_TYPE } from "../../src/core/messages.ts";
9+
import { createHarness, type Harness } from "./harness.ts";
10+
11+
const PROVIDER_ERROR_BLOCKED_REASON = "provider error ended the turn (retries exhausted)";
12+
const INTENTIONAL_BLOCKED_REASON = "Waiting on a user decision";
13+
const harnesses: Harness[] = [];
14+
15+
afterEach(() => {
16+
for (const harness of harnesses.splice(0)) {
17+
harness.cleanup();
18+
}
19+
});
20+
21+
function goalContinuationCount(harness: Harness): number {
22+
return harness.sessionManager
23+
.getEntries()
24+
.filter((entry) => entry.type === "custom_message" && entry.customType === GOAL_CONTINUATION_MESSAGE_TYPE).length;
25+
}
26+
27+
async function createProviderBlockedHarness(): Promise<{
28+
harness: Harness;
29+
ref: ReturnType<typeof goalStoreRef>;
30+
statusesAtAgentStart: Array<GoalStatus | null>;
31+
}> {
32+
const statusesAtAgentStart: Array<GoalStatus | null> = [];
33+
let ref: ReturnType<typeof goalStoreRef> | undefined;
34+
const observeGoalAtAgentStart = (pi: ExtensionAPI) => {
35+
pi.on("agent_start", async () => {
36+
statusesAtAgentStart.push(ref ? ((await readGoal(ref))?.status ?? null) : null);
37+
});
38+
};
39+
const harness = await createHarness({
40+
persistSession: true,
41+
settings: { retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 } },
42+
extensionFactories: [goalExtension, observeGoalAtAgentStart],
43+
});
44+
harnesses.push(harness);
45+
ref = goalStoreRef(harness.sessionManager, harness.tempDir);
46+
harness.setResponses([
47+
fauxAssistantMessage([fauxToolCall("create_goal", { objective: "Resume this goal after a provider outage" })], {
48+
stopReason: "toolUse",
49+
}),
50+
fauxAssistantMessage("", {
51+
stopReason: "error",
52+
errorMessage: "SENPI_TEST_TERMINAL_PROVIDER_ERROR",
53+
}),
54+
]);
55+
56+
await harness.session.prompt("create the recovery goal");
57+
58+
expect(await readGoal(ref)).toMatchObject({
59+
status: "blocked",
60+
blockedReason: PROVIDER_ERROR_BLOCKED_REASON,
61+
});
62+
return { harness, ref, statusesAtAgentStart };
63+
}
64+
65+
function completionResponses() {
66+
return [
67+
fauxAssistantMessage([fauxToolCall("update_goal", { status: "complete" })], {
68+
stopReason: "toolUse",
69+
}),
70+
fauxAssistantMessage("goal recovered and completed"),
71+
];
72+
}
73+
74+
describe("provider-error goal recovery through the real AgentSession", () => {
75+
it("reactivates a provider-error block before the next direct user run", async () => {
76+
const { harness, ref, statusesAtAgentStart } = await createProviderBlockedHarness();
77+
harness.setResponses(completionResponses());
78+
79+
await harness.session.prompt("retry the blocked goal");
80+
81+
expect(statusesAtAgentStart).toEqual([null, "active"]);
82+
expect(await readGoal(ref)).toMatchObject({ status: "complete" });
83+
});
84+
85+
it("does not auto-continue after an exhausted provider error", async () => {
86+
const { harness, ref, statusesAtAgentStart } = await createProviderBlockedHarness();
87+
88+
expect(harness.eventsOfType("agent_end")).toHaveLength(1);
89+
expect(harness.eventsOfType("agent_end")[0]?.willRetry).toBe(false);
90+
expect(goalContinuationCount(harness)).toBe(0);
91+
expect(harness.faux.state.callCount).toBe(2);
92+
93+
harness.setResponses(completionResponses());
94+
await harness.session.prompt("resume exactly once");
95+
96+
expect(statusesAtAgentStart).toEqual([null, "active"]);
97+
expect(harness.faux.state.callCount).toBe(4);
98+
expect(goalContinuationCount(harness)).toBe(0);
99+
expect(await readGoal(ref)).toMatchObject({ status: "complete" });
100+
});
101+
102+
it("keeps a deliberate block blocked on direct user input", async () => {
103+
const statusesAtAgentStart: Array<GoalStatus | null> = [];
104+
let ref: ReturnType<typeof goalStoreRef> | undefined;
105+
const observeGoalAtAgentStart = (pi: ExtensionAPI) => {
106+
pi.on("agent_start", async () => {
107+
statusesAtAgentStart.push(ref ? ((await readGoal(ref))?.status ?? null) : null);
108+
});
109+
};
110+
const harness = await createHarness({
111+
persistSession: true,
112+
extensionFactories: [goalExtension, observeGoalAtAgentStart],
113+
});
114+
harnesses.push(harness);
115+
await harness.session.bindExtensions({});
116+
ref = goalStoreRef(harness.sessionManager, harness.tempDir);
117+
await createGoal(ref, "Wait for a user decision");
118+
const deliberatelyBlocked = await updateGoal(
119+
ref,
120+
{ status: "blocked", reason: INTENTIONAL_BLOCKED_REASON },
121+
"model",
122+
);
123+
expect(deliberatelyBlocked.blockedAt).toEqual(expect.any(Number));
124+
harness.setResponses([fauxAssistantMessage("the block remains intentional")]);
125+
126+
await harness.session.prompt("inspect without resuming");
127+
128+
expect(statusesAtAgentStart).toEqual(["blocked"]);
129+
expect(await readGoal(ref)).toMatchObject({
130+
status: "blocked",
131+
blockedReason: INTENTIONAL_BLOCKED_REASON,
132+
blockedAt: deliberatelyBlocked.blockedAt,
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)