Skip to content

Commit b6906a5

Browse files
committed
test(tui): cover session overlay abort wiring
Removing registerOverlayAbort from session assembly must fail tests. The parked overlay binding is the session wiring, not a local duplicate of it in the resume unit test.
1 parent 1a890ef commit b6906a5

4 files changed

Lines changed: 119 additions & 25 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* TUI session overlay abort slot. Approval resume registers the parked
3+
* overlay controller here; the permission gate's identity signal merges it
4+
* so a reactor approval timeout dismisses the overlay instead of leaving it
5+
* parked after the call is gone.
6+
*/
7+
8+
interface ParkedOverlayAbortBinding {
9+
registerOverlayAbort: (controller: AbortController | undefined) => void;
10+
identitySignal: (identity: AbortSignal) => AbortSignal;
11+
}
12+
13+
export function createParkedOverlayAbortBinding(): ParkedOverlayAbortBinding {
14+
const parkedOverlayAbort = {
15+
controller: undefined as AbortController | undefined,
16+
};
17+
return {
18+
registerOverlayAbort: (controller) => {
19+
parkedOverlayAbort.controller = controller;
20+
},
21+
identitySignal: (identity) => {
22+
const parked = parkedOverlayAbort.controller?.signal;
23+
return parked === undefined
24+
? identity
25+
: AbortSignal.any([identity, parked]);
26+
},
27+
};
28+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { PermissionRequest } from "../../permission/types.js";
3+
import type { PermissionGateEvent } from "../gate-events.js";
4+
import { createGateRequestApproval } from "../request-approval.js";
5+
import { createParkedOverlayAbortBinding } from "./parked-overlay-abort.js";
6+
import { assembleTUISession } from "./session.js";
7+
8+
const request: PermissionRequest = {
9+
tool: "run_shell",
10+
action: "Run shell command",
11+
subject: "true",
12+
scopes: [],
13+
};
14+
15+
function noTimeout(): undefined {
16+
return undefined;
17+
}
18+
19+
describe("parked overlay abort binding", () => {
20+
test("aborting the registered controller aborts the merged identity signal", () => {
21+
const binding = createParkedOverlayAbortBinding();
22+
const identity = new AbortController();
23+
const overlay = new AbortController();
24+
binding.registerOverlayAbort(overlay);
25+
const merged = binding.identitySignal(identity.signal);
26+
expect(merged.aborted).toBe(false);
27+
overlay.abort("approval timed out");
28+
expect(merged.aborted).toBe(true);
29+
expect(merged.reason).toBe("approval timed out");
30+
expect(identity.signal.aborted).toBe(false);
31+
});
32+
33+
test("without a registered controller the identity signal is unchanged", () => {
34+
const binding = createParkedOverlayAbortBinding();
35+
const identity = new AbortController();
36+
expect(binding.identitySignal(identity.signal)).toBe(identity.signal);
37+
});
38+
39+
test("clearing the registration drops the overlay from later identity signals", () => {
40+
const binding = createParkedOverlayAbortBinding();
41+
const identity = new AbortController();
42+
const overlay = new AbortController();
43+
binding.registerOverlayAbort(overlay);
44+
binding.registerOverlayAbort(undefined);
45+
const merged = binding.identitySignal(identity.signal);
46+
overlay.abort("approval timed out");
47+
expect(merged.aborted).toBe(false);
48+
});
49+
});
50+
51+
describe("assembleTUISession overlay abort wiring", () => {
52+
test("registers the parked overlay abort on approval resume and merges it into the gate identity signal", () => {
53+
const src = assembleTUISession.toString();
54+
expect(src).toContain("createParkedOverlayAbortBinding");
55+
expect(src).toContain("registerOverlayAbort");
56+
expect(src).toContain("parkedOverlay.identitySignal");
57+
expect(src).toContain("parkedOverlay.registerOverlayAbort");
58+
});
59+
60+
test("a registered overlay abort dismisses the gate event the session identity signal feeds", async () => {
61+
const binding = createParkedOverlayAbortBinding();
62+
const identity = new AbortController();
63+
let captured: PermissionGateEvent | undefined;
64+
const requestApproval = createGateRequestApproval({
65+
emitGate: (event) => {
66+
captured = event;
67+
return true;
68+
},
69+
approvalTimeout: noTimeout,
70+
identitySignal: () => binding.identitySignal(identity.signal),
71+
});
72+
const overlay = new AbortController();
73+
binding.registerOverlayAbort(overlay);
74+
const pending = requestApproval(request);
75+
expect(captured?.signal?.aborted).toBe(false);
76+
overlay.abort("approval timed out");
77+
expect(captured?.signal?.aborted).toBe(true);
78+
expect(captured?.signal?.reason).toBe("approval timed out");
79+
captured?.resolve({ allow: false, message: "approval timed out" });
80+
expect((await pending).allow).toBe(false);
81+
});
82+
});

‎src/tui/runner/session.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
type RunnerState,
120120
type TUIStart,
121121
} from "./state.js";
122+
import { createParkedOverlayAbortBinding } from "./parked-overlay-abort.js";
122123

