55 createResumeAgentTool ,
66 createInterruptAgentTool ,
77 createFollowupTaskTool ,
8+ createSendInputTool ,
89} from "./lifecycle-tools.js" ;
910import { createFleetRecords } from "./agent-fleet.js" ;
1011import { createSubAgentSessionStore } from "./session-store.js" ;
@@ -14,7 +15,8 @@ async function callTool(
1415 | ReturnType < typeof createCloseAgentTool >
1516 | ReturnType < typeof createResumeAgentTool >
1617 | ReturnType < typeof createInterruptAgentTool >
17- | ReturnType < typeof createFollowupTaskTool > ,
18+ | ReturnType < typeof createFollowupTaskTool >
19+ | ReturnType < typeof createSendInputTool > ,
1820 args : Record < string , unknown > ,
1921) : Promise < Record < string , unknown > > {
2022 if ( tool . kind !== "full" ) throw new Error ( `expected full tool, got ${ tool . kind } ` ) ;
@@ -268,3 +270,131 @@ describe("interrupt_agent / followup_task", () => {
268270 expect ( followupErr . isError ) . toBe ( true ) ;
269271 } ) ;
270272} ) ;
273+
274+ describe ( "send_input" , ( ) => {
275+ test ( "soft-delivers without flipping lifecycle or awaiting a reply" , async ( ) => {
276+ const sessions = createSubAgentSessionStore ( ) ;
277+ const worker = sessions . start ( {
278+ description : "worker" ,
279+ agentId : "a" ,
280+ brief : "b" ,
281+ retained : true ,
282+ } ) ;
283+ sessions . markRunning ( worker . id ) ;
284+ const delivered : string [ ] = [ ] ;
285+ sessions . registerDeliver ( worker . id , ( message ) => {
286+ delivered . push ( message ) ;
287+ } ) ;
288+
289+ const sendInput = createSendInputTool ( { sessions } ) ;
290+ const result = await callTool ( sendInput , {
291+ target : worker . id ,
292+ message : "stop and inspect line 4" ,
293+ } ) ;
294+
295+ expect ( result ) . toEqual ( { agent_id : worker . id , status : "running" } ) ;
296+ expect ( delivered ) . toEqual ( [ "stop and inspect line 4" ] ) ;
297+ expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
298+ } ) ;
299+
300+ test ( "interrupt:true queues followup without awaiting and refuses when followup is missing" , async ( ) => {
301+ const sessions = createSubAgentSessionStore ( ) ;
302+ const worker = sessions . start ( {
303+ description : "worker" ,
304+ agentId : "a" ,
305+ brief : "b" ,
306+ retained : true ,
307+ } ) ;
308+ sessions . markRunning ( worker . id ) ;
309+ let interrupted = false ;
310+ let followupStarted = false ;
311+ sessions . registerInterrupt ( worker . id , ( ) => {
312+ interrupted = true ;
313+ } ) ;
314+ sessions . registerFollowup ( worker . id , async ( message ) => {
315+ followupStarted = true ;
316+ expect ( message ) . toBe ( "patch only the test" ) ;
317+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
318+ return "queued turn finished" ;
319+ } ) ;
320+ sessions . registerDeliver ( worker . id , ( ) => {
321+ throw new Error ( "interrupt:true should not soft-deliver" ) ;
322+ } ) ;
323+
324+ const sendInput = createSendInputTool ( { sessions } ) ;
325+ const result = await callTool ( sendInput , {
326+ target : worker . id ,
327+ message : "patch only the test" ,
328+ interrupt : true ,
329+ } ) ;
330+ expect ( result ) . toEqual ( { agent_id : worker . id , status : "interrupted" } ) ;
331+ expect ( interrupted ) . toBe ( true ) ;
332+ expect ( followupStarted ) . toBe ( true ) ;
333+ expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "interrupted" ) ;
334+
335+ const missing = sessions . start ( {
336+ description : "no-followup" ,
337+ agentId : "a" ,
338+ brief : "b" ,
339+ retained : true ,
340+ } ) ;
341+ sessions . markRunning ( missing . id ) ;
342+ sessions . registerInterrupt ( missing . id , ( ) => { } ) ;
343+ if ( sendInput . kind !== "full" ) throw new Error ( "expected full tool" ) ;
344+ const denied = await sendInput . handler (
345+ {
346+ id : "missing-followup" ,
347+ name : "send_input" ,
348+ arguments : { target : missing . id , message : "steer" , interrupt : true } ,
349+ } ,
350+ new AbortController ( ) . signal ,
351+ ) ;
352+ expect ( denied . isError ) . toBe ( true ) ;
353+ expect ( sessions . get ( missing . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
354+ } ) ;
355+
356+ test ( "enforces nested orchestrator descendant authority" , async ( ) => {
357+ const sessions = createSubAgentSessionStore ( ) ;
358+ const nested = sessions . start ( {
359+ id : "nested" ,
360+ description : "nested" ,
361+ agentId : "a" ,
362+ brief : "b" ,
363+ } ) ;
364+ const child = sessions . start ( {
365+ id : "child" ,
366+ description : "child" ,
367+ agentId : "a" ,
368+ brief : "b" ,
369+ parentSessionId : nested . id ,
370+ } ) ;
371+ const sibling = sessions . start ( {
372+ id : "sibling" ,
373+ description : "sibling" ,
374+ agentId : "a" ,
375+ brief : "b" ,
376+ } ) ;
377+ for ( const session of [ nested , child , sibling ] ) {
378+ sessions . markRunning ( session . id ) ;
379+ sessions . registerDeliver ( session . id , ( ) => { } ) ;
380+ }
381+ const sendInput = createSendInputTool ( {
382+ sessions,
383+ authority : {
384+ actorId : nested . id ,
385+ tier : "nested-orchestrator" ,
386+ getNodes : ( ) => sessions . list ( ) ,
387+ } ,
388+ } ) ;
389+
390+ const ok = await callTool ( sendInput , { target : child . id , message : "continue" } ) ;
391+ expect ( ok . status ) . toBe ( "running" ) ;
392+
393+ if ( sendInput . kind !== "full" ) throw new Error ( "expected full tool" ) ;
394+ const denied = await sendInput . handler (
395+ { id : "denied" , name : "send_input" , arguments : { target : sibling . id , message : "continue" } } ,
396+ new AbortController ( ) . signal ,
397+ ) ;
398+ expect ( denied . isError ) . toBe ( true ) ;
399+ } ) ;
400+ } ) ;
0 commit comments