Skip to content

Commit 03e09c0

Browse files
committed
feat(tui): add a view model for parked worker questions
1 parent a86b77c commit 03e09c0

2 files changed

Lines changed: 526 additions & 0 deletions

File tree

‎src/tui/worker-wait.test.ts‎

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import type { PendingAskWake } from "../subagent/fleet-report.js";
4+
import { stringWidth } from "./view/height.js";
5+
import {
6+
composeWorkerWaitLine,
7+
NO_WORKER_WAIT,
8+
reduceWorkerWait,
9+
selectedWorkerWait,
10+
workerWaitMoreCount,
11+
type WorkerWaitPart,
12+
type WorkerWaitState,
13+
} from "./worker-wait.js";
14+
15+
function ask(
16+
sessionId: string,
17+
questionId: string,
18+
question = `question ${questionId}`,
19+
): PendingAskWake {
20+
return {
21+
sessionId,
22+
agentId: "builder",
23+
description: `work ${sessionId}`,
24+
question,
25+
questionId,
26+
};
27+
}
28+
29+
function fold(...snapshots: (readonly PendingAskWake[])[]): WorkerWaitState {
30+
return snapshots.reduce<WorkerWaitState>(reduceWorkerWait, NO_WORKER_WAIT);
31+
}
32+
33+
function text(parts: readonly WorkerWaitPart[]): string {
34+
return parts.map((part) => part.text).join("");
35+
}
36+
37+
function selectedIdentity(state: WorkerWaitState): readonly string[] | null {
38+
const item = selectedWorkerWait(state);
39+
return item === null ? null : [item.sessionId, item.questionId];
40+
}
41+
42+
describe("worker wait view model", () => {
43+
test("an empty snapshot keeps the strip inactive", () => {
44+
const state = fold([]);
45+
expect(state).toBe(NO_WORKER_WAIT);
46+
expect(selectedWorkerWait(state)).toBeNull();
47+
expect(composeWorkerWaitLine(state, 80)).toEqual([]);
48+
});
49+
50+
test("one pending ask activates one selected item", () => {
51+
const state = fold([ask("s1", "q1")]);
52+
expect(state.items).toHaveLength(1);
53+
expect(selectedIdentity(state)).toEqual(["s1", "q1"]);
54+
expect(workerWaitMoreCount(state)).toBe(0);
55+
});
56+
57+
test("repeated reports of one identity produce one item and keep the same state", () => {
58+
const first = fold([ask("s1", "q1")]);
59+
const again = reduceWorkerWait(first, [ask("s1", "q1")]);
60+
expect(again).toBe(first);
61+
const doubled = fold([ask("s1", "q1"), ask("s1", "q1")]);
62+
expect(doubled.items).toHaveLength(1);
63+
});
64+
65+
test("a new question for the same session replaces the old item atomically", () => {
66+
const before = fold([ask("s1", "q1", "Old preview?")]);
67+
const after = reduceWorkerWait(before, [ask("s1", "q2", "New preview?")]);
68+
expect(after.items).toHaveLength(1);
69+
expect(selectedIdentity(after)).toEqual(["s1", "q2"]);
70+
expect(text(composeWorkerWaitLine(after, 120))).toContain("New preview?");
71+
expect(text(composeWorkerWaitLine(after, 120))).not.toContain(
72+
"Old preview?",
73+
);
74+
});
75+
76+
test("a replaced question does not inherit the selection", () => {
77+
const before = fold([ask("s1", "q1"), ask("s2", "q2")]);
78+
expect(selectedIdentity(before)).toEqual(["s1", "q1"]);
79+
// s1's new question moves behind s2 in source order; the old selected
80+
// identity is gone, so selection falls back to the first live item.
81+
const after = reduceWorkerWait(before, [ask("s2", "q2"), ask("s1", "q9")]);
82+
expect(selectedIdentity(after)).toEqual(["s2", "q2"]);
83+
});
84+
85+
test("multiple wakes keep the displayed identity while it stays live", () => {
86+
const before = fold([ask("s1", "q1")]);
87+
const after = reduceWorkerWait(before, [
88+
ask("s0", "q0"),
89+
ask("s1", "q1"),
90+
ask("s2", "q2"),
91+
]);
92+
expect(selectedIdentity(after)).toEqual(["s1", "q1"]);
93+
expect(workerWaitMoreCount(after)).toBe(2);
94+
expect(after.items.map((item) => item.sessionId)).toEqual([
95+
"s0",
96+
"s1",
97+
"s2",
98+
]);
99+
});
100+
101+
test("selection falls back to snapshot order when the displayed identity leaves", () => {
102+
const before = fold([ask("s1", "q1"), ask("s2", "q2"), ask("s3", "q3")]);
103+
const after = reduceWorkerWait(before, [ask("s3", "q3"), ask("s2", "q2")]);
104+
expect(selectedIdentity(after)).toEqual(["s3", "q3"]);
105+
expect(workerWaitMoreCount(after)).toBe(1);
106+
});
107+
108+
test("the additional count tracks the live snapshot", () => {
109+
let state = fold([ask("s1", "q1"), ask("s2", "q2"), ask("s3", "q3")]);
110+
expect(text(composeWorkerWaitLine(state, 160))).toContain("(+2 more)");
111+
state = reduceWorkerWait(state, [ask("s1", "q1"), ask("s3", "q3")]);
112+
expect(text(composeWorkerWaitLine(state, 160))).toContain("(+1 more)");
113+
state = reduceWorkerWait(state, [ask("s1", "q1")]);
114+
expect(text(composeWorkerWaitLine(state, 160))).not.toContain("more");
115+
});
116+
117+
test("a re-surfaced wake restating the same identity never duplicates the item", () => {
118+
const state = fold([ask("s1", "q1")]);
119+
// The stall-abort re-surface restates the question to the director; the
120+
// fleet snapshot it rides on still names the same identity.
121+
const resurfaced = reduceWorkerWait(state, [ask("s1", "q1")]);
122+
expect(resurfaced).toBe(state);
123+
expect(resurfaced.items).toHaveLength(1);
124+
});
125+
126+
test("an item leaves only when the snapshot omits its identity", () => {
127+
let state = fold([ask("s1", "q1"), ask("s2", "q2")]);
128+
state = reduceWorkerWait(state, [ask("s2", "q2")]);
129+
expect(state.items.map((item) => item.sessionId)).toEqual(["s2"]);
130+
state = reduceWorkerWait(state, []);
131+
expect(state.items).toEqual([]);
132+
expect(state.selectedKey).toBeNull();
133+
expect(composeWorkerWaitLine(state, 80)).toEqual([]);
134+
});
135+
136+
test("identity halves cannot alias across sessions", () => {
137+
const state = fold([ask("a", "b:c"), ask("a:b", "c")]);
138+
expect(state.items).toHaveLength(2);
139+
expect(new Set(state.items.map((item) => item.key)).size).toBe(2);
140+
});
141+
142+
test("teardown state renders nothing", () => {
143+
const live = fold([ask("s1", "q1")]);
144+
expect(composeWorkerWaitLine(live, 80)).not.toEqual([]);
145+
expect(composeWorkerWaitLine(NO_WORKER_WAIT, 80)).toEqual([]);
146+
});
147+
});
148+
149+
describe("worker wait strip copy", () => {
150+
const destination = fold([
151+
{
152+
sessionId: "sess-1",
153+
agentId: "builder",
154+
description: "copy assets",
155+
question: "Which destination path should I use?",
156+
questionId: "q1",
157+
},
158+
]);
159+
160+
test("full width names the waiting worker, its question, and director ownership", () => {
161+
const line = text(composeWorkerWaitLine(destination, 120));
162+
expect(line).toContain("WORKER WAITING");
163+
expect(line).toContain("director reply needed");
164+
expect(line).toContain("send_input");
165+
expect(line).toContain("builder (copy assets)");
166+
expect(line).toContain("Which destination path should I use?");
167+
});
168+
169+
test("the waiting label and routing copy carry the meaning without color", () => {
170+
const parts = composeWorkerWaitLine(destination, 120);
171+
const roles = new Map(parts.map((part) => [part.role, part.text]));
172+
expect(roles.get("label")).toBe("WORKER WAITING");
173+
expect(roles.get("routing")).toContain("director");
174+
});
175+
176+
test("every width fits the strip inside its row", () => {
177+
const many = fold([
178+
ask("s1", "q1", "A long question ".repeat(12)),
179+
ask("s2", "q2"),
180+
ask("s3", "q3"),
181+
]);
182+
for (let width = 1; width <= 160; width++) {
183+
const parts = composeWorkerWaitLine(many, width);
184+
expect(stringWidth(text(parts))).toBeLessThanOrEqual(width);
185+
expect(parts.length).toBeGreaterThan(0);
186+
}
187+
});
188+
189+
test("narrowing truncates the question before requester metadata", () => {
190+
const question = "Which destination path should I use for the bundle?";
191+
const long = fold([ask("s1", "q1", question), ask("s2", "q2")]);
192+
const partAt = (width: number, role: WorkerWaitPart["role"]) =>
193+
composeWorkerWaitLine(long, width).find((part) => part.role === role)
194+
?.text;
195+
expect(partAt(200, "question")).toBe(question);
196+
expect(partAt(200, "requester")).toBe("builder (work s1)");
197+
// A standard 80-column terminal still previews the question.
198+
expect(partAt(78, "question")).toEndWith("…");
199+
expect(text(composeWorkerWaitLine(long, 78))).toContain("(+1 more)");
200+
for (let width = 200; width >= 1; width--) {
201+
const preview = partAt(width, "question");
202+
const requester = partAt(width, "requester");
203+
// Metadata only shrinks once the question is already cut short.
204+
if (requester !== "builder (work s1)") {
205+
expect(preview === undefined || preview.endsWith("…")).toBe(true);
206+
}
207+
// The question only drops once the description is already gone.
208+
if (preview === undefined) {
209+
expect(requester === undefined || requester === "builder").toBe(true);
210+
}
211+
}
212+
});
213+
214+
test("narrow widths keep a compact explicit label and the additional count", () => {
215+
const two = fold([ask("s1", "q1"), ask("s2", "q2")]);
216+
const narrow = text(composeWorkerWaitLine(two, 30));
217+
expect(narrow).toContain("WAITING");
218+
expect(narrow).toContain("(+1 more)");
219+
const tiny = text(composeWorkerWaitLine(two, 20));
220+
expect(tiny).toContain("WAITING");
221+
expect(tiny).toContain("(+1 more)");
222+
});
223+
224+
test("worker text cannot inject terminal sequences or extra rows", () => {
225+
const hostile = fold([
226+
{
227+
sessionId: "s1",
228+
agentId: "builder\u001b[2J",
229+
description: "desc\nnext",
230+
question:
231+
"Pick\u001b[31m red\u001b[0m\r\nor \u001b]52;c;Zm9v\u0007blue?",
232+
questionId: "q1",
233+
},
234+
]);
235+
const line = text(composeWorkerWaitLine(hostile, 200));
236+
expect(line).not.toContain("\u001b");
237+
expect(line).not.toContain("\n");
238+
expect(line).not.toContain("\r");
239+
expect(line).toContain("Pick red or blue?");
240+
expect(line).toContain("builder (desc next)");
241+
});
242+
});

0 commit comments

Comments
 (0)