Skip to content

Commit 1da2213

Browse files
committed
test(tui): cover a lost decision for a queue-drained turn
1 parent 8d6d4da commit 1da2213

1 file changed

Lines changed: 78 additions & 6 deletions

File tree

‎src/tui/runner/queued-approval-delivery.test.ts‎

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,24 @@ import {
3030
} from "../../session/approval-resume.js";
3131
import type { PermissionGate } from "../../permission/gate.js";
3232
import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js";
33+
import { withTestRenderer } from "../harness.js";
34+
import { createAppShell } from "../shell/index.js";
35+
import { shellInternals, type AppShell } from "../shell/internals.js";
3336
import { createDeliverRouting, createSubmitPath } from "./submit.js";
3437
import type { RunnerServices, RunnerState } from "./state.js";
3538

3639
const CORRELATION = "00000000-0000-4000-8000-0000000000a1";
3740
const PARKED_CALL = "call_parked_approve_worker";
3841

39-
function harness() {
42+
interface HarnessOptions {
43+
// The operator's answer; defaults to allow-once.
44+
resolveSuspended?: () => Promise<{ allow: boolean }>;
45+
// Whether the stub reactor accepts a delivered decision.
46+
acceptsDecisions?: boolean;
47+
shell?: AppShell;
48+
}
49+
50+
function harness(opts: HarnessOptions = {}) {
4051
const delivered: InboundMessage[] = [];
4152
const waiters = new Map<string, () => void>();
4253
const acceptance = {
@@ -70,17 +81,17 @@ function harness() {
7081
deliver: (message: InboundMessage) => {
7182
delivered.push(message);
7283
const id = message.headers.interchangeCorrelationId;
73-
if (id !== undefined) acceptance.settle(id);
84+
if (id !== undefined && opts.acceptsDecisions !== false)
85+
acceptance.settle(id);
7486
},
7587
history: async () => [],
7688
} as unknown as Agent;
7789

7890
let gateAsked = 0;
79-
// Operator answers allow-once immediately.
8091
const gate = {
8192
resolveSuspended: async () => {
8293
gateAsked++;
83-
return { allow: true };
94+
return opts.resolveSuspended?.() ?? { allow: true };
8495
},
8596
} as unknown as PermissionGate;
8697

@@ -94,6 +105,7 @@ function harness() {
94105
fatalBuildError: null,
95106
inFlight: 0,
96107
sendAborted: false,
108+
liveSource: { credentialId: undefined },
97109
approvalPersistNotice: {},
98110
credentialRecovery: {
99111
begin: () => ({ failedProvider: "p" }),
@@ -107,14 +119,14 @@ function harness() {
107119
setSuspendedApprovalRecovery: () => undefined,
108120
parentCycleLive: false,
109121
},
110-
shell: {},
122+
shell: opts.shell ?? {},
111123
},
112124
} as unknown as RunnerState;
113125

114126
const approvalDeliverer = createApprovalDeliverer({
115127
deliverToAgent: (message) => agent.deliver(message),
116128
acceptance,
117-
timeoutMs: 1_000,
129+
timeoutMs: 50,
118130
});
119131
const approvalResume = createApprovalResume({
120132
getAgent: () => agent,
@@ -208,3 +220,63 @@ test("wake-drained turn: allow-once decision reaches the reactor", async () => {
208220
// And the queue must not be wedged: a later operator send awaits the tail.
209221
expect(await settleWithin(h.sessionOps.awaitTail(), 200)).toBe("settled");
210222
});
223+
224+
// A fresh shell is on the landing, which defers system rows until it closes.
225+
const systemRows = (shell: AppShell): string[] =>
226+
[
227+
...(shellInternals(shell)?.landingDeferredRows ?? []),
228+
...shell.streamLog,
229+
].flatMap((row) => (row.role === "system" ? [row.text] : []));
230+
231+
const drainWake = (h: ReturnType<typeof harness>): (() => unknown) => {
232+
let settled: unknown;
233+
h.route(
234+
`${ASK_DIRECTOR_WAKE_PREFIX} — worker qa-lead parked question ask-1`,
235+
"queue",
236+
[],
237+
(result) => {
238+
settled = result;
239+
},
240+
false,
241+
);
242+
return () => settled;
243+
};
244+
245+
// A decision that fails after the drained send returned cannot change the
246+
// drain's result: the wake already reached the agent, and restoring it would
247+
// resend it. It must be surfaced the way a failed send is instead.
248+
for (const [name, opts] of [
249+
[
250+
"the approval surface throws",
251+
{
252+
resolveSuspended: () =>
253+
Promise.reject(new Error("approval overlay failed")),
254+
},
255+
],
256+
["the reactor never accepts the decision", { acceptsDecisions: false }],
257+
] as const) {
258+
test(`wake-drained turn: surfaces a lost decision when ${name}`, async () => {
259+
await withTestRenderer(async (r) => {
260+
const shell = createAppShell(r.renderer, {
261+
title: "test",
262+
cwd: "/tmp",
263+
terminal: { columns: 80, rows: 24 },
264+
wireKeys: false,
265+
});
266+
try {
267+
const h = harness({ ...opts, shell });
268+
const settled = drainWake(h);
269+
await new Promise((resolve) => setTimeout(resolve, 200));
270+
expect(h.gateAsked()).toBe(1);
271+
expect(settled()).toEqual({ status: "accepted" });
272+
expect(h.state.runError).toBeDefined();
273+
expect(systemRows(shell).length).toBe(1);
274+
expect(await settleWithin(h.sessionOps.awaitTail(), 200)).toBe(
275+
"settled",
276+
);
277+
} finally {
278+
shell.dispose();
279+
}
280+
});
281+
});
282+
}

0 commit comments

Comments
 (0)