Skip to content

Commit 9cbc2f6

Browse files
committed
test(e2e): keep an approved mcp call settled across a reload
1 parent 5c675ca commit 9cbc2f6

1 file changed

Lines changed: 129 additions & 9 deletions

File tree

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

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test";
1010
import type { ReactorEmittedEvent } from "@intx/inference";
1111
import type { ConversationTurn } from "@intx/types/runtime";
1212

13+
import type { MCPClient } from "../src/mcp/client.js";
14+
import { mcpClientTools } from "../src/mcp/plugin.js";
1315
import { createPermissionGate } from "../src/permission/gate.js";
1416
import { createReactorAuthorize } from "../src/permission/reactor-authorize.js";
1517
import {
@@ -27,6 +29,50 @@ import {
2729

2830
// Ask-tier, but answered locally: nothing listens on the discard port.
2931
const CLOSED_PORT_PROBE = "curl -sS --max-time 1 http://127.0.0.1:9/";
32+
const SAVE_ISSUE = "mcp__linear__save_issue";
33+
const CREATE_LABEL = "mcp__linear__create_label";
34+
35+
function linearClient(calls: Record<string, unknown>[]): MCPClient {
36+
return {
37+
serverName: "linear",
38+
tools: [
39+
{
40+
name: "save_issue",
41+
description: "create or update a linear issue",
42+
inputSchema: {
43+
type: "object",
44+
properties: { title: { type: "string" } },
45+
},
46+
},
47+
],
48+
async callResult(_toolName, args) {
49+
calls.push(args);
50+
return { blocks: [{ type: "text", text: "SAVED-1" }], isError: false };
51+
},
52+
async close() {
53+
return undefined;
54+
},
55+
};
56+
}
57+
58+
function labelClient(): MCPClient {
59+
return {
60+
serverName: "linear",
61+
tools: [
62+
{
63+
name: "create_label",
64+
description: "create a linear label",
65+
inputSchema: { type: "object", properties: {} },
66+
},
67+
],
68+
async callResult() {
69+
return { blocks: [], isError: false };
70+
},
71+
async close() {
72+
return undefined;
73+
},
74+
};
75+
}
3076

3177
// Reactor gates run on the real clock (setTimeout + Date.now), not the
3278
// inference harness clock, so the approval deadline is driven here. Only
@@ -155,29 +201,45 @@ interface Reloaded {
155201
clock: ReturnType<typeof holdLongTimers>;
156202
}
157203

204+
interface ApproveThenReloadOpts {
205+
/** Runs on the opened session before the call is scripted. */
206+
prepare?: (session: IntegrationSession) => Promise<void>;
207+
/** Runs while the approval prompt is live, before it is approved. */
208+
afterApprovalOpened?: (session: IntegrationSession) => void;
209+
/**
210+
* Rebuild over the same toolset, as the TUI does for an MCP tool-set
211+
* change. Reopening from the store builds a fresh toolset, which would
212+
* drop tools mounted on the old one.
213+
*/
214+
rebuildInPlace?: boolean;
215+
}
216+
158217
/**
159-
* Parks `command` on an ask, approves it, and once `reloadWhen` holds for
160-
* the approved run, rebuilds the agent on the same store. The model turn
161-
* after the approved result stays open, so the reload always lands before
162-
* the reply terminal commits the cycle.
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.
163222
*/
164223
async function approveThenReload(
165-
command: string,
224+
call: { name: string; args: Record<string, unknown> },
166225
reloadWhen: (
167226
session: IntegrationSession,
168227
events: ReactorEmittedEvent[],
169228
) => boolean,
229+
opts: ApproveThenReloadOpts = {},
170230
): Promise<Reloaded> {
171231
const ctx = deferredApprovalGate();
172232
const sessionOpts = {
173233
permissionGate: ctx.gate,
174234
authorize: createReactorAuthorize(ctx.gate),
235+
pinToolSetAtBuild: true,
175236
};
176237
let session = await openIntegrationSession(sessionOpts);
177238
cleanups.push(() => closeIntegrationSession(session));
239+
await opts.prepare?.(session);
178240

179241
session.harness.scenario.replyOnce("anthropic", {
180-
toolCalls: [{ name: "run_shell", args: { command } }],
242+
toolCalls: [call],
181243
});
182244
session.harness.scenario.stall();
183245

@@ -201,6 +263,7 @@ async function approveThenReload(
201263
});
202264
const handling = resume.handle(turn.result);
203265
await waitFor(ctx.asked, "approval prompt");
266+
opts.afterApprovalOpened?.(session);
204267
ctx.approve();
205268
expect(await handling).toBe(true);
206269
void session.harness
@@ -211,7 +274,8 @@ async function approveThenReload(
211274

212275
const clock = holdLongTimers();
213276
cleanups.push(() => clock.restore());
214-
session = await rebuildIntegrationSession(session, sessionOpts);
277+
if (opts.rebuildInPlace === true) await session.rebuild();
278+
else session = await rebuildIntegrationSession(session, sessionOpts);
215279
return { session, events: collect(session), callId, deadline, clock };
216280
}
217281

@@ -229,7 +293,7 @@ describe("integration: approving an ask, then reloading the agent", () => {
229293
"after the approved result: the rebuilt agent continues, and the old deadline adds no result",
230294
async () => {
231295
const reloaded = await approveThenReload(
232-
CLOSED_PORT_PROBE,
296+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
233297
(session) => session.harness.scenario.matchedRequests().length === 2,
234298
);
235299
const { session, callId } = reloaded;
@@ -252,14 +316,70 @@ describe("integration: approving an ask, then reloading the agent", () => {
252316
20_000,
253317
);
254318

319+
test.serial(
320+
"an approved parked MCP call remains settled across a pending tool-set change, reopen, and original deadline",
321+
async () => {
322+
const calls: Record<string, unknown>[] = [];
323+
const reloaded = await approveThenReload(
324+
{ name: SAVE_ISSUE, args: { title: "late approval" } },
325+
(session) => session.harness.scenario.matchedRequests().length === 2,
326+
{
327+
prepare: async (session) => {
328+
session.toolset.dynamicRunner.addTools(
329+
mcpClientTools(linearClient(calls)),
330+
);
331+
await session.rebuild();
332+
},
333+
afterApprovalOpened: (session) => {
334+
// The prompt is live before this MCP change requests its reload.
335+
session.toolset.dynamicRunner.addTools(
336+
mcpClientTools(labelClient()),
337+
);
338+
expect(
339+
session.toolset.dynamicRunner.agentToolSetDrift().added,
340+
).toContain(CREATE_LABEL);
341+
},
342+
rebuildInPlace: true,
343+
},
344+
);
345+
const { session, callId } = reloaded;
346+
const rebuiltTools = session.toolset.dynamicRunner
347+
.agentDefinitions()
348+
.map((definition) => definition.name);
349+
expect(rebuiltTools).toContain(SAVE_ISSUE);
350+
expect(rebuiltTools).toContain(CREATE_LABEL);
351+
expect(calls).toEqual([{ title: "late approval" }]);
352+
expect(
353+
toolResultsFor(await session.agent.history(), callId),
354+
).toHaveLength(1);
355+
356+
session.harness.scenario.replyOnce("anthropic", { text: "Saved." });
357+
const followUp = await runUntilDone(session, "Continue.");
358+
expect(followUp.reply).toBe("Saved.");
359+
360+
await passDeadline(reloaded);
361+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
362+
[],
363+
);
364+
const results = toolResultsFor(await session.agent.history(), callId);
365+
expect(calls).toEqual([{ title: "late approval" }]);
366+
expect(results).toHaveLength(1);
367+
expect(results.join("")).not.toContain("approval timed out");
368+
},
369+
20_000,
370+
);
371+
255372
// The reload interrupts the approved call, which the closing reactor
256373
// answers itself. What an interrupted call should report is not pinned
257374
// here; only that the old deadline cannot add a second result.
258375
test.serial(
259376
"during the approved call: the old deadline adds no second result",
260377
async () => {
261378
const reloaded = await approveThenReload(
262-
`sleep 20; ${CLOSED_PORT_PROBE}`,
379+
{
380+
name: "run_shell",
381+
args: { command: `sleep 20; ${CLOSED_PORT_PROBE}` },
382+
},
263383
(_session, events) => events.some((e) => e.type === "tool.start"),
264384
);
265385
const { session, callId } = reloaded;

0 commit comments

Comments
 (0)