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