@@ -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,50 @@ 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+ 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 */
164223async 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