Skip to content

Commit 62feb32

Browse files
committed
test(tui): complete input-required persistence, negative, teardown, and session coverage
Adds live theme switching, transcript/scroll/typing/forced-repaint and overlay-open persistence, settle-one-of-two and both-strip ordering, MCP/permission/ask_director/OAuth negatives, a product-host teardown drain test, and the primary-session integration regression proving the marker, unique id, and admission-to-removal compose.
1 parent 09f8ebe commit 62feb32

4 files changed

Lines changed: 408 additions & 0 deletions

File tree

‎src/tui/operator-input-required-chrome.test.ts‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2+
import { EventEmitter } from "node:events";
23
import { rgbToHex, type CapturedSpan, type RGBA } from "@opentui/core";
34

5+
import type { PendingAskWake } from "../subagent/fleet-report.js";
46
import {
57
PRIMARY_ASK_OPERATOR_SOURCE,
68
type OperatorGateEvent,
79
} from "./gate-events.js";
10+
import { wireGates } from "./gate-wire.js";
811
import { withTestRenderer, type Harness } from "./harness.js";
912
import { BORDER } from "./prompt-border.js";
13+
import { repaintShellForTheme } from "./runner/settings.js";
1014
import {
15+
appendStreamRow,
1116
clearOperatorInputRequired,
17+
paintChrome,
1218
relayout,
1319
setOperatorInputRequiredGate,
20+
setStatusFlash,
21+
setWorkerWaitAsks,
1422
} from "./shell/chrome.js";
1523
import { createAppShell } from "./shell/index.js";
1624
import type { AppShell } from "./shell/internals.js";
@@ -28,6 +36,29 @@ function gate(id: string, question: string): OperatorGateEvent {
2836
};
2937
}
3038

