Skip to content

Commit b8943e7

Browse files
Merge permission-gate auto-deny and Esc resolution
2 parents 870ae54 + dbf21e4 commit b8943e7

4 files changed

Lines changed: 361 additions & 29 deletions

File tree

src/tui-opentui/gate-wire.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { withTestRenderer, type Harness } from "./harness.js"
99
import { OVERLAY_MAX_FRACTION } from "./geometry/index.js"
1010
import {
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+
426641
describe("permission overlay height", () => {
427642
const openGate = (shell: AppShell, scopeCount: number): void => {
428643
const emitter = new EventEmitter()

0 commit comments

Comments
 (0)