Skip to content

Commit eb274b3

Browse files
committed
test(e2e): cover a denied worker write approved once and retried
1 parent 83c6eef commit eb274b3

3 files changed

Lines changed: 340 additions & 1 deletion

File tree

‎e2e/fleet.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,45 @@ 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+
/**
42+
* A scenario has to approve the grant while it builds the parent's next reply,
43+
* before `runUntilDone` hands back any events, so the id comes from the
44+
* inference request body, where the provider nests each tool result as a
45+
* JSON string.
46+
*/
47+
export function waitAgentsGrantRequestId(body: string): string | undefined {
48+
const payload = JSON.parse(body) as unknown;
49+
for (const content of serializedContents(payload)) {
50+
const result = WaitAgentsResult(content);
51+
if (result instanceof type.errors) continue;
52+
const grantRequestId = result.results.find(
53+
(lane) => lane.grant_request_id !== undefined,
54+
)?.grant_request_id;
55+
if (grantRequestId !== undefined) return grantRequestId;
56+
}
57+
return undefined;
58+
}
59+
60+
function serializedContents(value: unknown): unknown[] {
61+
if (typeof value === "string") {
62+
try {
63+
return [JSON.parse(value)];
64+
} catch {
65+
return [];
66+
}
67+
}
68+
if (Array.isArray(value)) return value.flatMap(serializedContents);
69+
if (value !== null && typeof value === "object")
70+
return Object.values(value).flatMap(serializedContents);
71+
return [];
72+
}
73+
4074
/** Every wait_agents tool result in the event stream, in call order. */
4175
export function waitAgentsResults(
4276
events: ReactorEmittedEvent[],

‎e2e/harness.ts‎

Lines changed: 82 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,83 @@ 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+
export interface RuntimeReplies {
168+
/**
169+
* Register one response whose payload is built when its matching request
170+
* arrives. This keeps correlation values discovered during a scenario out
171+
* of eagerly serialized `replyOnce` fixtures.
172+
*/
173+
replyOnce: (
174+
provider: Parameters<typeof wire.completeResponse>[0],
175+
predicate: RequestPredicate,
176+
reply: (request: RuntimeReplyRequest) => ScriptedReply | undefined,
177+
) => void;
178+
/**
179+
* Rejects with the first error a reply builder or its clock run raises.
180+
* Race the scenario against it: the harness would otherwise park the error
181+
* for whichever `harness.run` checks next, and a background run that takes
182+
* it first loses it, leaving only the test timeout.
183+
*/
184+
failed: Promise<never>;
185+
}
186+
187+
export function createRuntimeReplies(session: E2ESession): RuntimeReplies {
188+
let fail: (error: unknown) => void = () => undefined;
189+
const failed = new Promise<never>((_, reject) => {
190+
fail = reject;
191+
});
192+
// A failure that lands after the race settled must not surface as an
193+
// unhandled rejection.
194+
failed.catch(() => undefined);
195+
const replyOnce: RuntimeReplies["replyOnce"] = (provider, predicate, reply) =>
196+
registerRuntimeReply(session, provider, predicate, reply, fail);
197+
return { replyOnce, failed };
198+
}
199+
200+
function registerRuntimeReply(
201+
session: E2ESession,
202+
provider: Parameters<typeof wire.completeResponse>[0],
203+
predicate: RequestPredicate,
204+
reply: (request: RuntimeReplyRequest) => ScriptedReply | undefined,
205+
fail: (error: unknown) => void,
206+
): void {
207+
const stream = session.harness.scenario.createStream();
208+
session.harness.scenario.whenRequestBodyMatches((body, request) => {
209+
if (!predicate(request)) return false;
210+
let response: ScriptedReply | undefined;
211+
try {
212+
response = reply({ body, request });
213+
} catch (error) {
214+
fail(error);
215+
return false;
216+
}
217+
if (response === undefined) return false;
218+
const chunks = wire.completeResponse(provider, {
219+
text: response.text ?? "",
220+
...(response.toolCalls === undefined
221+
? {}
222+
: {
223+
toolCalls: response.toolCalls.map((call, index) => ({
224+
callId: `runtime_call_${crypto.randomUUID()}_${index}`,
225+
name: call.name,
226+
argsJSON: JSON.stringify(call.args),
227+
})),
228+
}),
229+
});
230+
stream.enqueueAll(chunks, { startAt: session.harness.clock.now() + 1 });
231+
// A request-time reply can land after runUntilDone's harness.run has
232+
// gone quiescent (e.g. a worker request while the parent waits on the
233+
// wall clock), so drive the clock for the chunks just scheduled.
234+
void session.harness.run({ wallClockBudgetMs: Infinity }).catch(fail);
235+
return true;
236+
}, stream);
237+
}
238+
157239
/**
158240
* Queue one model response per inference request, in order — the common
159241
* "model calls tools, then answers" spine. Use

0 commit comments

Comments
 (0)