39+
const WAITING_WORKER: PendingAskWake = {
40+
sessionId: "sess-builder",
41+
agentId: "builder",
42+
description: "copy assets",
43+
question: "Which destination path should I use?",
44+
questionId: "q1",
45+
};
46+
47+
/**
48+
* Wire an emitter to the shell the way mountProductHost does, so gate events
49+
* reach the strip through the real admission/settlement hooks.
50+
*/
51+
function wirePrimaryHost(shell: AppShell, emitter: EventEmitter): () => void {
52+
return wireGates(emitter, shell, {
53+
onGateOpened: () => undefined,
54+
onGateClosed: () => undefined,
55+
onPrimaryOperatorAdmitted: (event) =>
56+
setOperatorInputRequiredGate(shell, event),
57+
onPrimaryOperatorSettled: (event) =>
58+
clearOperatorInputRequired(shell, event.id),
59+
});
60+
}
61+
3162
async function withShell(
3263
fn: (shell: AppShell, h: Harness) => Promise<void>,
3364
size = { columns: 120, rows: 30 },
@@ -148,4 +179,164 @@ describe("operator input required strip", () => {
148179
});
149180
});
150181
}
182+
183+
test("a live theme switch repaints the strip in the new palette", async () => {
184+
setTheme("corbits-dark");
185+
await withShell(async (shell, h) => {
186+
setOperatorInputRequiredGate(
187+
shell,
188+
gate("one", "Which branch should I use?"),
189+
);
190+
await frameRows(h);
191+
setTheme("corbits-light");
192+
repaintShellForTheme(shell);
193+
await frameRows(h);
194+
const label = spanWith(h, "INPUT REQUIRED");
195+
expect(label === undefined ? null : hex(label.fg)).toBe(
196+
UI.action.toLowerCase(),
197+
);
198+
});
199+
});
200+
201+
test("the strip persists through transcript growth, scrolling, typing, flashes and redraws", async () => {
202+
await withShell(async (shell, h) => {
203+
setOperatorInputRequiredGate(
204+
shell,
205+
gate("one", "Which branch should I use?"),
206+
);
207+
const before = await frameRows(h);
208+
const painted = before[inputRow(before)];
209+
for (let i = 0; i < 60; i++) {
210+
appendStreamRow(shell, { role: "assistant", text: `row ${i}` });
211+
}
212+
shell.transcript.scrollTo(0);
213+
shell.prompt.value = "hold on, let me check the path";
214+
setStatusFlash(shell, "copied");
215+
paintChrome(shell);
216+
paintChrome(shell, { force: true });
217+
relayout(shell);
218+
const after = await frameRows(h);
219+
expect(after[inputRow(after)]).toBe(painted);
220+
expect(shell.layout.heights.input_required).toBe(1);
221+
});
222+
});
223+
224+
test("the strip stays visible while an operator modal is open", async () => {
225+
await withShell(async (shell, h) => {
226+
const emitter = new EventEmitter();
227+
const disposeGates = wirePrimaryHost(shell, emitter);
228+
try {
229+
emitter.emit("operator.gate", {
230+
id: "one",
231+
source: PRIMARY_ASK_OPERATOR_SOURCE,
232+
question: "Which branch should I use?",
233+
options: ["main", "dev"],
234+
resolve: () => undefined,
235+
});
236+
expect(shell.overlayKind).toBe("operator");
237+
const rows = await frameRows(h);
238+
expect(shell.layout.heights.input_required).toBe(1);
239+
expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(true);
240+
} finally {
241+
disposeGates();
242+
}
243+
});
244+
});
245+
246+
test("settling one of two leaves the other strip in place", async () => {
247+
await withShell(async (shell, h) => {
248+
setOperatorInputRequiredGate(
249+
shell,
250+
gate("one", "Which branch should I use?"),
251+
);
252+
setOperatorInputRequiredGate(
253+
shell,
254+
gate("two", "Should I include tests?"),
255+
);
256+
await frameRows(h);
257+
clearOperatorInputRequired(shell, "one");
258+
const rows = await frameRows(h);
259+
expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(true);
260+
expect(rows.some((row) => row.includes("Should I include tests?"))).toBe(
261+
true,
262+
);
263+
expect(shell.layout.heights.input_required).toBe(1);
264+
clearOperatorInputRequired(shell, "two");
265+
const restored = await frameRows(h);
266+
expect(shell.layout.heights.input_required).toBe(0);
267+
expect(restored.some((row) => row.includes("INPUT REQUIRED"))).toBe(
268+
false,
269+
);
270+
});
271+
});
272+
273+
test("WORKER WAITING and INPUT REQUIRED sit together with correct order", async () => {
274+
await withShell(async (shell, h) => {
275+
setWorkerWaitAsks(shell, [WAITING_WORKER]);
276+
setOperatorInputRequiredGate(
277+
shell,
278+
gate("one", "Which branch should I use?"),
279+
);
280+
const rows = await frameRows(h);
281+
const worker = rows.findIndex((row) => row.includes("WORKER WAITING"));
282+
const input = rows.findIndex((row) => row.includes("INPUT REQUIRED"));
283+
expect(worker).toBeGreaterThanOrEqual(0);
284+
expect(input).toBeGreaterThan(worker);
285+
expect(rows.filter((row) => row.includes("WORKER WAITING"))).toHaveLength(
286+
1,
287+
);
288+
expect(rows.filter((row) => row.includes("INPUT REQUIRED"))).toHaveLength(
289+
1,
290+
);
291+
const w = shell.layout.regions.worker_wait;
292+
const i = shell.layout.regions.input_required;
293+
const p = shell.layout.regions.prompt;
294+
expect(w !== undefined && i !== undefined && p !== undefined).toBe(true);
295+
if (w === undefined || i === undefined || p === undefined) return;
296+
expect(w.y).toBeLessThan(i.y);
297+
expect(i.y + i.height).toBe(p.y);
298+
});
299+
});
300+
301+
test("MCP-trust and permission gates never raise an INPUT REQUIRED row", async () => {
302+
await withShell(async (shell, h) => {
303+
const emitter = new EventEmitter();
304+
const disposeGates = wirePrimaryHost(shell, emitter);
305+
try {
306+
emitter.emit("permission.gate", {
307+
id: "perm-1",
308+
request: {
309+
tool: "run_shell",
310+
action: "Run shell command",
311+
subject: "bun test",
312+
scopes: [],
313+
},
314+
resolve: () => undefined,
315+
});
316+
emitter.emit("operator.gate", {
317+
id: "mcp-trust",
318+
question: "Trust this local MCP server?",
319+
options: ["Trust and connect", "Cancel"],
320+
resolve: () => undefined,
321+
});
322+
const rows = await frameRows(h);
323+
expect(shell.overlayKind).toBe("permissions");
324+
expect(shell.layout.heights.input_required).toBe(0);
325+
expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(false);
326+
} finally {
327+
disposeGates();
328+
}
329+
});
330+
});
331+
332+
test("worker ask_director waiting and OAuth-style notices never raise an INPUT REQUIRED row", async () => {
333+
await withShell(async (shell, h) => {
334+
setWorkerWaitAsks(shell, [WAITING_WORKER]);
335+
setStatusFlash(shell, "OAuth token refresh needed");
336+
const rows = await frameRows(h);
337+
expect(rows.some((row) => row.includes("WORKER WAITING"))).toBe(true);
338+
expect(shell.layout.heights.input_required).toBe(0);
339+
expect(rows.some((row) => row.includes("INPUT REQUIRED"))).toBe(false);
340+
});
341+
});
151342
});