123124
export async function assembleTUISession(
124125
state: RunnerState,
@@ -171,9 +172,7 @@ export async function assembleTUISession(
171172

172173
const correlationAcceptance = createCorrelationAcceptance();
173174
const parkedApprovalCancel = { fn: undefined as (() => void) | undefined };
174-
const parkedOverlayAbort = {
175-
controller: undefined as AbortController | undefined,
176-
};
175+
const parkedOverlay = createParkedOverlayAbortBinding();
177176
const deliveryGeneration = createDeliveryGeneration(() => {
178177
parkedApprovalCancel.fn?.();
179178
correlationAcceptance.settleAll();
@@ -188,13 +187,8 @@ export async function assembleTUISession(
188187
requestApproval: createGateRequestApproval({
189188
emitGate: (event) => emitter.emit("permission.gate", event),
190189
approvalTimeout,
191-
identitySignal: () => {
192-
const identity = deliveryGeneration.signal();
193-
const parked = parkedOverlayAbort.controller?.signal;
194-
return parked === undefined
195-
? identity
196-
: AbortSignal.any([identity, parked]);
197-
},
190+
identitySignal: () =>
191+
parkedOverlay.identitySignal(deliveryGeneration.signal()),
198192
}),
199193
getActiveProviderModel: () =>
200194
`${state.config.providerName}:${state.config.model}`,
@@ -535,9 +529,7 @@ export async function assembleTUISession(
535529
registerParkedCancel: (cancel) => {
536530
parkedApprovalCancel.fn = cancel;
537531
},
538-
registerOverlayAbort: (controller) => {
539-
parkedOverlayAbort.controller = controller;
540-
},
532+
registerOverlayAbort: parkedOverlay.registerOverlayAbort,
541533
deliver: (message, stillCurrent) => {
542534
return sessionOps.enqueue(async () => {
543535
if (!stillCurrent()) return;

‎tests/unit/approval-resume.test.ts‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { createGateRequestApproval } from "../../src/tui/request-approval.js";
2828
import { startInterruptRebuild } from "../../src/tui/runner/exit.js";
2929
import { runWhileAgentBusy } from "../../src/tui/runner/state.js";
30+
import { createParkedOverlayAbortBinding } from "../../src/tui/runner/parked-overlay-abort.js";
3031

3132
const SUSPENDED: SendResult = {
3233
type: "suspended",
@@ -756,9 +757,7 @@ describe("approval resume occupancy until correlation", () => {
756757

757758
describe("approval resume overlay on reactor timeout", () => {
758759
test("auto-abandons the overlay and unsticks occupancy when the parked call times out", async () => {
759-
const parkedOverlayAbort = {
760-
controller: undefined as AbortController | undefined,
761-
};
760+
const parkedOverlay = createParkedOverlayAbortBinding();
762761
const generation = createDeliveryGeneration();
763762
const turns = [userTurn()];
764763
let overlay: PermissionGateEvent | undefined;
@@ -786,12 +785,7 @@ describe("approval resume overlay on reactor timeout", () => {
786785
return true;
787786
},
788787
approvalTimeout: () => undefined,
789-
identitySignal: () => {
790-
const identity = generation.signal();
791-
const parked = parkedOverlayAbort.controller?.signal;
792-
if (parked === undefined) return identity;
793-
return AbortSignal.any([identity, parked]);
794-
},
788+
identitySignal: () => parkedOverlay.identitySignal(generation.signal()),
795789
});
796790
const gate = createPermissionGate({
797791
approvals: [],
@@ -808,9 +802,7 @@ describe("approval resume overlay on reactor timeout", () => {
808802
history: async () => turns,
809803
}),
810804
captureGeneration: generation.capture,
811-
registerOverlayAbort: (controller) => {
812-
parkedOverlayAbort.controller = controller;
813-
},
805+
registerOverlayAbort: parkedOverlay.registerOverlayAbort,
814806
parkedTimeoutPollMs: 5,
815807
gate,
816808
});

0 commit comments

Comments
 (0)