@@ -512,7 +512,7 @@ describe("wireGates", () => {
512512 ) ;
513513 } ) ;
514514
515- test ( "re-emitted marked primary ask keeps one live gate and resolves once " , async ( ) => {
515+ test ( "re-emitting a marked primary ask queues a second gate; both resolvers settle " , async ( ) => {
516516 const admitted : string [ ] = [ ] ;
517517 const settled : string [ ] = [ ] ;
518518 const resolved : unknown [ ] = [ ] ;
@@ -521,18 +521,71 @@ describe("wireGates", () => {
521521 const event = {
522522 id : "same-ask" ,
523523 source : PRIMARY_ASK_OPERATOR_SOURCE ,
524+ question : "Proceed?" ,
525+ options : [ "Cancel" , "Continue" ] ,
524526 resolve : ( result : unknown ) => resolved . push ( result ) ,
525527 } ;
526528 emitOperator ( emitter , event ) ;
527529 emitOperator ( emitter , event ) ;
528530
529- expect ( admitted ) . toEqual ( [ "same-ask" ] ) ;
531+ // The gate-wire layer no longer drops same-id events: dedup lives in
532+ // the input-required reducer (operator-input-required.ts), which keeps
533+ // the display single while gate-wire preserves HEAD's queue behavior.
534+ expect ( admitted ) . toEqual ( [ "same-ask" , "same-ask" ] ) ;
530535 expect ( shell . overlayKind ) . toBe ( "operator" ) ;
531536
532537 acceptOverlaySelection ( shell ) ;
538+ expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
539+ // The re-emitted gate was queued, not dropped: it takes the host next.
540+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
541+ acceptOverlaySelection ( shell ) ;
542+ expect ( resolved ) . toEqual ( [
543+ { kind : "option" , index : 0 } ,
544+ { kind : "option" , index : 0 } ,
545+ ] ) ;
546+ expect ( settled ) . toEqual ( [ "same-ask" , "same-ask" ] ) ;
547+ expect ( shell . overlayList ) . toBeNull ( ) ;
548+ } ,
549+ {
550+ hooks : {
551+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
552+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
553+ } ,
554+ } ,
555+ ) ;
556+ } ) ;
557+
558+ test ( "two distinct events sharing an id both resolve on accept" , async ( ) => {
559+ const admitted : string [ ] = [ ] ;
560+ const settled : string [ ] = [ ] ;
561+ const resolved : unknown [ ] = [ ] ;
562+ await withGates (
563+ async ( { shell, emitter } ) => {
564+ emitOperator ( emitter , {
565+ id : "dup" ,
566+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
567+ options : [ "Cancel" , "Continue" ] ,
568+ resolve : ( result : unknown ) => resolved . push ( result ) ,
569+ } ) ;
570+ emitOperator ( emitter , {
571+ id : "dup" ,
572+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
573+ options : [ "Cancel" , "Continue" ] ,
574+ resolve : ( result : unknown ) => resolved . push ( result ) ,
575+ } ) ;
533576
534- expect ( settled ) . toEqual ( [ "same-ask" ] ) ;
577+ expect ( admitted ) . toEqual ( [ "dup" , "dup" ] ) ;
578+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
579+
580+ acceptOverlaySelection ( shell ) ;
535581 expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
582+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
583+ acceptOverlaySelection ( shell ) ;
584+ expect ( resolved ) . toEqual ( [
585+ { kind : "option" , index : 0 } ,
586+ { kind : "option" , index : 0 } ,
587+ ] ) ;
588+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
536589 expect ( shell . overlayList ) . toBeNull ( ) ;
537590 } ,
538591 {
@@ -544,6 +597,87 @@ describe("wireGates", () => {
544597 ) ;
545598 } ) ;
546599
600+ test ( "two distinct events sharing an id both settle on timeout" , async ( ) => {
601+ const settled : string [ ] = [ ] ;
602+ const resolved : unknown [ ] = [ ] ;
603+ await withGates (
604+ async ( { shell, emitter } ) => {
605+ emitOperator ( emitter , {
606+ id : "dup" ,
607+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
608+ options : [ "Yes" , "No" ] ,
609+ timeoutMs : 5 ,
610+ resolve : ( result : unknown ) => resolved . push ( result ) ,
611+ } ) ;
612+ emitOperator ( emitter , {
613+ id : "dup" ,
614+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
615+ options : [ "Yes" , "No" ] ,
616+ // Arms only once the queued duplicate opens, so the two settles are
617+ // observable separately.
618+ timeoutMs : 80 ,
619+ resolve : ( result : unknown ) => resolved . push ( result ) ,
620+ } ) ;
621+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
622+
623+ await new Promise ( ( r ) => setTimeout ( r , 20 ) ) ;
624+ expect ( resolved ) . toEqual ( [ operatorCancelResult ( ) ] ) ;
625+ // The queued duplicate opened once the first timed out.
626+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
627+ await new Promise ( ( r ) => setTimeout ( r , 120 ) ) ;
628+ expect ( resolved ) . toEqual ( [
629+ operatorCancelResult ( ) ,
630+ operatorCancelResult ( ) ,
631+ ] ) ;
632+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
633+ expect ( shell . overlayList ) . toBeNull ( ) ;
634+ } ,
635+ {
636+ hooks : {
637+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
638+ } ,
639+ } ,
640+ ) ;
641+ } ) ;
642+
643+ test ( "a duplicate id still queued at dispose settles via the teardown sweep" , async ( ) => {
644+ const settled : string [ ] = [ ] ;
645+ const resolved : unknown [ ] = [ ] ;
646+ await withGates (
647+ async ( { shell, emitter, disposeGates } ) => {
648+ emitOperator ( emitter , {
649+ id : "dup" ,
650+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
651+ options : [ "Yes" , "No" ] ,
652+ resolve : ( result : unknown ) => resolved . push ( result ) ,
653+ } ) ;
654+ emitOperator ( emitter , {
655+ id : "dup" ,
656+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
657+ options : [ "Yes" , "No" ] ,
658+ resolve : ( result : unknown ) => resolved . push ( result ) ,
659+ } ) ;
660+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
661+
662+ acceptOverlaySelection ( shell ) ;
663+ expect ( resolved ) . toHaveLength ( 1 ) ;
664+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
665+
666+ disposeGates ( ) ;
667+ expect ( resolved ) . toEqual ( [
668+ { kind : "option" , index : 0 } ,
669+ operatorCancelResult ( ) ,
670+ ] ) ;
671+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
672+ } ,
673+ {
674+ hooks : {
675+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
676+ } ,
677+ } ,
678+ ) ;
679+ } ) ;
680+
547681 test ( "unmarked operator gates retain modal behavior without input-required hooks" , async ( ) => {
548682 let admitted = 0 ;
549683 let settled = 0 ;
@@ -564,6 +698,123 @@ describe("wireGates", () => {
564698 ) ;
565699 } ) ;
566700
701+ test ( "an MCP-trust-shaped operator gate opens the modal but never fires input-required hooks" , async ( ) => {
702+ let admitted = 0 ;
703+ let settled = 0 ;
704+ await withGates (
705+ async ( { shell, emitter } ) => {
706+ emitOperator ( emitter , {
707+ question : "Trust this local MCP server?" ,
708+ options : [ "Trust and connect" , "Cancel" ] ,
709+ } ) ;
710+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
711+ expect ( admitted ) . toBe ( 0 ) ;
712+ acceptOverlaySelection ( shell ) ;
713+ expect ( settled ) . toBe ( 0 ) ;
714+ } ,
715+ {
716+ hooks : {
717+ onPrimaryOperatorAdmitted : ( ) => admitted ++ ,
718+ onPrimaryOperatorSettled : ( ) => settled ++ ,
719+ } ,
720+ } ,
721+ ) ;
722+ } ) ;
723+
724+ test ( "free-text answer settles a marked ask with one admission and one settlement" , async ( ) => {
725+ const admitted : string [ ] = [ ] ;
726+ const settled : string [ ] = [ ] ;
727+ const resolved : unknown [ ] = [ ] ;
728+ await withGates (
729+ async ( { shell, emitter } ) => {
730+ emitOperator ( emitter , {
731+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
732+ options : [ "Continue" ] ,
733+ resolve : ( result : unknown ) => resolved . push ( result ) ,
734+ } ) ;
735+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
736+ setOverlayAnswerActive ( shell , true ) ;
737+ for ( const ch of "yes" ) {
738+ handleOverlayAnswerKey ( shell , {
739+ name : ch ,
740+ sequence : ch ,
741+ ctrl : false ,
742+ meta : false ,
743+ option : false ,
744+ } as unknown as KeyEvent ) ;
745+ }
746+ handleOverlayAnswerKey ( shell , {
747+ name : "return" ,
748+ sequence : "" ,
749+ ctrl : false ,
750+ meta : false ,
751+ option : false ,
752+ } as unknown as KeyEvent ) ;
753+ expect ( resolved ) . toEqual ( [ { kind : "custom" , text : "yes" } ] ) ;
754+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
755+ expect ( shell . overlayList ) . toBeNull ( ) ;
756+ } ,
757+ {
758+ hooks : {
759+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
760+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
761+ } ,
762+ } ,
763+ ) ;
764+ } ) ;
765+
766+ test ( "Esc settles a marked ask with one admission and one settlement" , async ( ) => {
767+ const admitted : string [ ] = [ ] ;
768+ const settled : string [ ] = [ ] ;
769+ const resolved : unknown [ ] = [ ] ;
770+ await withGates (
771+ async ( { shell, emitter } ) => {
772+ emitOperator ( emitter , {
773+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
774+ resolve : ( result : unknown ) => resolved . push ( result ) ,
775+ } ) ;
776+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
777+ closeInsetOverlay ( shell ) ;
778+ expect ( resolved ) . toEqual ( [ { kind : "cancel" } ] ) ;
779+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
780+ expect ( shell . overlayList ) . toBeNull ( ) ;
781+ } ,
782+ {
783+ hooks : {
784+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
785+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
786+ } ,
787+ } ,
788+ ) ;
789+ } ) ;
790+
791+ test ( "timeout settles a marked ask with one admission and one settlement" , async ( ) => {
792+ const admitted : string [ ] = [ ] ;
793+ const settled : string [ ] = [ ] ;
794+ const resolved : unknown [ ] = [ ] ;
795+ await withGates (
796+ async ( { shell, emitter } ) => {
797+ emitOperator ( emitter , {
798+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
799+ timeoutMs : 5 ,
800+ resolve : ( result : unknown ) => resolved . push ( result ) ,
801+ } ) ;
802+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
803+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
804+ await new Promise ( ( r ) => setTimeout ( r , 20 ) ) ;
805+ expect ( resolved ) . toEqual ( [ operatorCancelResult ( ) ] ) ;
806+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
807+ expect ( shell . overlayList ) . toBeNull ( ) ;
808+ } ,
809+ {
810+ hooks : {
811+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
812+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
813+ } ,
814+ } ,
815+ ) ;
816+ } ) ;
817+
567818 test ( "a marked queued ask settles its own indicator on abort and disposal" , async ( ) => {
568819 const settled : string [ ] = [ ] ;
569820 await withGates (
0 commit comments