‎src/tui/operator-input-required.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ describe("operator input required view model", () => {
6969
).toBe(NO_OPERATOR_INPUT_REQUIRED);
7070
});
7171

72+
test("fails closed for an unrecognized marker string and an MCP-trust-shaped event", () => {
73+
const wrongMarker = {
74+
...gate("one"),
75+
source: "mcp-trust",
76+
} as unknown as OperatorGateEvent;
77+
expect(
78+
addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, wrongMarker),
79+
).toBe(NO_OPERATOR_INPUT_REQUIRED);
80+
const mcpTrust = {
81+
id: "mcp-trust",
82+
question: "Trust this local MCP server?",
83+
options: ["Trust and connect", "Cancel"],
84+
resolve: () => undefined,
85+
} as unknown as OperatorGateEvent;
86+
expect(addOperatorInputRequired(NO_OPERATOR_INPUT_REQUIRED, mcpTrust)).toBe(
87+
NO_OPERATOR_INPUT_REQUIRED,
88+
);
89+
});
90+
7291
test("keeps insertion selection and removes exactly the settled id", () => {
7392
const first = addOperatorInputRequired(
7493
NO_OPERATOR_INPUT_REQUIRED,

‎src/tui/product-host.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
runOverlayAction,
1515
} from "./shell/overlay-list.js";
1616
import { handleListFilterKey } from "./shell/palette.js";
17+
import { PRIMARY_ASK_OPERATOR_SOURCE } from "./gate-events.js";
18+
import { NO_OPERATOR_INPUT_REQUIRED } from "./operator-input-required.js";
1719
import {
1820
decideRemoveKey,
1921
mountProductHost,
@@ -608,6 +610,39 @@ describe("mountProductHost", () => {
608610
destroyHarness();
609611
}
610612
});
613+
614+
test("host teardown drains a marked outstanding operator gate and clears its strip", async () => {
615+
const { host, emitter, destroyHarness } = await mountHeadless();
616+
try {
617+
let resolved: unknown = "unset";
618+
emitter.emit("operator.gate", {
619+
id: "ask-teardown",
620+
source: PRIMARY_ASK_OPERATOR_SOURCE,
621+
question: "Continue?",
622+
options: ["Yes", "No"],
623+
resolve: (result: unknown) => {
624+
resolved = result;
625+
},
626+
});
627+
expect(
628+
host.shell.operatorInputRequired.items.map((item) => item.id),
629+
).toEqual(["ask-teardown"]);
630+
expect(host.shell.layout.heights.input_required).toBe(1);
631+
expect(emitter.listenerCount("operator.gate")).toBe(1);
632+
expect(emitter.listenerCount("permission.gate")).toBe(1);
633+
634+
host.dispose();
635+
636+
// The gate is drained by the teardown sweep, never left hanging.
637+
expect(resolved).toEqual({ kind: "cancel" });
638+
expect(host.shell.operatorInputRequired).toBe(NO_OPERATOR_INPUT_REQUIRED);
639+
expect(host.shell.layout.heights.input_required).toBe(0);
640+
expect(emitter.listenerCount("operator.gate")).toBe(0);
641+
expect(emitter.listenerCount("permission.gate")).toBe(0);
642+
} finally {
643+
destroyHarness();
644+
}
645+
});
611646
});
612647

613648
describe("flat type-to-filter model picker", () => {

0 commit comments

Comments
 (0)