@@ -9,6 +9,7 @@ import { withTestRenderer, type Harness } from "./harness.js"
99import { OVERLAY_MAX_FRACTION } from "./geometry/index.js"
1010import {
1111 acceptOverlaySelection ,
12+ closeInsetOverlay ,
1213 createAppShell ,
1314 exitOverlayAnswerMode ,
1415 handleOverlayAnswerKey ,
@@ -423,6 +424,220 @@ describe("wireGates", () => {
423424 } )
424425} )
425426
427+ describe ( "permission.gate auto-deny" , ( ) => {
428+ test ( "timeoutMs elapsing auto-denies with the timeout message and closes the overlay" , async ( ) => {
429+ await withTestRenderer ( async ( h ) => {
430+ const shell = createAppShell ( h . renderer , {
431+ terminal : { columns : 80 , rows : 24 } ,
432+ run : "idle" ,
433+ } )
434+ const emitter = new EventEmitter ( )
435+ let resolved : unknown
436+ try {
437+ wireGates ( emitter , shell )
438+ emitter . emit ( "permission.gate" , {
439+ request : baseRequest ( ) ,
440+ resolve : ( outcome : unknown ) => {
441+ resolved = outcome
442+ } ,
443+ timeoutMs : 5 ,
444+ timeoutMessage : "goal mode: no answer in time" ,
445+ } )
446+ expect ( shell . overlayKind ) . toBe ( "permissions" )
447+
448+ await new Promise ( ( r ) => setTimeout ( r , 20 ) )
449+
450+ expect ( resolved ) . toEqual ( {
451+ allow : false ,
452+ message : "goal mode: no answer in time" ,
453+ } )
454+ expect ( shell . overlayList ) . toBeNull ( )
455+ } finally {
456+ shell . dispose ( )
457+ }
458+ } )
459+ } )
460+
461+ test ( "aborting the signal while the overlay is open auto-denies and closes it" , async ( ) => {
462+ await withTestRenderer ( async ( h ) => {
463+ const shell = createAppShell ( h . renderer , {
464+ terminal : { columns : 80 , rows : 24 } ,
465+ run : "idle" ,
466+ } )
467+ const emitter = new EventEmitter ( )
468+ const controller = new AbortController ( )
469+ let resolved : unknown
470+ try {
471+ wireGates ( emitter , shell )
472+ emitter . emit ( "permission.gate" , {
473+ request : baseRequest ( ) ,
474+ resolve : ( outcome : unknown ) => {
475+ resolved = outcome
476+ } ,
477+ signal : controller . signal ,
478+ } )
479+ expect ( shell . overlayKind ) . toBe ( "permissions" )
480+
481+ controller . abort ( )
482+
483+ expect ( resolved ) . toEqual ( {
484+ allow : false ,
485+ message : "tool no longer running; permission request denied" ,
486+ } )
487+ expect ( shell . overlayList ) . toBeNull ( )
488+ } finally {
489+ shell . dispose ( )
490+ }
491+ } )
492+ } )
493+
494+ test ( "resolving normally clears the timer instead of firing it later" , async ( ) => {
495+ await withTestRenderer ( async ( h ) => {
496+ const shell = createAppShell ( h . renderer , {
497+ terminal : { columns : 80 , rows : 24 } ,
498+ run : "idle" ,
499+ } )
500+ const emitter = new EventEmitter ( )
501+ let resolveCount = 0
502+ let lastOutcome : unknown
503+ try {
504+ wireGates ( emitter , shell )
505+ emitter . emit ( "permission.gate" , {
506+ request : baseRequest ( ) ,
507+ resolve : ( outcome : unknown ) => {
508+ resolveCount += 1
509+ lastOutcome = outcome
510+ } ,
511+ timeoutMs : 10 ,
512+ } )
513+
514+ acceptOverlaySelection ( shell )
515+ expect ( resolveCount ) . toBe ( 1 )
516+ expect ( lastOutcome ) . toEqual ( { allow : false } )
517+
518+ await new Promise ( ( r ) => setTimeout ( r , 25 ) )
519+ expect ( resolveCount ) . toBe ( 1 )
520+ } finally {
521+ shell . dispose ( )
522+ }
523+ } )
524+ } )
525+
526+ test ( "a queued gate's timeout fires while it waits, without disturbing the open one" , async ( ) => {
527+ await withTestRenderer ( async ( h ) => {
528+ const shell = createAppShell ( h . renderer , {
529+ terminal : { columns : 80 , rows : 24 } ,
530+ run : "idle" ,
531+ } )
532+ const emitter = new EventEmitter ( )
533+ let firstResolved : unknown
534+ let secondResolved : unknown
535+ try {
536+ wireGates ( emitter , shell )
537+ emitter . emit ( "permission.gate" , {
538+ request : baseRequest ( ) ,
539+ resolve : ( outcome : unknown ) => {
540+ firstResolved = outcome
541+ } ,
542+ } )
543+ emitter . emit ( "permission.gate" , {
544+ request : baseRequest ( { tool : "queued_tool" } ) ,
545+ resolve : ( outcome : unknown ) => {
546+ secondResolved = outcome
547+ } ,
548+ timeoutMs : 5 ,
549+ timeoutMessage : "queued gate timed out" ,
550+ } )
551+ // Second gate has not opened yet — it is waiting behind the first.
552+ expect ( shell . overlayKind ) . toBe ( "permissions" )
553+
554+ await new Promise ( ( r ) => setTimeout ( r , 20 ) )
555+
556+ // The queued gate resolved on its own without ever opening...
557+ expect ( secondResolved ) . toEqual ( {
558+ allow : false ,
559+ message : "queued gate timed out" ,
560+ } )
561+ // ...and the open overlay (the first gate) is undisturbed.
562+ expect ( firstResolved ) . toBeUndefined ( )
563+ expect ( shell . overlayKind ) . toBe ( "permissions" )
564+
565+ acceptOverlaySelection ( shell )
566+ expect ( firstResolved ) . toEqual ( { allow : false } )
567+ // No queued gate left to open once the first closes.
568+ expect ( shell . overlayList ) . toBeNull ( )
569+ } finally {
570+ shell . dispose ( )
571+ }
572+ } )
573+ } )
574+ } )
575+
576+ describe ( "Esc on a gate overlay settles the awaited promise" , ( ) => {
577+ test ( "permission.gate: Esc denies instead of abandoning the promise" , async ( ) => {
578+ await withTestRenderer ( async ( h ) => {
579+ const shell = createAppShell ( h . renderer , {
580+ terminal : { columns : 80 , rows : 24 } ,
581+ run : "idle" ,
582+ } )
583+ const emitter = new EventEmitter ( )
584+ let resolved : unknown
585+ let resolveCount = 0
586+ try {
587+ wireGates ( emitter , shell )
588+ emitter . emit ( "permission.gate" , {
589+ request : baseRequest ( ) ,
590+ resolve : ( outcome : unknown ) => {
591+ resolveCount += 1
592+ resolved = outcome
593+ } ,
594+ } )
595+ expect ( shell . overlayKind ) . toBe ( "permissions" )
596+
597+ closeInsetOverlay ( shell )
598+
599+ expect ( shell . overlayList ) . toBeNull ( )
600+ expect ( resolveCount ) . toBe ( 1 )
601+ expect ( resolved ) . toEqual ( { allow : false } )
602+ } finally {
603+ shell . dispose ( )
604+ }
605+ } )
606+ } )
607+
608+ test ( "operator.gate: Esc cancels instead of abandoning the promise" , async ( ) => {
609+ await withTestRenderer ( async ( h ) => {
610+ const shell = createAppShell ( h . renderer , {
611+ terminal : { columns : 80 , rows : 24 } ,
612+ run : "idle" ,
613+ } )
614+ const emitter = new EventEmitter ( )
615+ let resolved : unknown
616+ let resolveCount = 0
617+ try {
618+ wireGates ( emitter , shell )
619+ emitter . emit ( "operator.gate" , {
620+ question : "Proceed?" ,
621+ options : [ "Cancel" , "Continue" ] ,
622+ resolve : ( result : unknown ) => {
623+ resolveCount += 1
624+ resolved = result
625+ } ,
626+ } )
627+ expect ( shell . overlayKind ) . toBe ( "operator" )
628+
629+ closeInsetOverlay ( shell )
630+
631+ expect ( shell . overlayList ) . toBeNull ( )
632+ expect ( resolveCount ) . toBe ( 1 )
633+ expect ( resolved ) . toEqual ( { kind : "cancel" } )
634+ } finally {
635+ shell . dispose ( )
636+ }
637+ } )
638+ } )
639+ } )
640+
426641describe ( "permission overlay height" , ( ) => {
427642 const openGate = ( shell : AppShell , scopeCount : number ) : void => {
428643 const emitter = new EventEmitter ( )
0 commit comments