@@ -25,6 +25,7 @@ import {
2525 DROPPED_APPROVAL_SETTLED_EVENT ,
2626 type DroppedApprovalCalls ,
2727} from "../src/agent/director.js" ;
28+ import { createQueuedApprovalDeliver } from "../src/tui/approval-delivery.js" ;
2829import {
2930 createDeliveryGeneration ,
3031 createSessionOperationQueue ,
@@ -427,13 +428,25 @@ const ADAPTERS: Adapter[] = [
427428
428429interface Dropped extends Reloaded {
429430 resume : ApprovalResume ;
431+ correlationId : string ;
432+ /** Events from the parked agent, up to its close. */
433+ parkedEvents : ReactorEmittedEvent [ ] ;
434+ /** The calls the closed agent's store still showed parked at the rebuild. */
435+ parkedAtRebuild : ( string | undefined ) [ ] ;
430436}
431437
438+ /** Where the interrupt lands relative to the operator's decision. */
439+ type InterruptPoint =
440+ /** Before the prompt opens, while the approval resolves its parked call. */
441+ | "resolving-call"
442+ /** After an approval, while it waits behind a busy session queue. */
443+ | "decision-queued" ;
444+
432445/**
433- * Parks `call` on an ask, then interrupts while the approval is still
434- * resolving its parked call, so the drop sends the old agent nothing. The
435- * rebuild settles the drop on the rebuilt agent, as the TUI's interrupt does ,
436- * and no reply is scripted for it.
446+ * Parks `call` on an ask, then interrupts at `opts.interruptWhile` (while
447+ * the approval resolves its parked call, by default), so no decision reaches
448+ * a reactor that runs it. The rebuild settles the drop on the rebuilt agent,
449+ * as the TUI's interrupt does, and no reply is scripted for it.
437450 */
438451async function dropThenInterrupt (
439452 call : { name : string ; args : Record < string , unknown > } ,
@@ -445,21 +458,36 @@ async function dropThenInterrupt(
445458 * slower than that reactor's own.
446459 */
447460 deadlinePassesDuringRebuild ?: boolean ;
461+ interruptWhile ?: InterruptPoint ;
448462 } = { } ,
449463) : Promise < Dropped > {
464+ const interruptWhile = opts . interruptWhile ?? "resolving-call" ;
450465 // Every build's director claims from the resume, which needs the session.
451466 let claimFrom : ApprovalResume | undefined ;
452467 const droppedCalls : DroppedApprovalCalls = {
453468 claim : ( callId ) => claimFrom ?. droppedCalls . claim ( callId ) === true ,
454469 } ;
455- const { live, sessionOpts, ctx, clock, suspended, callId, deadline } =
456- await parkOnAsk ( call , { adapter, droppedApprovalCalls : droppedCalls } ) ;
470+ const {
471+ live,
472+ sessionOpts,
473+ ctx,
474+ clock,
475+ suspended,
476+ events : parkedEvents ,
477+ callId,
478+ deadline,
479+ } = await parkOnAsk ( call , { adapter, droppedApprovalCalls : droppedCalls } ) ;
457480 const parkedCancel = { fn : undefined as ( ( ) => void ) | undefined } ;
458481 const generation = createDeliveryGeneration ( ( ) => parkedCancel . fn ?.( ) ) ;
459482 let releaseCallId : ( ( ) => void ) | undefined ;
460483 const callIdResolved = new Promise < void > ( ( resolve ) => {
461484 releaseCallId = resolve ;
462485 } ) ;
486+ const { enqueue, awaitTail } = createSessionOperationQueue ( ) ;
487+ let decisionQueued : ( ( ) => void ) | undefined ;
488+ const queued = new Promise < void > ( ( resolve ) => {
489+ decisionQueued = resolve ;
490+ } ) ;
463491 const resume = createApprovalResume ( {
464492 getAgent : ( ) => live . session . agent ,
465493 gate : ctx . gate ,
@@ -468,12 +496,40 @@ async function dropThenInterrupt(
468496 parkedCancel . fn = cancel ;
469497 } ,
470498 resolveParkedCallId : ( ) => callIdResolved . then ( ( ) => callId ) ,
499+ // As the TUI does: behind the session's other operations.
500+ deliver : createQueuedApprovalDeliver ( {
501+ enqueue : ( op ) => {
502+ const queuedOp = enqueue ( op ) ;
503+ decisionQueued ?.( ) ;
504+ return queuedOp ;
505+ } ,
506+ fatalBuildError : ( ) => null ,
507+ deliverer : {
508+ deliver : async ( message ) => live . session . agent . deliver ( message ) ,
509+ } ,
510+ } ) ,
471511 } ) ;
472512 claimFrom = resume ;
473513 const handling = resume . handle ( suspended ) ;
474514
475- const { enqueue, awaitTail } = createSessionOperationQueue ( ) ;
515+ let releaseBusy : ( ( ) => void ) | undefined ;
516+ if ( interruptWhile !== "resolving-call" ) {
517+ releaseCallId ?.( ) ;
518+ await waitFor ( ctx . asked , "approval prompt" ) ;
519+ }
520+ if ( interruptWhile === "decision-queued" ) {
521+ // Any earlier operation holds the queue, as a send or another deliver would.
522+ void enqueue (
523+ ( ) =>
524+ new Promise < void > ( ( resolve ) => {
525+ releaseBusy = resolve ;
526+ } ) ,
527+ ) ;
528+ ctx . approve ( ) ;
529+ await queued ;
530+ }
476531 let events : ReactorEmittedEvent [ ] = [ ] ;
532+ let parkedAtRebuild : ( string | undefined ) [ ] = [ ] ;
477533 startInterruptRebuild ( {
478534 deliveryGeneration : generation ,
479535 markSendAborted : ( ) => undefined ,
@@ -482,6 +538,9 @@ async function dropThenInterrupt(
482538 if ( opts . deadlinePassesDuringRebuild === true ) {
483539 clock . shift ( deadline + 1_000 ) ;
484540 }
541+ parkedAtRebuild = (
542+ await live . session . storage . load ( )
543+ ) . pendingOperations . map ( ( op ) => op . suspendedCall ?. id ) ;
485544 await resume . prepareRebuild ( live . session . storage ) ;
486545 live . session = await rebuildIntegrationSession ( live . session , sessionOpts ) ;
487546 events = collect ( live . session ) ;
@@ -500,6 +559,7 @@ async function dropThenInterrupt(
500559 } ,
501560 } ) ;
502561 releaseCallId ?.( ) ;
562+ releaseBusy ?.( ) ;
503563 expect ( await handling ) . toBe ( true ) ;
504564 await awaitTail ( ) ;
505565
@@ -524,6 +584,9 @@ async function dropThenInterrupt(
524584 deadline,
525585 clock,
526586 resume,
587+ correlationId : suspended . correlationId ,
588+ parkedEvents,
589+ parkedAtRebuild,
527590 } ;
528591}
529592
@@ -997,4 +1060,40 @@ describe("integration: an ask dropped by an interrupt, then settled on the rebui
9971060 } ,
9981061 20_000 ,
9991062 ) ;
1063+
1064+ /** The approved call never ran, and the rebuilt agent settled it once. */
1065+ async function expectSettledWithoutRunning ( reloaded : Dropped ) : Promise < void > {
1066+ const { session, callId, events, parkedEvents } = reloaded ;
1067+ expect ( reloaded . parkedAtRebuild ) . toEqual ( [ callId ] ) ;
1068+ expect ( parkedEvents . filter ( ( e ) => e . type === "tool.start" ) ) . toEqual ( [ ] ) ;
1069+ expect ( ( await session . storage . load ( ) ) . pendingOperations ) . toEqual ( [ ] ) ;
1070+ const settled = toolResultsFor ( await session . agent . history ( ) , callId ) ;
1071+ expect ( settled ) . toHaveLength ( 1 ) ;
1072+ expect ( settled . join ( "" ) ) . toContain ( APPROVAL_DROPPED_NOTICE ) ;
1073+ expect ( session . harness . scenario . matchedRequests ( ) ) . toHaveLength ( 0 ) ;
1074+ expect ( events . filter ( ( e ) => e . type === "inference.start" ) ) . toEqual ( [ ] ) ;
1075+
1076+ await passDeadline ( reloaded , reloaded . deadline + FRESH_WINDOW_PASSED_MS ) ;
1077+ expect ( toolResultsFor ( await session . agent . history ( ) , callId ) ) . toEqual (
1078+ settled ,
1079+ ) ;
1080+ expect ( events . filter ( ( e ) => e . type === "reactor.error" ) ) . toEqual ( [ ] ) ;
1081+ }
1082+
1083+ test . serial (
1084+ "an approval the interrupt skips while it waits for delivery settles with no model turn" ,
1085+ async ( ) => {
1086+ const reloaded = await dropThenInterrupt (
1087+ { name : "run_shell" , args : { command : CLOSED_PORT_PROBE } } ,
1088+ NATIVE_ANTHROPIC ,
1089+ { interruptWhile : "decision-queued" } ,
1090+ ) ;
1091+
1092+ await expectSettledWithoutRunning ( reloaded ) ;
1093+ expect ( reloaded . resume . status ( reloaded . correlationId ) ) . not . toBe (
1094+ "handed-over" ,
1095+ ) ;
1096+ } ,
1097+ 20_000 ,
1098+ ) ;
10001099} ) ;
0 commit comments