@@ -491,7 +491,7 @@ describe("wireGates", () => {
491491 ) ;
492492 } ) ;
493493
494- test ( "re-emitted marked primary ask keeps one live gate and resolves once " , async ( ) => {
494+ test ( "re-emitting a marked primary ask queues a second gate; both resolvers settle " , async ( ) => {
495495 const admitted : string [ ] = [ ] ;
496496 const settled : string [ ] = [ ] ;
497497 const resolved : unknown [ ] = [ ] ;
@@ -500,18 +500,71 @@ describe("wireGates", () => {
500500 const event = {
501501 id : "same-ask" ,
502502 source : PRIMARY_ASK_OPERATOR_SOURCE ,
503+ question : "Proceed?" ,
504+ options : [ "Cancel" , "Continue" ] ,
503505 resolve : ( result : unknown ) => resolved . push ( result ) ,
504506 } ;
505507 emitOperator ( emitter , event ) ;
506508 emitOperator ( emitter , event ) ;
507509
508- expect ( admitted ) . toEqual ( [ "same-ask" ] ) ;
510+ // The gate-wire layer no longer drops same-id events: dedup lives in
511+ // the input-required reducer (operator-input-required.ts), which keeps
512+ // the display single while gate-wire preserves HEAD's queue behavior.
513+ expect ( admitted ) . toEqual ( [ "same-ask" , "same-ask" ] ) ;
509514 expect ( shell . overlayKind ) . toBe ( "operator" ) ;
510515
511516 acceptOverlaySelection ( shell ) ;
517+ expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
518+ // The re-emitted gate was queued, not dropped: it takes the host next.
519+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
520+ acceptOverlaySelection ( shell ) ;
521+ expect ( resolved ) . toEqual ( [
522+ { kind : "option" , index : 0 } ,
523+ { kind : "option" , index : 0 } ,
524+ ] ) ;
525+ expect ( settled ) . toEqual ( [ "same-ask" , "same-ask" ] ) ;
526+ expect ( shell . overlayList ) . toBeNull ( ) ;
527+ } ,
528+ {
529+ hooks : {
530+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
531+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
532+ } ,
533+ } ,
534+ ) ;
535+ } ) ;
536+
537+ test ( "two distinct events sharing an id both resolve on accept" , async ( ) => {
538+ const admitted : string [ ] = [ ] ;
539+ const settled : string [ ] = [ ] ;
540+ const resolved : unknown [ ] = [ ] ;
541+ await withGates (
542+ async ( { shell, emitter } ) => {
543+ emitOperator ( emitter , {
544+ id : "dup" ,
545+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
546+ options : [ "Cancel" , "Continue" ] ,
547+ resolve : ( result : unknown ) => resolved . push ( result ) ,
548+ } ) ;
549+ emitOperator ( emitter , {
550+ id : "dup" ,
551+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
552+ options : [ "Cancel" , "Continue" ] ,
553+ resolve : ( result : unknown ) => resolved . push ( result ) ,
554+ } ) ;
512555
513- expect ( settled ) . toEqual ( [ "same-ask" ] ) ;
556+ expect ( admitted ) . toEqual ( [ "dup" , "dup" ] ) ;
557+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
558+
559+ acceptOverlaySelection ( shell ) ;
514560 expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
561+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
562+ acceptOverlaySelection ( shell ) ;
563+ expect ( resolved ) . toEqual ( [
564+ { kind : "option" , index : 0 } ,
565+ { kind : "option" , index : 0 } ,
566+ ] ) ;
567+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
515568 expect ( shell . overlayList ) . toBeNull ( ) ;
516569 } ,
517570 {
@@ -523,6 +576,87 @@ describe("wireGates", () => {
523576 ) ;
524577 } ) ;
525578
579+ test ( "two distinct events sharing an id both settle on timeout" , async ( ) => {
580+ const settled : string [ ] = [ ] ;
581+ const resolved : unknown [ ] = [ ] ;
582+ await withGates (
583+ async ( { shell, emitter } ) => {
584+ emitOperator ( emitter , {
585+ id : "dup" ,
586+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
587+ options : [ "Yes" , "No" ] ,
588+ timeoutMs : 5 ,
589+ resolve : ( result : unknown ) => resolved . push ( result ) ,
590+ } ) ;
591+ emitOperator ( emitter , {
592+ id : "dup" ,
593+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
594+ options : [ "Yes" , "No" ] ,
595+ // Arms only once the queued duplicate opens, so the two settles are
596+ // observable separately.
597+ timeoutMs : 80 ,
598+ resolve : ( result : unknown ) => resolved . push ( result ) ,
599+ } ) ;
600+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
601+
602+ await new Promise ( ( r ) => setTimeout ( r , 20 ) ) ;
603+ expect ( resolved ) . toEqual ( [ operatorCancelResult ( ) ] ) ;
604+ // The queued duplicate opened once the first timed out.
605+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
606+ await new Promise ( ( r ) => setTimeout ( r , 120 ) ) ;
607+ expect ( resolved ) . toEqual ( [
608+ operatorCancelResult ( ) ,
609+ operatorCancelResult ( ) ,
610+ ] ) ;
611+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
612+ expect ( shell . overlayList ) . toBeNull ( ) ;
613+ } ,
614+ {
615+ hooks : {
616+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
617+ } ,
618+ } ,
619+ ) ;
620+ } ) ;
621+
622+ test ( "a duplicate id still queued at dispose settles via the teardown sweep" , async ( ) => {
623+ const settled : string [ ] = [ ] ;
624+ const resolved : unknown [ ] = [ ] ;
625+ await withGates (
626+ async ( { shell, emitter, disposeGates } ) => {
627+ emitOperator ( emitter , {
628+ id : "dup" ,
629+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
630+ options : [ "Yes" , "No" ] ,
631+ resolve : ( result : unknown ) => resolved . push ( result ) ,
632+ } ) ;
633+ emitOperator ( emitter , {
634+ id : "dup" ,
635+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
636+ options : [ "Yes" , "No" ] ,
637+ resolve : ( result : unknown ) => resolved . push ( result ) ,
638+ } ) ;
639+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
640+
641+ acceptOverlaySelection ( shell ) ;
642+ expect ( resolved ) . toHaveLength ( 1 ) ;
643+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
644+
645+ disposeGates ( ) ;
646+ expect ( resolved ) . toEqual ( [
647+ { kind : "option" , index : 0 } ,
648+ operatorCancelResult ( ) ,
649+ ] ) ;
650+ expect ( settled ) . toEqual ( [ "dup" , "dup" ] ) ;
651+ } ,
652+ {
653+ hooks : {
654+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
655+ } ,
656+ } ,
657+ ) ;
658+ } ) ;
659+
526660 test ( "unmarked operator gates retain modal behavior without input-required hooks" , async ( ) => {
527661 let admitted = 0 ;
528662 let settled = 0 ;
@@ -543,6 +677,123 @@ describe("wireGates", () => {
543677 ) ;
544678 } ) ;
545679
680+ test ( "an MCP-trust-shaped operator gate opens the modal but never fires input-required hooks" , async ( ) => {
681+ let admitted = 0 ;
682+ let settled = 0 ;
683+ await withGates (
684+ async ( { shell, emitter } ) => {
685+ emitOperator ( emitter , {
686+ question : "Trust this local MCP server?" ,
687+ options : [ "Trust and connect" , "Cancel" ] ,
688+ } ) ;
689+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
690+ expect ( admitted ) . toBe ( 0 ) ;
691+ acceptOverlaySelection ( shell ) ;
692+ expect ( settled ) . toBe ( 0 ) ;
693+ } ,
694+ {
695+ hooks : {
696+ onPrimaryOperatorAdmitted : ( ) => admitted ++ ,
697+ onPrimaryOperatorSettled : ( ) => settled ++ ,
698+ } ,
699+ } ,
700+ ) ;
701+ } ) ;
702+
703+ test ( "free-text answer settles a marked ask with one admission and one settlement" , async ( ) => {
704+ const admitted : string [ ] = [ ] ;
705+ const settled : string [ ] = [ ] ;
706+ const resolved : unknown [ ] = [ ] ;
707+ await withGates (
708+ async ( { shell, emitter } ) => {
709+ emitOperator ( emitter , {
710+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
711+ options : [ "Continue" ] ,
712+ resolve : ( result : unknown ) => resolved . push ( result ) ,
713+ } ) ;
714+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
715+ setOverlayAnswerActive ( shell , true ) ;
716+ for ( const ch of "yes" ) {
717+ handleOverlayAnswerKey ( shell , {
718+ name : ch ,
719+ sequence : ch ,
720+ ctrl : false ,
721+ meta : false ,
722+ option : false ,
723+ } as unknown as KeyEvent ) ;
724+ }
725+ handleOverlayAnswerKey ( shell , {
726+ name : "return" ,
727+ sequence : "" ,
728+ ctrl : false ,
729+ meta : false ,
730+ option : false ,
731+ } as unknown as KeyEvent ) ;
732+ expect ( resolved ) . toEqual ( [ { kind : "custom" , text : "yes" } ] ) ;
733+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
734+ expect ( shell . overlayList ) . toBeNull ( ) ;
735+ } ,
736+ {
737+ hooks : {
738+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
739+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
740+ } ,
741+ } ,
742+ ) ;
743+ } ) ;
744+
745+ test ( "Esc settles a marked ask with one admission and one settlement" , async ( ) => {
746+ const admitted : string [ ] = [ ] ;
747+ const settled : string [ ] = [ ] ;
748+ const resolved : unknown [ ] = [ ] ;
749+ await withGates (
750+ async ( { shell, emitter } ) => {
751+ emitOperator ( emitter , {
752+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
753+ resolve : ( result : unknown ) => resolved . push ( result ) ,
754+ } ) ;
755+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
756+ closeInsetOverlay ( shell ) ;
757+ expect ( resolved ) . toEqual ( [ { kind : "cancel" } ] ) ;
758+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
759+ expect ( shell . overlayList ) . toBeNull ( ) ;
760+ } ,
761+ {
762+ hooks : {
763+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
764+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
765+ } ,
766+ } ,
767+ ) ;
768+ } ) ;
769+
770+ test ( "timeout settles a marked ask with one admission and one settlement" , async ( ) => {
771+ const admitted : string [ ] = [ ] ;
772+ const settled : string [ ] = [ ] ;
773+ const resolved : unknown [ ] = [ ] ;
774+ await withGates (
775+ async ( { shell, emitter } ) => {
776+ emitOperator ( emitter , {
777+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
778+ timeoutMs : 5 ,
779+ resolve : ( result : unknown ) => resolved . push ( result ) ,
780+ } ) ;
781+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
782+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
783+ await new Promise ( ( r ) => setTimeout ( r , 20 ) ) ;
784+ expect ( resolved ) . toEqual ( [ operatorCancelResult ( ) ] ) ;
785+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
786+ expect ( shell . overlayList ) . toBeNull ( ) ;
787+ } ,
788+ {
789+ hooks : {
790+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
791+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
792+ } ,
793+ } ,
794+ ) ;
795+ } ) ;
796+
546797 test ( "a marked queued ask settles its own indicator on abort and disposal" , async ( ) => {
547798 const settled : string [ ] = [ ] ;
548799 await withGates (
0 commit comments