Skip to content

Commit 9ca3fa3

Browse files
committed
test(e2e): keep rejected, timed-out and rebuilt approvals settled
1 parent 4dd4fb4 commit 9ca3fa3

1 file changed

Lines changed: 193 additions & 30 deletions

File tree

‎e2e/reactor-approval-reload.test.ts‎

Lines changed: 193 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
2-
* An approved ask whose agent is rebuilt from the same store before the next
3-
* cycle commit (the TUI's deferred reload) must not leave the parked call's
2+
* A settled ask (approved, rejected, or timed out) whose agent is rebuilt
3+
* from the same store, before the next cycle commit or at the run's end
4+
* where the TUI's deferred reload fires, must not leave the parked call's
45
* pending operation behind: the rebuilt reactor would re-arm its gate and, at
56
* the original deadline, answer an already-answered call with "approval timed
67
* out".
@@ -151,6 +152,7 @@ function deferredApprovalGate() {
151152
}),
152153
}),
153154
approve: () => release?.({ allow: true }),
155+
reject: () => release?.({ allow: false }),
154156
};
155157
}
156158

@@ -201,10 +203,21 @@ interface Reloaded {
201203
clock: ReturnType<typeof holdLongTimers>;
202204
}
203205

204-
interface ApproveThenReloadOpts {
206+
interface DecideThenReloadOpts {
207+
/**
208+
* How the parked call is settled. `expire` never opens the approval
209+
* prompt; the reactor's own deadline answers the call.
210+
*/
211+
decision?: "approve" | "reject" | "expire";
212+
/**
213+
* The model's reply after the settled call. Without it the next model
214+
* turn stays open, so the reload lands before the cycle commits; with it
215+
* the run can end, which is where the TUI's deferred reload fires.
216+
*/
217+
replyAfterDecision?: string;
205218
/** Runs on the opened session before the call is scripted. */
206219
prepare?: (session: IntegrationSession) => Promise<void>;
207-
/** Runs while the approval prompt is live, before it is approved. */
220+
/** Runs while the approval prompt is live, before it is decided. */
208221
afterApprovalOpened?: (session: IntegrationSession) => void;
209222
/**
210223
* Rebuild over the same toolset, as the TUI does for an MCP tool-set
@@ -215,19 +228,23 @@ interface ApproveThenReloadOpts {
215228
}
216229

217230
/**
218-
* Parks `call` on an ask, approves it, and once `reloadWhen` holds for the
219-
* approved run, rebuilds the agent on the same store. The model turn after
220-
* the approved result stays open, so the reload always lands before the
221-
* reply terminal commits the cycle.
231+
* Parks `call` on an ask, settles it per `opts.decision` (approve by
232+
* default), and once `reloadWhen` holds for the settled run, rebuilds the
233+
* agent on the same store. `reloadWhen` sees only events from after the
234+
* decision.
222235
*/
223-
async function approveThenReload(
236+
async function decideThenReload(
224237
call: { name: string; args: Record<string, unknown> },
225238
reloadWhen: (
226239
session: IntegrationSession,
227240
events: ReactorEmittedEvent[],
228241
) => boolean,
229-
opts: ApproveThenReloadOpts = {},
242+
opts: DecideThenReloadOpts = {},
230243
): Promise<Reloaded> {
244+
// Held from before the park so the expire case can fire the original gate
245+
// timer; the approve and reject cases clear it unfired.
246+
const clock = holdLongTimers();
247+
cleanups.push(() => clock.restore());
231248
const ctx = deferredApprovalGate();
232249
const sessionOpts = {
233250
permissionGate: ctx.gate,
@@ -241,7 +258,11 @@ async function approveThenReload(
241258
session.harness.scenario.replyOnce("anthropic", {
242259
toolCalls: [call],
243260
});
244-
session.harness.scenario.stall();
261+
if (opts.replyAfterDecision === undefined) session.harness.scenario.stall();
262+
else
263+
session.harness.scenario.replyOnce("anthropic", {
264+
text: opts.replyAfterDecision,
265+
});
245266

246267
const turn = await runUntilSuspended(session, "Probe the local port.");
247268
if (turn.result.type !== "suspended") throw new Error("did not park");
@@ -255,33 +276,48 @@ async function approveThenReload(
255276
throw new Error("parked call has no persisted call id or deadline");
256277
}
257278

258-
const resume = createApprovalResume({
259-
getAgent: () => session.agent,
260-
gate: ctx.gate,
261-
resolveParkedCallId: (id) =>
262-
resolveParkedCallIdFromStore(session.storage, id),
263-
});
264-
const handling = resume.handle(turn.result);
265-
await waitFor(ctx.asked, "approval prompt");
266-
opts.afterApprovalOpened?.(session);
267-
ctx.approve();
268-
expect(await handling).toBe(true);
279+
const decision = opts.decision ?? "approve";
280+
const decidedAt = turn.events.length;
281+
if (decision === "expire") {
282+
clock.advanceTo(deadline + 1_000);
283+
} else {
284+
const resume = createApprovalResume({
285+
getAgent: () => session.agent,
286+
gate: ctx.gate,
287+
resolveParkedCallId: (id) =>
288+
resolveParkedCallIdFromStore(session.storage, id),
289+
});
290+
const handling = resume.handle(turn.result);
291+
await waitFor(ctx.asked, "approval prompt");
292+
opts.afterApprovalOpened?.(session);
293+
if (decision === "approve") ctx.approve();
294+
else ctx.reject();
295+
expect(await handling).toBe(true);
296+
}
269297
void session.harness
270298
.run({ wallClockBudgetMs: 30_000 })
271299
.catch(() => undefined);
272300
const before = session;
273-
await waitFor(() => reloadWhen(before, turn.events), "reload point");
301+
await waitFor(
302+
() => reloadWhen(before, turn.events.slice(decidedAt)),
303+
"reload point",
304+
);
274305

275-
const clock = holdLongTimers();
276-
cleanups.push(() => clock.restore());
277306
if (opts.rebuildInPlace === true) await session.rebuild();
278307
else session = await rebuildIntegrationSession(session, sessionOpts);
279308
return { session, events: collect(session), callId, deadline, clock };
280309
}
281310

311+
// Past both the original deadline and a full approval window freshly armed
312+
// at the reload, so either re-arm would have fired.
313+
const FRESH_WINDOW_PASSED_MS = 2 * 60 * 60_000;
314+
282315
/** Moves past the original approval deadline and lets the reactor react. */
283-
async function passDeadline(reloaded: Reloaded): Promise<void> {
284-
reloaded.clock.advanceTo(reloaded.deadline + 1_000);
316+
async function passDeadline(
317+
reloaded: Reloaded,
318+
at = reloaded.deadline + 1_000,
319+
): Promise<void> {
320+
reloaded.clock.advanceTo(at);
285321
await reloaded.session.harness
286322
.run({ wallClockBudgetMs: 2_000 })
287323
.catch(() => undefined);
@@ -292,7 +328,7 @@ describe("integration: approving an ask, then reloading the agent", () => {
292328
test.serial(
293329
"after the approved result: the rebuilt agent continues, and the old deadline adds no result",
294330
async () => {
295-
const reloaded = await approveThenReload(
331+
const reloaded = await decideThenReload(
296332
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
297333
(session) => session.harness.scenario.matchedRequests().length === 2,
298334
);
@@ -320,7 +356,7 @@ describe("integration: approving an ask, then reloading the agent", () => {
320356
"an approved parked MCP call remains settled across a pending tool-set change, reopen, and original deadline",
321357
async () => {
322358
const calls: Record<string, unknown>[] = [];
323-
const reloaded = await approveThenReload(
359+
const reloaded = await decideThenReload(
324360
{ name: SAVE_ISSUE, args: { title: "late approval" } },
325361
(session) => session.harness.scenario.matchedRequests().length === 2,
326362
{
@@ -375,7 +411,7 @@ describe("integration: approving an ask, then reloading the agent", () => {
375411
test.serial(
376412
"during the approved call: the old deadline adds no second result",
377413
async () => {
378-
const reloaded = await approveThenReload(
414+
const reloaded = await decideThenReload(
379415
{
380416
name: "run_shell",
381417
args: { command: `sleep 20; ${CLOSED_PORT_PROBE}` },
@@ -397,4 +433,131 @@ describe("integration: approving an ask, then reloading the agent", () => {
397433
},
398434
20_000,
399435
);
436+
437+
// The TUI defers an MCP tool-set rebuild until the approval-resumed run
438+
// ends (reloadIfIdle in src/tui/runner/exit.ts, covered by its runner
439+
// tests). The harness has no TUI runner, so this rebuilds in place at that
440+
// same boundary, gated on an added tool, one of the drifts reloadIfIdle
441+
// rebuilds for.
442+
test.serial(
443+
"an approved MCP call stays settled when the deferred tool-set rebuild fires at the run's end",
444+
async () => {
445+
const calls: Record<string, unknown>[] = [];
446+
const reloaded = await decideThenReload(
447+
{ name: SAVE_ISSUE, args: { title: "late approval" } },
448+
(session, events) =>
449+
events.some((e) => e.type === "message.run.ended") &&
450+
session.toolset.dynamicRunner.agentToolSetDrift().added.length > 0,
451+
{
452+
replyAfterDecision: "Saved.",
453+
prepare: async (session) => {
454+
session.toolset.dynamicRunner.addTools(
455+
mcpClientTools(linearClient(calls)),
456+
);
457+
await session.rebuild();
458+
},
459+
afterApprovalOpened: (session) => {
460+
session.toolset.dynamicRunner.addTools(
461+
mcpClientTools(labelClient()),
462+
);
463+
},
464+
rebuildInPlace: true,
465+
},
466+
);
467+
const { session, callId } = reloaded;
468+
expect(session.toolset.dynamicRunner.agentToolSetDrift().added).toEqual(
469+
[],
470+
);
471+
expect(
472+
session.toolset.dynamicRunner
473+
.agentDefinitions()
474+
.map((definition) => definition.name),
475+
).toContain(CREATE_LABEL);
476+
expect(calls).toEqual([{ title: "late approval" }]);
477+
expect(
478+
toolResultsFor(await session.agent.history(), callId),
479+
).toHaveLength(1);
480+
481+
session.harness.scenario.replyOnce("anthropic", { text: "Done." });
482+
const followUp = await runUntilDone(session, "Continue.");
483+
expect(followUp.reply).toBe("Done.");
484+
485+
await passDeadline(reloaded);
486+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
487+
[],
488+
);
489+
const results = toolResultsFor(await session.agent.history(), callId);
490+
expect(calls).toEqual([{ title: "late approval" }]);
491+
expect(results).toHaveLength(1);
492+
expect(results.join("")).not.toContain("approval timed out");
493+
},
494+
20_000,
495+
);
496+
});
497+
498+
// Reloads at the run's end, where the TUI's deferred reload fires. A reload
499+
// after the decision but before the cycle commits is not covered here: the
500+
// rejected or timed-out result is not yet persisted, so the rebuilt agent
501+
// loads the parked call with no result at all.
502+
describe("integration: an ask settled without running, then reloading the agent at the run's end", () => {
503+
const runEnded = (
504+
_session: IntegrationSession,
505+
events: ReactorEmittedEvent[],
506+
): boolean => events.some((e) => e.type === "message.run.ended");
507+
508+
test.serial(
509+
"after a rejection: the rebuilt agent continues, and the old deadline adds no result",
510+
async () => {
511+
const reloaded = await decideThenReload(
512+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
513+
runEnded,
514+
{ decision: "reject", replyAfterDecision: "Skipped." },
515+
);
516+
const { session, callId } = reloaded;
517+
const settled = toolResultsFor(await session.agent.history(), callId);
518+
expect(settled).toHaveLength(1);
519+
expect(settled.join("")).toContain("denied by approver");
520+
521+
session.harness.scenario.replyOnce("anthropic", { text: "Done." });
522+
const followUp = await runUntilDone(session, "Continue.");
523+
expect(followUp.reply).toBe("Done.");
524+
525+
await passDeadline(reloaded);
526+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
527+
[],
528+
);
529+
const results = toolResultsFor(await session.agent.history(), callId);
530+
expect(results).toEqual(settled);
531+
expect(results.join("")).not.toContain("approval timed out");
532+
},
533+
20_000,
534+
);
535+
536+
test.serial(
537+
"after the deadline answers it: the rebuilt agent continues, and no later deadline adds a second result",
538+
async () => {
539+
const reloaded = await decideThenReload(
540+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
541+
runEnded,
542+
{ decision: "expire", replyAfterDecision: "Timed out." },
543+
);
544+
const { session, callId } = reloaded;
545+
const settled = toolResultsFor(await session.agent.history(), callId);
546+
expect(settled).toHaveLength(1);
547+
expect(settled.join("")).toContain("approval timed out");
548+
549+
session.harness.scenario.replyOnce("anthropic", { text: "Done." });
550+
const followUp = await runUntilDone(session, "Continue.");
551+
expect(followUp.reply).toBe("Done.");
552+
553+
await passDeadline(reloaded, reloaded.deadline + FRESH_WINDOW_PASSED_MS);
554+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
555+
[],
556+
);
557+
expect(toolResultsFor(await session.agent.history(), callId)).toEqual(
558+
settled,
559+
);
560+
},
561+
20_000,
562+
);
400563
});

0 commit comments

Comments
 (0)