@@ -16,6 +16,7 @@ import { isLiveWaitStatus } from "./lifecycle.js";
1616import {
1717 createInterruptAgentTool ,
1818 createCloseAgentTool ,
19+ createResumeAgentTool ,
1920 createSendInputTool ,
2021} from "./lifecycle-tools.js" ;
2122import { createSubAgentSessionStore } from "./session-store.js" ;
@@ -984,7 +985,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
984985 gate . resolve ( { report : "done" } ) ;
985986 } ) ;
986987
987- test ( "send_input interrupt:true unblocks wait_agents as interrupted " , async ( ) => {
988+ test ( "send_input interrupt:true keeps wait_agents live until the followup completes " , async ( ) => {
988989 const gate = deferred < RunSubAgentResult > ( ) ;
989990 const followupGate = deferred < string > ( ) ;
990991 const deps = makeDeps ( async ( params ) => {
@@ -1013,11 +1014,92 @@ describe("interrupt_agent unblocks wait_agents", () => {
10131014 const id = spawned . agent_id as string ;
10141015 const waiting = callTool ( wait , { targets : [ id ] , timeout_ms : 5000 } ) ;
10151016 await callTool ( sendInput , { target : id , message : "stop that" , interrupt : true } ) ;
1017+ followupGate . resolve ( "later" ) ;
1018+ gate . resolve ( { report : "original interrupted" , interrupted : true } as RunSubAgentResult ) ;
10161019 const waited = await waiting ;
10171020 expect ( waited . timed_out ) . toBe ( false ) ;
1018- const results = waited . results as { status : string } [ ] ;
1019- expect ( results [ 0 ] ! . status ) . toBe ( "interrupted" ) ;
1020- followupGate . resolve ( "later" ) ;
1021+ const results = waited . results as { status : string ; report ?: string } [ ] ;
1022+ expect ( results [ 0 ] ! . status ) . toBe ( "done" ) ;
1023+ expect ( results [ 0 ] ! . report ) . toBe ( "later" ) ;
1024+ } ) ;
1025+
1026+ test ( "CL-7331: send_input interrupt keeps wait live until the queued followup completes" , async ( ) => {
1027+ const gate = deferred < RunSubAgentResult > ( ) ;
1028+ const followupGate = deferred < string > ( ) ;
1029+ const deps = makeDeps ( async ( params ) => {
1030+ params . onAgentReady ?.( {
1031+ close : async ( ) => { } ,
1032+ interrupt : ( ) => { } ,
1033+ followup : async ( ) => followupGate . promise ,
1034+ deliver : ( ) => { } ,
1035+ } ) ;
1036+ return gate . promise ;
1037+ } ) ;
1038+ const spawn = createSpawnAgentTool ( deps ) ;
1039+ const wait = createWaitAgentsTool ( {
1040+ sessions : deps . sessions ,
1041+ fleetRecords : deps . fleetRecords ,
1042+ } ) ;
1043+ const list = createListAgentsTool ( {
1044+ sessions : deps . sessions ,
1045+ fleetRecords : deps . fleetRecords ,
1046+ } ) ;
1047+ const sendInput = createSendInputTool ( {
1048+ sessions : deps . sessions ,
1049+ fleetRecords : deps . fleetRecords ,
1050+ } ) ;
1051+ const resume = createResumeAgentTool ( {
1052+ sessions : deps . sessions ,
1053+ fleetRecords : deps . fleetRecords ,
1054+ } ) ;
1055+ const spawned = await callTool ( spawn , {
1056+ description : "looping" ,
1057+ prompt : "do it" ,
1058+ intent : "explore" ,
1059+ } ) ;
1060+ const id = spawned . agent_id as string ;
1061+
1062+ const sent = await callTool ( sendInput , {
1063+ target : id ,
1064+ message : "return a concise report" ,
1065+ interrupt : true ,
1066+ } ) ;
1067+ expect ( sent . status ) . toBe ( "interrupted" ) ;
1068+
1069+ // The queued followup is still running: wait must stay live (not an
1070+ // immediate terminal interrupted), and list must agree with lifecycle.
1071+ const pending = await callTool ( wait , { targets : [ id ] , timeout_ms : 50 } ) ;
1072+ expect ( pending . timed_out ) . toBe ( true ) ;
1073+ expect ( ( pending . results as { status : string } [ ] ) [ 0 ] ! . status ) . toBe ( "running" ) ;
1074+
1075+ const listed = await callTool ( list , { } ) ;
1076+ const entry = ( listed . agents as { agent_id : string ; status : string ; lifecycle : string } [ ] ) . find (
1077+ ( a ) => a . agent_id === id ,
1078+ ) ;
1079+ expect ( entry ?. status ) . toBe ( "running" ) ;
1080+ expect ( entry ?. lifecycle ) . toBe ( "running" ) ;
1081+
1082+ // A resume while the followup is in flight must agree with wait/list.
1083+ if ( resume . kind !== "full" ) throw new Error ( "expected full tool" ) ;
1084+ const resumed = await resume . handler (
1085+ {
1086+ id : "resume-while-followup" ,
1087+ name : "resume_agent" ,
1088+ arguments : { target : id , message : "x" } ,
1089+ } ,
1090+ new AbortController ( ) . signal ,
1091+ ) ;
1092+ expect ( resumed . isError ) . toBe ( true ) ;
1093+ expect ( String ( resumed . content ) ) . toContain ( "status: running" ) ;
1094+
1095+ // When the queued followup finishes, its report must surface via wait.
1096+ followupGate . resolve ( "followup report" ) ;
1097+ gate . resolve ( { report : "original interrupted" , interrupted : true } as RunSubAgentResult ) ;
1098+ const done = await callTool ( wait , { targets : [ id ] , timeout_ms : 5000 } ) ;
1099+ expect ( done . timed_out ) . toBe ( false ) ;
1100+ const doneResults = done . results as { status : string ; report ?: string } [ ] ;
1101+ expect ( doneResults [ 0 ] ! . status ) . toBe ( "done" ) ;
1102+ expect ( doneResults [ 0 ] ! . report ) . toBe ( "followup report" ) ;
10211103 } ) ;
10221104
10231105 test ( "soft-interrupt wait path collects so omitted re-wait does not re-deliver" , async ( ) => {
0 commit comments