返回 DeepSeek-Reasonix
subagent-progress.test.ts
根目录 / desktop / frontend / src / __tests__ / subagent-progress.test.ts
1 // Run: tsx src/__tests__/subagent-progress.test.ts
2 //
3 // Verifies the reserved ToolProgress channels (reasonix.subagent.*) update
4 // only the target card's in-memory subagentProgress: never tool.output, never
5 // the parent LiveStream, never history data. Also locks the background keep-
6 // running rule, the group-card settle rule, preview caps, and the isolation
7 // of concurrent children.
8
9 import { historyMessagesToItems, initialState, reducer, SUBAGENT_PROGRESS_STATUS, SUBAGENT_PROGRESS_REASONING, SUBAGENT_PROGRESS_TEXT, SUBAGENT_PROGRESS_NOTICE } from "../lib/useController";
10 import type { HistoryMessage, WireTool } from "../lib/types";
11 import type { Item } from "../lib/useController";
12
13 type TestState = typeof initialState;
14 type ToolItem = Extract<Item, { kind: "tool" }>;
15
16 let passed = 0;
17 let failed = 0;
18
19 function eq<T>(a: T, b: T, label: string) {
20 if (a === b) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
25 failed += 1;
26 }
27 }
28
29 function ok(cond: boolean, label: string) {
30 if (cond) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}\n`);
35 failed += 1;
36 }
37 }
38
39 function toolItems(s: TestState): ToolItem[] {
40 return s.items.filter((it): it is ToolItem => it.kind === "tool");
41 }
42
43 function toolById(s: TestState, id: string): ToolItem {
44 const it = toolItems(s).find((t) => t.id === id);
45 if (!it) throw new Error(`tool ${id} missing`);
46 return it;
47 }
48
49 function dispatch(s: TestState, tool: WireTool): TestState {
50 return reducer(s, { type: "event", e: { kind: "tool_dispatch", tool } });
51 }
52
53 function result(s: TestState, tool: WireTool): TestState {
54 return reducer(s, { type: "event", e: { kind: "tool_result", tool } });
55 }
56
57 function progress(s: TestState, tool: WireTool): TestState {
58 return reducer(s, { type: "event", e: { kind: "tool_progress", tool } });
59 }
60
61 function text(s: TestState, t: string): TestState {
62 return reducer(s, { type: "event", e: { kind: "text", text: t } });
63 }
64
65 function reasoning(s: TestState, t: string): TestState {
66 return reducer(s, { type: "event", e: { kind: "reasoning", reasoning: t } });
67 }
68
69 function progressTool(id: string, name: string, output: string, extra: Partial<WireTool> = {}): WireTool {
70 return { id, name, output, readOnly: false, ...extra };
71 }
72
73 console.log("\nsubagent progress reducer");
74
75 // --- 1. Reserved channels update only the target card's preview ------------
76
77 {
78 let s = initialState;
79 s = text(s, "parent answer part 1");
80 s = reasoning(s, "parent thinking");
81 s = dispatch(s, { id: "task-1", name: "task", args: "{}", readOnly: true });
82 const liveBefore = JSON.stringify(s.live);
83
84 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_STATUS, "running"));
85 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_REASONING, "child thinks"));
86 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_TEXT, "child answer preview"));
87 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_NOTICE, "heads up"));
88 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_STATUS, "completed", { durationMs: 1234 }));
89
90 const card = toolById(s, "task-1");
91 ok(card.subagentProgress?.phase === "completed", "terminal status phase applied");
92 eq(card.subagentProgress?.reasoning, "child thinks", "reasoning preview routed to subagentProgress");
93 eq(card.subagentProgress?.text, "child answer preview", "text preview routed to subagentProgress");
94 eq(card.subagentProgress?.notice, "heads up", "notice preview routed to subagentProgress");
95 eq(card.subagentProgress?.durationMs, 1234, "terminal duration captured");
96 eq(card.status, "done", "completed terminal maps to done status");
97 eq(card.output, undefined, "preview never writes tool.output");
98 eq(JSON.stringify(s.live), liveBefore, "parent LiveStream bytes unchanged by child previews");
99 eq(s.live?.text, "parent answer part 1", "parent live text unchanged by child previews");
100 eq(s.live?.reasoning, "parent thinking", "parent live reasoning unchanged by child previews");
101 }
102
103 // --- 2. Unknown status phases and unknown names are ignored ---------------
104
105 {
106 let s = initialState;
107 s = dispatch(s, { id: "task-1", name: "task", args: "{}", readOnly: true });
108 const before = JSON.stringify(toolById(s, "task-1"));
109 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_STATUS, "not-a-phase"));
110 s = progress(s, progressTool("task-1", "reasonix.subagent.bogus", "x"));
111 eq(JSON.stringify(toolById(s, "task-1")), before, "unknown phase and unknown channel ignored");
112 }
113
114 // --- 3. Concurrent children stay isolated per card ID ----------------------
115
116 {
117 let s = initialState;
118 s = dispatch(s, { id: "p-1", name: "parallel_tasks", args: "{}", readOnly: true });
119 s = dispatch(s, { id: "p-1/sub-1", name: "task", args: "{}", readOnly: true, parentId: "p-1" });
120 s = dispatch(s, { id: "p-1/sub-2", name: "task", args: "{}", readOnly: true, parentId: "p-1" });
121
122 s = progress(s, progressTool("p-1/sub-1", SUBAGENT_PROGRESS_REASONING, "AAAA"));
123 s = progress(s, progressTool("p-1/sub-2", SUBAGENT_PROGRESS_REASONING, "BBBB"));
124 s = progress(s, progressTool("p-1/sub-1", SUBAGENT_PROGRESS_TEXT, "1111"));
125 s = progress(s, progressTool("p-1/sub-2", SUBAGENT_PROGRESS_TEXT, "2222"));
126
127 eq(toolById(s, "p-1/sub-1").subagentProgress?.reasoning, "AAAA", "child 1 reasoning isolated");
128 eq(toolById(s, "p-1/sub-1").subagentProgress?.text, "1111", "child 1 text isolated");
129 eq(toolById(s, "p-1/sub-2").subagentProgress?.reasoning, "BBBB", "child 2 reasoning isolated");
130 eq(toolById(s, "p-1/sub-2").subagentProgress?.text, "2222", "child 2 text isolated");
131 ok(!toolById(s, "p-1/sub-1").output && !toolById(s, "p-1/sub-2").output, "no child preview leaks into outputs");
132 }
133
134 // --- 4. Background call keeps running until its terminal progress ----------
135
136 {
137 let s = initialState;
138 s = dispatch(s, { id: "bg-1", name: "task", args: "{}", readOnly: true });
139 s = result(s, { id: "bg-1", name: "task", readOnly: true, output: "Started background task \"bg\" (job-1)." });
140 eq(toolById(s, "bg-1").status, "running", "job id result keeps the card running");
141 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "queued"));
142 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "running"));
143 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "completed", { durationMs: 42 }));
144 eq(toolById(s, "bg-1").status, "done", "terminal progress settles the background card");
145 eq(toolById(s, "bg-1").subagentProgress?.phase, "completed", "background card phase completed");
146 eq(toolById(s, "bg-1").output, "Started background task \"bg\" (job-1).", "job id output retained");
147 }
148
149 // --- 5. Cancelled / failed terminals use stopped / error semantics ---------
150
151 {
152 let s = initialState;
153 s = dispatch(s, { id: "c-1", name: "task", args: "{}", readOnly: true });
154 s = progress(s, progressTool("c-1", SUBAGENT_PROGRESS_STATUS, "cancelled"));
155 s = result(s, { id: "c-1", name: "task", readOnly: true, err: "cancelled: context canceled" });
156 eq(toolById(s, "c-1").status, "stopped", "cancelled terminal wins over an error result");
157
158 s = dispatch(s, { id: "f-1", name: "task", args: "{}", readOnly: true });
159 s = progress(s, progressTool("f-1", SUBAGENT_PROGRESS_STATUS, "failed"));
160 s = result(s, { id: "f-1", name: "task", readOnly: true, err: "provider exploded" });
161 eq(toolById(s, "f-1").status, "error", "failed terminal maps to error status");
162 }
163
164 // --- 6. Group card settles only from its own lifecycle terminal ------------
165
166 {
167 let s = initialState;
168 s = dispatch(s, { id: "fl-1", name: "fleet", args: "{}", readOnly: true });
169 s = dispatch(s, { id: "fl-1/fleet-1", name: "task", args: "{}", readOnly: true, parentId: "fl-1" });
170 s = dispatch(s, { id: "fl-1/fleet-2", name: "task", args: "{}", readOnly: true, parentId: "fl-1" });
171
172 // Background fleet: the result (job id) arrives while children still run.
173 s = result(s, { id: "fl-1", name: "fleet", readOnly: true, output: "Started background fleet (job-2)." });
174 eq(toolById(s, "fl-1").status, "running", "fleet card stays running after the job-id result");
175
176 s = progress(s, progressTool("fl-1/fleet-1", SUBAGENT_PROGRESS_STATUS, "completed"));
177 s = progress(s, progressTool("fl-1/fleet-2", SUBAGENT_PROGRESS_STATUS, "completed"));
178 eq(toolById(s, "fl-1").status, "running", "all children terminal alone must not settle the fleet");
179
180 // The group settles from its own lifecycle terminal event.
181 s = progress(s, progressTool("fl-1", SUBAGENT_PROGRESS_STATUS, "completed", { durationMs: 9000 }));
182 eq(toolById(s, "fl-1").status, "done", "group completed terminal settles the fleet");
183 eq(toolById(s, "fl-1").subagentProgress?.phase, "completed", "settled fleet phase completed");
184 }
185
186 // --- 6b. Job-id first + fast child must not settle the group ----------------
187
188 {
189 let s = initialState;
190 s = dispatch(s, { id: "fl-0", name: "fleet", args: "{}", readOnly: true });
191 // Background order: job-id result, then child-1 dispatches and finishes
192 // while later children have not dispatched yet.
193 s = result(s, { id: "fl-0", name: "fleet", readOnly: true, output: "Started background fleet (job-3)." });
194 eq(toolById(s, "fl-0").status, "running", "fleet stays running after the job-id result");
195
196 s = dispatch(s, { id: "fl-0/fleet-1", name: "task", args: "{}", readOnly: true, parentId: "fl-0" });
197 s = progress(s, progressTool("fl-0/fleet-1", SUBAGENT_PROGRESS_STATUS, "completed"));
198 eq(toolById(s, "fl-0").status, "running", "a fast first child must not settle the fleet");
199
200 // A later child appears and runs while the group is still live.
201 s = dispatch(s, { id: "fl-0/fleet-2", name: "task", args: "{}", readOnly: true, parentId: "fl-0" });
202 s = progress(s, progressTool("fl-0/fleet-2", SUBAGENT_PROGRESS_STATUS, "reasoning"));
203 eq(toolById(s, "fl-0").status, "running", "fleet keeps running while a later child works");
204
205 s = progress(s, progressTool("fl-0/fleet-2", SUBAGENT_PROGRESS_STATUS, "completed"));
206 s = progress(s, progressTool("fl-0", SUBAGENT_PROGRESS_STATUS, "completed"));
207 eq(toolById(s, "fl-0").status, "done", "group terminal settles the fleet after all children");
208 }
209
210 // --- 6c. Zero-child cancellation and group failure --------------------------
211
212 {
213 // A background fleet cancelled before any child dispatched still receives
214 // its explicit cancelled terminal from the backend.
215 let s = initialState;
216 s = dispatch(s, { id: "zc-1", name: "fleet", args: "{}", readOnly: true });
217 s = progress(s, progressTool("zc-1", SUBAGENT_PROGRESS_STATUS, "cancelled"));
218 s = result(s, { id: "zc-1", name: "fleet", readOnly: true, err: "cancelled: context canceled" });
219 eq(toolById(s, "zc-1").status, "stopped", "zero-child cancelled fleet shows stopped");
220
221 // A group failed terminal maps to error regardless of children.
222 s = dispatch(s, { id: "gf-1", name: "parallel_tasks", args: "{}", readOnly: true });
223 s = dispatch(s, { id: "gf-1/sub-1", name: "task", args: "{}", readOnly: true, parentId: "gf-1" });
224 s = progress(s, progressTool("gf-1/sub-1", SUBAGENT_PROGRESS_STATUS, "failed"));
225 s = progress(s, progressTool("gf-1", SUBAGENT_PROGRESS_STATUS, "failed"));
226 s = result(s, { id: "gf-1", name: "parallel_tasks", readOnly: true, output: "Completed 1 parallel tasks..." });
227 eq(toolById(s, "gf-1").status, "error", "group failed terminal maps to error");
228 }
229
230 // --- 7. Preview caps keep recent tails -------------------------------------
231
232 {
233 let s = initialState;
234 s = dispatch(s, { id: "cap-1", name: "task", args: "{}", readOnly: true });
235 const big = "x".repeat(12_000);
236 const tail = "TAIL";
237 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_REASONING, big + tail));
238 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_TEXT, big + tail));
239 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_NOTICE, big + tail));
240
241 const sp = toolById(s, "cap-1").subagentProgress!;
242 eq(sp.reasoning.length, 8_192, "reasoning preview capped at 8 KiB");
243 ok(sp.reasoning.endsWith(tail), "reasoning keeps the recent tail");
244 eq(sp.text.length, 8_192, "text preview capped at 8 KiB");
245 eq(sp.notice.length, 2_048, "notice preview capped at 2 KiB");
246 eq(sp.truncated, false, "frontend cap alone does not mark truncated (backend sends the flag)");
247 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_TEXT, "more", { truncated: true }));
248 eq(toolById(s, "cap-1").subagentProgress?.truncated, true, "backend truncated flag honored");
249 }
250
251 // --- 8. Terminal keeps the preview; history hydration never restores it ----
252
253 {
254 let s = initialState;
255 s = dispatch(s, { id: "h-1", name: "task", args: "{}", readOnly: true });
256 s = progress(s, progressTool("h-1", SUBAGENT_PROGRESS_REASONING, "kept after terminal"));
257 s = progress(s, progressTool("h-1", SUBAGENT_PROGRESS_STATUS, "completed"));
258 eq(toolById(s, "h-1").subagentProgress?.reasoning, "kept after terminal", "preview retained after terminal");
259
260 const hydrated = historyMessagesToItems([
261 { role: "user", content: "go" },
262 { role: "assistant", content: "", toolCalls: [{ id: "h-1", name: "task", arguments: "{}" }] },
263 { role: "tool", toolCallId: "h-1", toolName: "task", content: "done" },
264 ] as HistoryMessage[], "h").items.filter((it) => it.kind === "tool");
265 ok(hydrated.every((it) => !it.subagentProgress), "history hydration never restores transient progress");
266 }
267
268 // --- 9. Nested real tool activity touches the parent card ------------------
269
270 {
271 let s = initialState;
272 s = dispatch(s, { id: "t-1", name: "task", args: "{}", readOnly: true });
273 const before = toolById(s, "t-1").subagentProgress!.lastActivityAt;
274 s = dispatch(s, { id: "t-1/bash_1", name: "bash", args: "ls", readOnly: false, parentId: "t-1" });
275 const after = toolById(s, "t-1").subagentProgress!;
276 eq(after.phase, "tool", "nested dispatch flips parent phase to tool");
277 ok(after.lastActivityAt >= before, "nested dispatch refreshes parent recent activity");
278 }
279
280 // --- 10. Ordinary tool progress behavior is unchanged ----------------------
281
282 {
283 let s = initialState;
284 s = dispatch(s, { id: "b-1", name: "bash", args: "ls", readOnly: false });
285 s = progress(s, progressTool("b-1", "bash", "file1\n"));
286 s = progress(s, progressTool("b-1", "bash", "file2\n"));
287 eq(toolById(s, "b-1").output, "file1\nfile2\n", "ordinary progress still appends to tool.output");
288 eq(toolById(s, "b-1").subagentProgress, undefined, "ordinary tools never gain a progress preview");
289
290 // Archiving on result still applies to sub-agent cards without dropping the
291 // preview (the tracker always emits its terminal before the result).
292 s = dispatch(s, { id: "arc-1", name: "task", args: "{}", readOnly: true });
293 s = progress(s, progressTool("arc-1", SUBAGENT_PROGRESS_STATUS, "completed"));
294 s = result(s, { id: "arc-1", name: "task", readOnly: true, output: "done" });
295 const archived = toolById(s, "arc-1");
296 eq(archived.status, "done", "archived sub-agent card settles");
297 ok(archived.subagentProgress !== undefined, "subagentProgress survives result archiving");
298 }
299
300 console.log(`\nsubagent progress: ${passed} passed, ${failed} failed`);
301 if (failed > 0) process.exit(1);
302
302 lines TYPESCRIPT