Skip to content

Commit c5dd317

Browse files
committed
test(e2e): pin the recoverable-failure successor round trip
1 parent b1a6092 commit c5dd317

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

‎e2e/subagent-recoverable-failure.test.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, test } from "bun:test";
2+
import { type } from "arktype";
23

34
import { createSubAgentSessionStore } from "../src/subagent/index.js";
5+
import { waitAgentsToolDefinition } from "../src/subagent/agent-fleet.js";
46
import { withMockedModuleDuring } from "../testkit/mock-module.js";
57
import {
68
fromHost,
@@ -16,7 +18,15 @@ import {
1618
seedFile,
1719
} from "./harness.js";
1820

21+
const RequestURL = type({ url: "string" });
22+
1923
describe("e2e — recoverable worker failure does not stall the parent", () => {
24+
test.serial(
25+
"wait_agents documents the continuable successor contract",
26+
() => {
27+
expect(waitAgentsToolDefinition.description).toContain("continuable");
28+
},
29+
);
2030
test.serial(
2131
"a worker that dies retryably after tool use reports failed+continuable",
2232
async () => {
@@ -102,4 +112,124 @@ describe("e2e — recoverable worker failure does not stall the parent", () => {
102112
},
103113
60000,
104114
);
115+
test.serial(
116+
"a failed+continuable lane lets the parent spawn a successor sibling that runs to done",
117+
async () => {
118+
const fleetSessions = createSubAgentSessionStore();
119+
const session = await openE2ESession({
120+
permissionGate: e2ePermissionGate(),
121+
subAgent: {
122+
provider: WORKER_PROVIDER,
123+
sessions: fleetSessions,
124+
// Abort the inner retry outright — the production backoff waits
125+
// on the injected scheduler, which is inert under the harness.
126+
outerRetryDelayMs: 0,
127+
retryPolicy: () => ({ kind: "abort" }),
128+
},
129+
mountWaitAgents: true,
130+
toolAvailability: {
131+
languageServerAvailable: false,
132+
waitAgentsMounted: true,
133+
},
134+
});
135+
try {
136+
seedFile(session, "marker.txt", "the marker content\n");
137+
const parent = fromHost("api.anthropic.com");
138+
const worker = fromHost(WORKER_HOST);
139+
140+
session.harness.scenario.replyOnce("anthropic", {
141+
predicate: parent,
142+
text: "Dispatching a worker.",
143+
toolCalls: [
144+
{
145+
name: "spawn_agent",
146+
args: {
147+
description: "read the marker",
148+
prompt: "Read marker.txt and report its contents.",
149+
intent: "explore",
150+
},
151+
},
152+
],
153+
});
154+
// The first lane runs one real tool, then its inference dies
155+
// retryably — failed+continuable, like the marker test above.
156+
session.harness.scenario.replyOnce("openai", {
157+
predicate: worker,
158+
toolCalls: [{ name: "read", args: { path: "marker.txt" } }],
159+
});
160+
session.harness.scenario.replyOnce("openai", {
161+
predicate: worker,
162+
text: "upstream unavailable",
163+
responseOpts: { status: 503 },
164+
});
165+
session.harness.scenario.replyOnce("anthropic", {
166+
predicate: parent,
167+
text: "Collecting the failed worker.",
168+
toolCalls: [{ name: "wait_agents", args: { timeout_ms: 30_000 } }],
169+
});
170+
// One successor sibling with the same brief — the most the
171+
// continuable marker allows.
172+
session.harness.scenario.replyOnce("anthropic", {
173+
predicate: parent,
174+
text: "Spawning one successor.",
175+
toolCalls: [
176+
{
177+
name: "spawn_agent",
178+
args: {
179+
description: "read the marker",
180+
prompt: "Read marker.txt and report its contents.",
181+
intent: "explore",
182+
},
183+
},
184+
],
185+
});
186+
// A leaf report must carry the full four-heading envelope —
187+
// partial replies trigger the salvage/nudge path instead of done.
188+
session.harness.scenario.replyOnce("openai", {
189+
predicate: worker,
190+
text: "## Summary\nThe marker holds the marker content.\n## Findings\nnone\n## Blockers\nnone\n## Paths\nmarker.txt",
191+
});
192+
session.harness.scenario.replyOnce("anthropic", {
193+
predicate: parent,
194+
text: "Collecting the successor.",
195+
toolCalls: [{ name: "wait_agents", args: { timeout_ms: 30_000 } }],
196+
});
197+
session.harness.scenario.replyOnce("anthropic", {
198+
predicate: parent,
199+
text: "The successor recovered the lane.",
200+
});
201+
202+
const { events, reply } = await withMockedModuleDuring(
203+
import.meta.resolve("../src/session/assemble-runtime.js"),
204+
(real: typeof import("../src/session/assemble-runtime.js")) => ({
205+
...real,
206+
assembleInferenceBase: async () => session.harness.deps,
207+
}),
208+
() => runUntilDone(session, "Run the flaky job to recovery"),
209+
);
210+
211+
const [failed, settled] = waitAgentsResults(events);
212+
expect(failed?.timed_out).toBe(false);
213+
expect(failed?.results[0]?.status).toBe("failed");
214+
expect(failed?.results[0]?.continuable).toBe(true);
215+
expect(failed?.results[0]?.continue_with).toBeString();
216+
expect(settled?.timed_out).toBe(false);
217+
expect(settled?.results[0]?.status).toBe("done");
218+
expect(settled?.results[0]?.report).toContain("marker content");
219+
// A true sibling lane: a new agent, not a re-wait of the failed one.
220+
expect(settled?.results[0]?.agent_id).toBeString();
221+
expect(settled?.results[0]?.agent_id).not.toBe(
222+
failed?.results[0]?.agent_id,
223+
);
224+
const workerRequests = session.harness.scenario
225+
.matchedRequests()
226+
.filter((r) => RequestURL.assert(r).url.includes(WORKER_HOST));
227+
expect(workerRequests).toHaveLength(3);
228+
expect(reply).toContain("recovered the lane");
229+
} finally {
230+
await closeE2ESession(session);
231+
}
232+
},
233+
60000,
234+
);
105235
});

0 commit comments

Comments
 (0)