@@ -13,9 +13,14 @@ import {
1313} from "../runtime-bridge.js" ;
1414import { createLiveSessionPort } from "../live-session-port.js" ;
1515import { createAppShell } from "../shell/index.js" ;
16- import { withTestRenderer } from "../harness.js" ;
16+ import { withTestRenderer , type Harness } from "../harness.js" ;
1717import { resetSessionForRotation } from "./exit.js" ;
18- import { clearTranscript } from "../shell/chrome.js" ;
18+ import { clearTranscript , setMcpNeedsAuth } from "../shell/chrome.js" ;
19+ import type { AppShell } from "../shell/internals.js" ;
20+ import { closeInsetOverlay } from "../shell/overlay-host.js" ;
21+ import { submitPrompt } from "../shell/prompt.js" ;
22+ import { wireGates } from "../gate-wire.js" ;
23+ import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js" ;
1924import {
2025 createDeliveryGeneration ,
2126 createLeftoverSend ,
@@ -223,6 +228,8 @@ for (const phase of ["settled", "prequeued", "deferred"] as const) {
223228 { type : "agent-ask" , asks : [ ] } ,
224229 { type : "fleet" , running : 0 } ,
225230 ] ) ;
231+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
232+ expect ( shell . workerWaitRow . visible ) . toBe ( false ) ;
226233 expect (
227234 store . list ( ) . every ( ( worker ) => worker . status === "cancelled" ) ,
228235 ) . toBe ( true ) ;
@@ -301,6 +308,10 @@ for (const removal of [
301308 resolve : ( ) => undefined ,
302309 reject : ( ) => undefined ,
303310 } ) ;
311+ expect ( shell . workerWait . items . map ( ( item ) => item . sessionId ) ) . toEqual ( [
312+ worker . id ,
313+ ] ) ;
314+ expect ( shell . workerWaitRow . visible ) . toBe ( true ) ;
304315 if ( removal === "answer" ) {
305316 const mailbox = createFleetMailbox ( store ) ;
306317 mailbox . register ( worker . id ) ;
@@ -338,6 +349,8 @@ for (const removal of [
338349 type : "agent-ask" ,
339350 asks : [ ] ,
340351 } ) ;
352+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
353+ expect ( shell . workerWaitRow . visible ) . toBe ( false ) ;
341354 bridge . handle ( { type : "inference.done" , data : { } } ) ;
342355 expect ( sends ) . toEqual ( [ ] ) ;
343356 } finally {
@@ -528,3 +541,275 @@ test("yield wait does not stamp; sending pendingAskWakeText gates list_agents",
528541 { width : 80 , height : 24 } ,
529542 ) ;
530543} ) ;
544+
545+ interface StripRig {
546+ readonly shell : AppShell ;
547+ readonly h : Harness ;
548+ readonly bridge : ReturnType < typeof attachSessionBridge > ;
549+ readonly store : ReturnType < typeof createSubAgentSessionStore > ;
550+ readonly emitter : EventEmitter ;
551+ /** Everything the primary session port was handed, in order. */
552+ readonly toPrimary : string [ ] ;
553+ readonly answers : string [ ] ;
554+ readonly park : ( id : string , questionId : string , question : string ) => void ;
555+ }
556+
557+ async function withStripRig ( fn : ( rig : StripRig ) => Promise < void > ) {
558+ await withTestRenderer (
559+ async ( h ) => {
560+ const shell = createAppShell ( h . renderer , {
561+ terminal : { columns : 120 , rows : 30 } ,
562+ wireKeys : false ,
563+ } ) ;
564+ const toPrimary : string [ ] = [ ] ;
565+ const answers : string [ ] = [ ] ;
566+ const bridge = attachSessionBridge (
567+ shell ,
568+ createLiveSessionPort ( {
569+ send : ( text ) => {
570+ toPrimary . push ( text ) ;
571+ } ,
572+ deliver : ( text ) => {
573+ toPrimary . push ( text ) ;
574+ } ,
575+ interrupt : ( ) => undefined ,
576+ } ) ,
577+ ) ;
578+ const store = createSubAgentSessionStore ( ) ;
579+ const emitter = new EventEmitter ( ) ;
580+ emitter . on ( "event" , ( event : BridgeInboundEvent ) => bridge . handle ( event ) ) ;
581+ const publisher = createFleetWakePublisher ( store , emitter ) ;
582+ const unsubscribe = store . subscribe ( publisher . publish ) ;
583+ const disposeGates = wireGates ( emitter , shell , {
584+ onGateOpened : ( ) => bridge . gateOpened ( ) ,
585+ onGateClosed : ( ) => bridge . gateClosed ( ) ,
586+ } ) ;
587+ const park = ( id : string , questionId : string , question : string ) => {
588+ if ( store . list ( ) . every ( ( lane ) => lane . id !== id ) ) {
589+ store . start ( { id, agentId : "builder" , description : id , brief : "b" } ) ;
590+ store . markRunning ( id ) ;
591+ }
592+ store . registerAsk ( id , {
593+ question,
594+ questionId,
595+ resolve : ( answer ) => {
596+ answers . push ( `${ id } :${ answer } ` ) ;
597+ } ,
598+ reject : ( ) => undefined ,
599+ } ) ;
600+ } ;
601+ try {
602+ await fn ( {
603+ shell,
604+ h,
605+ bridge,
606+ store,
607+ emitter,
608+ toPrimary,
609+ answers,
610+ park,
611+ } ) ;
612+ } finally {
613+ disposeGates ( ) ;
614+ unsubscribe ( ) ;
615+ bridge . dispose ( ) ;
616+ shell . dispose ( ) ;
617+ }
618+ } ,
619+ { width : 120 , height : 30 } ,
620+ ) ;
621+ }
622+
623+ async function stripText ( h : Harness ) : Promise < string | undefined > {
624+ await h . renderOnce ( ) ;
625+ await h . renderOnce ( ) ;
626+ return h
627+ . captureCharFrame ( )
628+ . split ( "\n" )
629+ . find ( ( row ) => row . includes ( "WAITING" ) ) ;
630+ }
631+
632+ test ( "the strip follows agent-ask snapshots while the wake reaches the director and send_input resolves it" , async ( ) => {
633+ await withStripRig (
634+ async ( { shell, h, bridge, store, toPrimary, answers, park } ) => {
635+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
636+ park ( "worker-session" , "q1" , "Which destination path should I use?" ) ;
637+ const parked = await stripText ( h ) ;
638+ expect ( parked ) . toContain ( "WORKER WAITING" ) ;
639+ expect ( parked ) . toContain ( "Which destination path should I use?" ) ;
640+ expect ( toPrimary ) . toEqual ( [ ] ) ;
641+
642+ // The primary settles: the wake goes to the director, the strip stays.
643+ bridge . handle ( { type : "inference.done" , data : { } } ) ;
644+ expect ( toPrimary ) . toHaveLength ( 1 ) ;
645+ expect ( toPrimary [ 0 ] ) . toContain ( "Which destination path should I use?" ) ;
646+ expect ( toPrimary [ 0 ] ) . toContain ( "using target worker-session" ) ;
647+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
648+
649+ // Director inference on the wake and its settlement are not resolution.
650+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
651+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
652+ bridge . handle ( { type : "inference.done" , data : { } } ) ;
653+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
654+
655+ // A republished snapshot of the same identity does not duplicate it.
656+ store . wake ( ) ;
657+ expect ( shell . workerWait . items ) . toHaveLength ( 1 ) ;
658+ expect ( toPrimary ) . toHaveLength ( 1 ) ;
659+
660+ // Only the director's send_input to the worker session resolves it.
661+ expect ( store . sendInputOne ( "worker-session" , "/srv/out" ) . ok ) . toBe ( true ) ;
662+ expect ( answers ) . toEqual ( [ "worker-session:/srv/out" ] ) ;
663+ expect ( await stripText ( h ) ) . toBeUndefined ( ) ;
664+ expect ( shell . workerWaitRow . visible ) . toBe ( false ) ;
665+ } ,
666+ ) ;
667+ } ) ;
668+
669+ test ( "multiple parked workers keep the displayed identity and an accurate count" , async ( ) => {
670+ await withStripRig ( async ( { shell, h, bridge, store, park } ) => {
671+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
672+ park ( "first" , "q1" , "First question?" ) ;
673+ park ( "second" , "q2" , "Second question?" ) ;
674+ park ( "third" , "q3" , "Third question?" ) ;
675+ let strip = await stripText ( h ) ;
676+ expect ( strip ) . toContain ( "First question?" ) ;
677+ expect ( strip ) . toContain ( "(+2 more)" ) ;
678+
679+ // A later worker's replacement question leaves the displayed one alone.
680+ store . cancelAsk ( "third" ) ;
681+ park ( "third" , "q4" , "Third replacement?" ) ;
682+ strip = await stripText ( h ) ;
683+ expect ( strip ) . toContain ( "First question?" ) ;
684+ expect ( strip ) . toContain ( "(+2 more)" ) ;
685+ expect ( shell . workerWait . items . map ( ( item ) => item . questionId ) ) . toEqual ( [
686+ "q1" ,
687+ "q2" ,
688+ "q4" ,
689+ ] ) ;
690+
691+ // The displayed identity resolves: fall back to snapshot order.
692+ expect ( store . sendInputOne ( "first" , "ok" ) . ok ) . toBe ( true ) ;
693+ strip = await stripText ( h ) ;
694+ expect ( strip ) . toContain ( "Second question?" ) ;
695+ expect ( strip ) . toContain ( "(+1 more)" ) ;
696+ } ) ;
697+ } ) ;
698+
699+ test ( "composer submission still goes to the director and never answers or clears the worker" , async ( ) => {
700+ await withStripRig (
701+ async ( { shell, h, bridge, store, toPrimary, answers, park } ) => {
702+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
703+ park ( "worker-session" , "q1" , "Which destination path should I use?" ) ;
704+ bridge . handle ( { type : "inference.done" , data : { } } ) ;
705+ expect ( toPrimary ) . toHaveLength ( 1 ) ;
706+ const parked = await stripText ( h ) ;
707+
708+ // Mid wake turn: Enter queues a steer for the primary session.
709+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
710+ shell . prompt . value = "try /srv/out" ;
711+ submitPrompt ( shell , "steer" ) ;
712+ expect ( shell . session . items . map ( ( item ) => item . text ) ) . toEqual ( [
713+ "try /srv/out" ,
714+ ] ) ;
715+ expect ( answers ) . toEqual ( [ ] ) ;
716+ expect ( store . hasPendingAsk ( "worker-session" ) ) . toBe ( true ) ;
717+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
718+ bridge . handle ( { type : "inference.done" , data : { } } ) ;
719+
720+ // Idle with the worker still live: Enter is a new primary turn and the
721+ // operator's words reach the director verbatim.
722+ shell . prompt . value = "/srv/out" ;
723+ submitPrompt ( shell , "steer" ) ;
724+ expect ( toPrimary . at ( - 1 ) ) . toBe ( "/srv/out" ) ;
725+ expect ( answers ) . toEqual ( [ ] ) ;
726+ expect ( store . hasPendingAsk ( "worker-session" ) ) . toBe ( true ) ;
727+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
728+
729+ // Typing alone changes nothing either.
730+ shell . prompt . value = "still thinking" ;
731+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
732+ expect ( store . hasPendingAsk ( "worker-session" ) ) . toBe ( true ) ;
733+ } ,
734+ ) ;
735+ } ) ;
736+
737+ test ( "ask_operator and permission gates never activate the strip" , async ( ) => {
738+ await withStripRig ( async ( { shell, h, bridge, emitter } ) => {
739+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
740+ let operatorAnswer : unknown ;
741+ emitter . emit ( "operator.gate" , {
742+ id : "ask-1" ,
743+ question : "Which destination path should I use?" ,
744+ options : [ "Cancel" , "Continue" ] ,
745+ resolve : ( value : unknown ) => {
746+ operatorAnswer = value ;
747+ } ,
748+ } ) ;
749+ expect ( shell . overlayList ) . not . toBeNull ( ) ;
750+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
751+ expect ( shell . layout . heights . worker_wait ) . toBe ( 0 ) ;
752+ expect ( await stripText ( h ) ) . toBeUndefined ( ) ;
753+ closeInsetOverlay ( shell ) ;
754+ expect ( operatorAnswer ) . toBeDefined ( ) ;
755+
756+ emitter . emit ( "permission.gate" , {
757+ id : "req-1" ,
758+ request : {
759+ tool : "run_shell" ,
760+ action : "Run shell command" ,
761+ subject : "rm -rf build" ,
762+ scopes : [ ] ,
763+ } ,
764+ resolve : ( ) => undefined ,
765+ } ) ;
766+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
767+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
768+ expect ( await stripText ( h ) ) . toBeUndefined ( ) ;
769+ } ) ;
770+ } ) ;
771+
772+ test ( "transcript wake text, status events and MCP attention never activate the strip" , async ( ) => {
773+ await withStripRig ( async ( { shell, h, bridge } ) => {
774+ bridge . handle ( {
775+ type : "assistant" ,
776+ text : `${ ASK_DIRECTOR_WAKE_PREFIX } worker builder parked question q1` ,
777+ } ) ;
778+ bridge . handle ( { type : "system" , text : "worker builder is waiting" } ) ;
779+ bridge . handle ( { type : "fleet" , running : 2 } ) ;
780+ setMcpNeedsAuth ( shell , [ "granola" ] ) ;
781+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
782+ expect ( shell . layout . heights . worker_wait ) . toBe ( 0 ) ;
783+ expect ( await stripText ( h ) ) . toBeUndefined ( ) ;
784+ } ) ;
785+ } ) ;
786+
787+ test ( "a live strip and an ask_operator overlay stay separate" , async ( ) => {
788+ await withStripRig ( async ( { shell, h, bridge, emitter, store, park } ) => {
789+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
790+ park ( "worker-session" , "q1" , "Which destination path should I use?" ) ;
791+ const parked = await stripText ( h ) ;
792+ emitter . emit ( "operator.gate" , {
793+ id : "ask-2" ,
794+ question : "Ship it?" ,
795+ options : [ "No" , "Yes" ] ,
796+ resolve : ( ) => undefined ,
797+ } ) ;
798+ expect ( shell . overlayList ) . not . toBeNull ( ) ;
799+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
800+ closeInsetOverlay ( shell ) ;
801+ expect ( await stripText ( h ) ) . toBe ( parked ) ;
802+ expect ( store . hasPendingAsk ( "worker-session" ) ) . toBe ( true ) ;
803+ } ) ;
804+ } ) ;
805+
806+ test ( "bridge teardown clears the strip" , async ( ) => {
807+ await withStripRig ( async ( { shell, h, bridge, park } ) => {
808+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
809+ park ( "worker-session" , "q1" , "Which destination path should I use?" ) ;
810+ expect ( await stripText ( h ) ) . toContain ( "WORKER WAITING" ) ;
811+ bridge . dispose ( ) ;
812+ expect ( shell . workerWait . items ) . toEqual ( [ ] ) ;
813+ expect ( await stripText ( h ) ) . toBeUndefined ( ) ;
814+ } ) ;
815+ } ) ;
0 commit comments