@@ -10,6 +10,8 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test";
1010import type { ReactorEmittedEvent } from "@intx/inference" ;
1111import type { ConversationTurn } from "@intx/types/runtime" ;
1212
13+ import type { MCPClient } from "../src/mcp/client.js" ;
14+ import { mcpClientTools } from "../src/mcp/plugin.js" ;
1315import { createPermissionGate } from "../src/permission/gate.js" ;
1416import { createReactorAuthorize } from "../src/permission/reactor-authorize.js" ;
1517import {
@@ -27,6 +29,49 @@ import {
2729
2830// Ask-tier, but answered locally: nothing listens on the discard port.
2931const 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 */
164209async 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