|
| 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