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,172 @@ describe("interrupt_agent / followup_task", () => {
258260 expect ( followupErr . isError ) . toBe ( true ) ;
259261 } ) ;
260262} ) ;
263+
264+ describe ( "send_input" , ( ) => {
265+ test ( "soft-delivers a durable message to a running worker without 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 ( "interrupts and queues a next-turn message without awaiting the reply" , 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 ( "drop the broad refactor and patch only the test" ) ;
307+ await new Promise ( ( resolve ) => setTimeout ( resolve , 25 ) ) ;
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 : "drop the broad refactor and patch only the test" ,
318+ interrupt : true ,
319+ } ) ;
320+
321+ expect ( result ) . toEqual ( { agent_id : worker . id , status : "interrupted" } ) ;
322+ expect ( interrupted ) . toBe ( true ) ;
323+ expect ( followupStarted ) . toBe ( true ) ;
324+ expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "interrupted" ) ;
325+ } ) ;
326+
327+ test ( "fails closed when interrupt:true cannot queue the followup" , async ( ) => {
328+ const sessions = createSubAgentSessionStore ( ) ;
329+ const worker = sessions . start ( {
330+ description : "worker" ,
331+ agentId : "a" ,
332+ brief : "b" ,
333+ retained : true ,
334+ } ) ;
335+ sessions . markRunning ( worker . id ) ;
336+ let interrupted = false ;
337+ sessions . registerInterrupt ( worker . id , ( ) => {
338+ interrupted = true ;
339+ } ) ;
340+
341+ const sendInput = createSendInputTool ( { sessions } ) ;
342+ if ( sendInput . kind !== "full" ) throw new Error ( "expected full tool" ) ;
343+ const result = await sendInput . handler (
344+ {
345+ id : "missing-followup" ,
346+ name : "send_input" ,
347+ arguments : { target : worker . id , message : "steer after interrupt" , interrupt : true } ,
348+ } ,
349+ new AbortController ( ) . signal ,
350+ ) ;
351+
352+ expect ( result . isError ) . toBe ( true ) ;
353+ expect ( interrupted ) . toBe ( false ) ;
354+ expect ( sessions . get ( worker . id ) ?. lifecycleStatus ) . toBe ( "running" ) ;
355+ } ) ;
356+
357+ test ( "rejects empty and oversize messages" , async ( ) => {
358+ const sessions = createSubAgentSessionStore ( ) ;
359+ const worker = sessions . start ( {
360+ description : "worker" ,
361+ agentId : "a" ,
362+ brief : "b" ,
363+ retained : true ,
364+ } ) ;
365+ sessions . markRunning ( worker . id ) ;
366+ sessions . registerDeliver ( worker . id , ( ) => { } ) ;
367+ const sendInput = createSendInputTool ( { sessions } ) ;
368+
369+ if ( sendInput . kind !== "full" ) throw new Error ( "expected full tool" ) ;
370+ const empty = await sendInput . handler (
371+ { id : "empty" , name : "send_input" , arguments : { target : worker . id , message : " " } } ,
372+ new AbortController ( ) . signal ,
373+ ) ;
374+ expect ( empty . isError ) . toBe ( true ) ;
375+
376+ const oversize = await sendInput . handler (
377+ {
378+ id : "big" ,
379+ name : "send_input" ,
380+ arguments : { target : worker . id , message : "x" . repeat ( 24_001 ) } ,
381+ } ,
382+ new AbortController ( ) . signal ,
383+ ) ;
384+ expect ( oversize . isError ) . toBe ( true ) ;
385+ } ) ;
386+
387+ test ( "enforces nested orchestrator descendant authority" , async ( ) => {
388+ const sessions = createSubAgentSessionStore ( ) ;
389+ const nested = sessions . start ( {
390+ id : "nested" ,
391+ description : "nested" ,
392+ agentId : "a" ,
393+ brief : "b" ,
394+ } ) ;
395+ const child = sessions . start ( {
396+ id : "child" ,
397+ description : "child" ,
398+ agentId : "a" ,
399+ brief : "b" ,
400+ parentSessionId : nested . id ,
401+ } ) ;
402+ const sibling = sessions . start ( {
403+ id : "sibling" ,
404+ description : "sibling" ,
405+ agentId : "a" ,
406+ brief : "b" ,
407+ } ) ;
408+ for ( const session of [ nested , child , sibling ] ) {
409+ sessions . markRunning ( session . id ) ;
410+ sessions . registerDeliver ( session . id , ( ) => { } ) ;
411+ }
412+ const sendInput = createSendInputTool ( {
413+ sessions,
414+ authority : {
415+ actorId : nested . id ,
416+ tier : "nested-orchestrator" ,
417+ getNodes : ( ) => sessions . list ( ) ,
418+ } ,
419+ } ) ;
420+
421+ const ok = await callTool ( sendInput , { target : child . id , message : "continue" } ) ;
422+ expect ( ok . status ) . toBe ( "running" ) ;
423+
424+ if ( sendInput . kind !== "full" ) throw new Error ( "expected full tool" ) ;
425+ const denied = await sendInput . handler (
426+ { id : "denied" , name : "send_input" , arguments : { target : sibling . id , message : "continue" } } ,
427+ new AbortController ( ) . signal ,
428+ ) ;
429+ expect ( denied . isError ) . toBe ( true ) ;
430+ } ) ;
431+ } ) ;
0 commit comments