Skip to content

Commit f40a375

Browse files
committed
test(e2e): cover a denied worker write approved once and retried
1 parent 5c675ca commit f40a375

3 files changed

Lines changed: 301 additions & 1 deletion

File tree

‎e2e/fleet.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,44 @@ export const WaitAgentsResult = type({
3232
"error?": "string",
3333
"question?": "string",
3434
"question_id?": "string",
35+
"grant_request_id?": "string",
3536
"report?": "string",
3637
}).array(),
3738
timed_out: "boolean",
3839
});
3940

41+
/** Extract the public worker-grant correlation from serialized tool output. */
42+
export function waitAgentsGrantRequestId(body: string): string | undefined {
43+
const payload = JSON.parse(body) as unknown;
44+
const contents = serializedContents(payload);
45+
for (const content of contents) {
46+
try {
47+
const result = WaitAgentsResult.assert(content);
48+
const grantRequestId = result.results.find(
49+
(lane) => lane.grant_request_id !== undefined,
50+
)?.grant_request_id;
51+
if (grantRequestId !== undefined) return grantRequestId;
52+
} catch {
53+
// Request messages that are not wait_agents results are irrelevant here.
54+
}
55+
}
56+
return undefined;
57+
}
58+
59+
function serializedContents(value: unknown): unknown[] {
60+
if (typeof value === "string") {
61+
try {
62+
return [JSON.parse(value)];
63+
} catch {
64+
return [];
65+
}
66+
}
67+
if (Array.isArray(value)) return value.flatMap(serializedContents);
68+
if (value !== null && typeof value === "object")
69+
return Object.values(value).flatMap(serializedContents);
70+
return [];
71+
}
72+
4073
/** Every wait_agents tool result in the event stream, in call order. */
4174
export function waitAgentsResults(
4275
events: ReactorEmittedEvent[],

‎e2e/harness.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { dirname, join } from "node:path";
1414

1515
import type { ReactorEmittedEvent } from "@intx/inference";
1616
import type { InboundMessage } from "@intx/types/runtime";
17+
import {
18+
wire,
19+
type HarnessRequest,
20+
type RequestPredicate,
21+
} from "@intx/inference-testing";
1722

1823
import { OPERATOR_ORIGINATED_FLAG } from "../src/agent/message-provenance.js";
1924
import {
@@ -154,6 +159,50 @@ export interface ScriptedReply {
154159
toolCalls?: { name: string; args: Record<string, unknown> }[];
155160
}
156161

162+
export interface RuntimeReplyRequest {
163+
body: string;
164+
request: HarnessRequest;
165+
}
166+
167+
/**
168+
* Register one response whose payload is built when its matching request
169+
* arrives. This keeps correlation values discovered during a scenario out of
170+
* eagerly serialized `replyOnce` fixtures.
171+
*/
172+
export function replyOnceAtRequest(
173+
session: E2ESession,
174+
provider: Parameters<typeof wire.completeResponse>[0],
175+
predicate: RequestPredicate,
176+
reply: (request: RuntimeReplyRequest) => ScriptedReply | undefined,
177+
): void {
178+
const stream = session.harness.scenario.createStream();
179+
session.harness.scenario.whenRequestBodyMatches((body, request) => {
180+
if (!predicate(request)) return false;
181+
const response = reply({ body, request });
182+
if (response === undefined) return false;
183+
const chunks = wire.completeResponse(provider, {
184+
text: response.text ?? "",
185+
...(response.toolCalls === undefined
186+
? {}
187+
: {
188+
toolCalls: response.toolCalls.map((call, index) => ({
189+
callId: `runtime_call_${crypto.randomUUID()}_${index}`,
190+
name: call.name,
191+
argsJSON: JSON.stringify(call.args),
192+
})),
193+
}),
194+
});
195+
stream.enqueueAll(chunks, { startAt: session.harness.clock.now() + 1 });
196+
// A request-time reply can land after runUntilDone's harness.run has
197+
// gone quiescent (e.g. a worker request while the parent waits on the
198+
// wall clock), so drive the clock for the chunks just scheduled.
199+
void session.harness
200+
.run({ wallClockBudgetMs: Infinity })
201+
.catch(() => undefined);
202+
return true;
203+
}, stream);
204+
}
205+
157206
/**
158207
* Queue one model response per inference request, in order — the common
159208
* "model calls tools, then answers" spine. Use

‎e2e/subagent-orchestration.test.ts‎

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { describe, expect, test } from "bun:test";
2+
import { join } from "node:path";
23
import { type } from "arktype";
34

5+
import { createPermissionGate } from "../src/permission/gate.js";
6+
47
import { createSubAgentSessionStore } from "../src/subagent/index.js";
58
import { withMockedModuleDuring } from "../testkit/mock-module.js";
69
import {
710
fromHost,
11+
waitAgentsGrantRequestId,
812
waitAgentsResults,
913
WORKER_HOST,
1014
WORKER_PROVIDER,
@@ -13,17 +17,43 @@ import {
1317
closeE2ESession,
1418
e2ePermissionGate,
1519
openE2ESession,
20+
replyOnceAtRequest,
1621
runUntilDone,
1722
} from "./harness.js";
1823
import { DIRECTOR_RESOLVABLE_ESCALATION } from "../testkit/escalation.js";
1924

2025
const RequestURL = type({ url: "string" });
26+
type AuthorizationCheckpoint =
27+
| "denied-write"
28+
| "asked-for-approval"
29+
| "approval-issued"
30+
| "authorized-retry";
31+
32+
test("wait_agents grant correlation accepts pretty serialized tool output", () => {
33+
const result = JSON.stringify(
34+
{
35+
results: [
36+
{
37+
agent_id: "lane-1",
38+
grant_request_id: "grant-1",
39+
status: "awaiting_director",
40+
},
41+
],
42+
timed_out: false,
43+
},
44+
null,
45+
2,
46+
);
47+
const request = JSON.stringify({ messages: [{ content: result }] });
48+
expect(waitAgentsGrantRequestId(request)).toBe("grant-1");
49+
});
2150

2251
async function openFleetSession(
2352
sessions: ReturnType<typeof createSubAgentSessionStore>,
53+
permissionGate = e2ePermissionGate(),
2454
) {
2555
return openE2ESession({
26-
permissionGate: e2ePermissionGate(),
56+
permissionGate,
2757
subAgent: {
2858
provider: WORKER_PROVIDER,
2959
sessions,
@@ -140,6 +170,194 @@ describe("e2e — fleet orchestration", () => {
140170
60000,
141171
);
142172

173+
test.serial(
174+
"a real denied worker write is approved once with live correlation IDs and retried once",
175+
async () => {
176+
const fleetSessions = createSubAgentSessionStore();
177+
const session = await openFleetSession(
178+
fleetSessions,
179+
createPermissionGate({
180+
approvals: [
181+
{ tool: "spawn_agent", pattern: "*" },
182+
{ tool: "wait_agents", pattern: "*" },
183+
{ tool: "approve_worker_call", pattern: "*" },
184+
],
185+
interactive: true,
186+
skipPermissions: false,
187+
reactorGated: true,
188+
requestApproval: async () => ({ allow: true }),
189+
}),
190+
);
191+
try {
192+
const parent = fromHost("api.anthropic.com");
193+
const worker = fromHost(WORKER_HOST);
194+
const output = "approved.txt";
195+
const checkpoints: AuthorizationCheckpoint[] = [];
196+
let approvedRequestId: string | undefined;
197+
session.harness.scenario.replyOnce("anthropic", {
198+
predicate: parent,
199+
toolCalls: [
200+
{
201+
name: "spawn_agent",
202+
args: {
203+
description: "authorized write lane",
204+
prompt:
205+
"Write approved.txt with approved, ask for authorization if denied, then report.",
206+
intent: "implement",
207+
success_criteria: ["approved.txt contains approved"],
208+
},
209+
},
210+
],
211+
});
212+
session.harness.scenario.replyOnce("openai", {
213+
predicate: worker,
214+
toolCalls: [
215+
{ name: "write_file", args: { path: output, content: "approved" } },
216+
],
217+
});
218+
replyOnceAtRequest(session, "openai", worker, ({ body }) => {
219+
const grantRequestId = /grant request ([\w-]+)/.exec(body)?.[1];
220+
if (grantRequestId === undefined) {
221+
throw new Error(
222+
`worker did not receive the denied-call grant marker: ${body}`,
223+
);
224+
}
225+
checkpoints.push("denied-write");
226+
return {
227+
toolCalls: [
228+
{
229+
name: "ask_director",
230+
args: {
231+
question: "Please approve the denied write.",
232+
grant_request_id: grantRequestId,
233+
escalation: DIRECTOR_RESOLVABLE_ESCALATION,
234+
},
235+
},
236+
],
237+
};
238+
});
239+
replyOnceAtRequest(session, "openai", worker, ({ body }) =>
240+
body.includes("Parent approved the exact denied call")
241+
? (checkpoints.push("authorized-retry"),
242+
{
243+
toolCalls: [
244+
{
245+
name: "write_file",
246+
args: { path: output, content: "approved" },
247+
},
248+
],
249+
})
250+
: undefined,
251+
);
252+
replyOnceAtRequest(session, "openai", worker, ({ body }) =>
253+
body.includes("wrote 8 bytes to")
254+
? {
255+
text: "## Summary\nWrite completed.\n## Findings\nAuthorized retry succeeded.\n## Blockers\nNone.\n## Paths\napproved.txt",
256+
}
257+
: undefined,
258+
);
259+
session.harness.scenario.replyOnce("anthropic", {
260+
predicate: parent,
261+
toolCalls: [{ name: "wait_agents", args: { timeout_ms: 30_000 } }],
262+
});
263+
session.harness.scenario.replyOnce("anthropic", {
264+
predicate: parent,
265+
toolCalls: [{ name: "wait_agents", args: { timeout_ms: 30_000 } }],
266+
});
267+
replyOnceAtRequest(session, "anthropic", parent, ({ body }) => {
268+
const lane = fleetSessions.list()[0];
269+
const ask =
270+
lane === undefined ? undefined : fleetSessions.peekAsk(lane.id);
271+
const requestId = ask?.deniedCall?.requestId;
272+
const publicRequestId = waitAgentsGrantRequestId(body);
273+
if (
274+
lane === undefined ||
275+
ask?.questionId === undefined ||
276+
requestId === undefined ||
277+
publicRequestId === undefined
278+
) {
279+
return undefined;
280+
}
281+
if (publicRequestId !== requestId) {
282+
throw new Error(
283+
`wait_agents grant_request_id ${publicRequestId} did not match pending ask ${requestId}`,
284+
);
285+
}
286+
checkpoints.push("asked-for-approval");
287+
approvedRequestId = publicRequestId;
288+
const args = {
289+
target: lane.id,
290+
question_id: ask.questionId,
291+
request_id: publicRequestId,
292+
};
293+
return { toolCalls: [{ name: "approve_worker_call", args }] };
294+
});
295+
replyOnceAtRequest(session, "anthropic", parent, ({ body }) =>
296+
/\\?"status\\?":\s*\\?"authorized/.test(body)
297+
? (checkpoints.push("approval-issued"),
298+
{
299+
toolCalls: [
300+
{ name: "wait_agents", args: { timeout_ms: 30_000 } },
301+
],
302+
})
303+
: undefined,
304+
);
305+
replyOnceAtRequest(session, "anthropic", parent, ({ body }) =>
306+
body.includes("Write completed")
307+
? { text: "The approved worker write completed." }
308+
: undefined,
309+
);
310+
311+
const result = await withMockedModuleDuring(
312+
import.meta.resolve("../src/session/assemble-runtime.js"),
313+
(real: typeof import("../src/session/assemble-runtime.js")) => ({
314+
...real,
315+
assembleInferenceBase: async () => session.harness.deps,
316+
}),
317+
() => runUntilDone(session, "Run the authorized write"),
318+
);
319+
320+
const waits = waitAgentsResults(result.events);
321+
const parked = waits[0];
322+
const settled = waits.at(-1);
323+
const lane = parked?.results[0];
324+
expect(checkpoints.slice(0, 2)).toEqual([
325+
"denied-write",
326+
"asked-for-approval",
327+
]);
328+
expect(checkpoints.slice(2).sort()).toEqual([
329+
"approval-issued",
330+
"authorized-retry",
331+
]);
332+
expect(lane?.status).toBe("awaiting_director");
333+
expect(lane?.question_id).toBeString();
334+
expect(lane?.grant_request_id).toBeString();
335+
expect(lane?.grant_request_id).toBe(approvedRequestId);
336+
expect(settled?.results[0]?.status).toBe("done");
337+
expect(await Bun.file(join(session.cwd, output)).text()).toBe(
338+
"approved",
339+
);
340+
const approvals = result.events.filter(
341+
(event) =>
342+
event.type === "tool.done" &&
343+
typeof event.data.result.content === "string" &&
344+
/"status":\s*"authorized"/.test(event.data.result.content),
345+
);
346+
expect(approvals).toHaveLength(1);
347+
const workerRequests = session.harness.scenario
348+
.matchedRequests()
349+
.filter((request) =>
350+
RequestURL.assert(request).url.includes(WORKER_HOST),
351+
);
352+
expect(workerRequests).toHaveLength(4);
353+
expect(result.reply).toContain("completed");
354+
} finally {
355+
await closeE2ESession(session);
356+
}
357+
},
358+
60000,
359+
);
360+
143361
test.serial(
144362
"a retryable first send triggers the outer retry and the worker recovers",
145363
async () => {

0 commit comments

Comments
 (0)