Skip to content

Commit 39171cc

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

1 file changed

Lines changed: 100 additions & 4 deletions

File tree

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

Lines changed: 100 additions & 4 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,49 @@ 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+
34+
function linearClient(calls: Record<string, unknown>[]): MCPClient {
35+
return {
36+
serverName: "linear",
37+
tools: [
38+
{
39+
name: "save_issue",
40+
description: "create or update a linear issue",
41+
inputSchema: {
42+
type: "object",
43+
properties: { title: { type: "string" } },
44+
},
45+
},
46+
],
47+
async callResult(_toolName, args) {
48+
calls.push(args);
49+
return { blocks: [{ type: "text", text: "SAVED-1" }], isError: false };
50+
},
51+
async close() {
52+
return undefined;
53+
},
54+
};
55+
}
56+
57+
function labelClient(): MCPClient {
58+
return {
59+
serverName: "linear",
60+
tools: [
61+
{
62+
name: "create_label",
63+
description: "create a linear label",
64+
inputSchema: { type: "object", properties: {} },
65+
},
66+
],
67+
async callResult() {
68+
return { blocks: [], isError: false };
69+
},
70+
async close() {
71+
return undefined;
72+
},
73+
};
74+
}
3075

3176
// Reactor gates run on the real clock (setTimeout + Date.now), not the
3277
// inference harness clock, so the approval deadline is driven here. Only
@@ -162,22 +207,26 @@ interface Reloaded {
162207
* the reply terminal commits the cycle.
163208
*/
164209
async function approveThenReload(
165-
command: string,
210+
call: { name: string; args: Record<string, unknown> },
166211
reloadWhen: (
167212
session: IntegrationSession,
168213
events: ReactorEmittedEvent[],
169214
) => boolean,
215+
prepare?: (session: IntegrationSession) => Promise<void>,
216+
afterApprovalOpened?: (session: IntegrationSession) => void,
170217
): Promise<Reloaded> {
171218
const ctx = deferredApprovalGate();
172219
const sessionOpts = {
173220
permissionGate: ctx.gate,
174221
authorize: createReactorAuthorize(ctx.gate),
222+
pinToolSetAtBuild: true,
175223
};
176224
let session = await openIntegrationSession(sessionOpts);
177225
cleanups.push(() => closeIntegrationSession(session));
226+
await prepare?.(session);
178227

179228
session.harness.scenario.replyOnce("anthropic", {
180-
toolCalls: [{ name: "run_shell", args: { command } }],
229+
toolCalls: [call],
181230
});
182231
session.harness.scenario.stall();
183232

@@ -201,6 +250,7 @@ async function approveThenReload(
201250
});
202251
const handling = resume.handle(turn.result);
203252
await waitFor(ctx.asked, "approval prompt");
253+
afterApprovalOpened?.(session);
204254
ctx.approve();
205255
expect(await handling).toBe(true);
206256
void session.harness
@@ -229,7 +279,7 @@ describe("integration: approving an ask, then reloading the agent", () => {
229279
"after the approved result: the rebuilt agent continues, and the old deadline adds no result",
230280
async () => {
231281
const reloaded = await approveThenReload(
232-
CLOSED_PORT_PROBE,
282+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
233283
(session) => session.harness.scenario.matchedRequests().length === 2,
234284
);
235285
const { session, callId } = reloaded;
@@ -252,14 +302,60 @@ describe("integration: approving an ask, then reloading the agent", () => {
252302
20_000,
253303
);
254304

305+
test.serial(
306+
"an approved parked MCP call remains settled across a pending tool-set change, reopen, and original deadline",
307+
async () => {
308+
const calls: Record<string, unknown>[] = [];
309+
const reloaded = await approveThenReload(
310+
{ name: SAVE_ISSUE, args: { title: "late approval" } },
311+
(session) => session.harness.scenario.matchedRequests().length === 2,
312+
async (session) => {
313+
session.toolset.dynamicRunner.addTools(
314+
mcpClientTools(linearClient(calls)),
315+
);
316+
await session.rebuild();
317+
},
318+
(session) => {
319+
// The prompt is live before this MCP change requests its reload.
320+
session.toolset.dynamicRunner.addTools(mcpClientTools(labelClient()));
321+
expect(
322+
session.toolset.dynamicRunner.agentToolSetDrift().added,
323+
).toContain("mcp__linear__create_label");
324+
},
325+
);
326+
const { session, callId } = reloaded;
327+
expect(calls).toEqual([{ title: "late approval" }]);
328+
expect(
329+
toolResultsFor(await session.agent.history(), callId),
330+
).toHaveLength(1);
331+
332+
session.harness.scenario.replyOnce("anthropic", { text: "Saved." });
333+
const followUp = await runUntilDone(session, "Continue.");
334+
expect(followUp.reply).toBe("Saved.");
335+
336+
await passDeadline(reloaded);
337+
expect(reloaded.events.filter((e) => e.type === "reactor.error")).toEqual(
338+
[],
339+
);
340+
const results = toolResultsFor(await session.agent.history(), callId);
341+
expect(calls).toEqual([{ title: "late approval" }]);
342+
expect(results).toHaveLength(1);
343+
expect(results.join("")).not.toContain("approval timed out");
344+
},
345+
20_000,
346+
);
347+
255348
// The reload interrupts the approved call, which the closing reactor
256349
// answers itself. What an interrupted call should report is not pinned
257350
// here; only that the old deadline cannot add a second result.
258351
test.serial(
259352
"during the approved call: the old deadline adds no second result",
260353
async () => {
261354
const reloaded = await approveThenReload(
262-
`sleep 20; ${CLOSED_PORT_PROBE}`,
355+
{
356+
name: "run_shell",
357+
args: { command: `sleep 20; ${CLOSED_PORT_PROBE}` },
358+
},
263359
(_session, events) => events.some((e) => e.type === "tool.start"),
264360
);
265361
const { session, callId } = reloaded;

0 commit comments

Comments
 (0)