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