返回 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 settled parent assistant segment, 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 parentBefore = JSON.stringify(s.items.find((item) => item.kind === "assistant"));
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 const parent = s.items.find((item) => item.kind === "assistant");
99 eq(JSON.stringify(parent), parentBefore, "settled parent assistant bytes unchanged by child previews");
100 eq(parent?.kind === "assistant" ? parent.text : undefined, "parent answer part 1", "parent text unchanged by child previews");
101 eq(parent?.kind === "assistant" ? parent.reasoning : undefined, "parent thinking", "parent reasoning unchanged by child previews");
102 }
103
104 // --- 2. Unknown status phases and unknown names are ignored ---------------
105
106 {
107 let s = initialState;
108 s = dispatch(s, { id: "task-1", name: "task", args: "{}", readOnly: true });
109 const before = JSON.stringify(toolById(s, "task-1"));
110 s = progress(s, progressTool("task-1", SUBAGENT_PROGRESS_STATUS, "not-a-phase"));
111 s = progress(s, progressTool("task-1", "reasonix.subagent.bogus", "x"));
112 eq(JSON.stringify(toolById(s, "task-1")), before, "unknown phase and unknown channel ignored");
113 }
114
115 // --- 3. Concurrent children stay isolated per card ID ----------------------
116
117 {
118 let s = initialState;
119 s = dispatch(s, { id: "p-1", name: "parallel_tasks", args: "{}", readOnly: true });
120 s = dispatch(s, { id: "p-1/sub-1", name: "task", args: "{}", readOnly: true, parentId: "p-1" });
121 s = dispatch(s, { id: "p-1/sub-2", name: "task", args: "{}", readOnly: true, parentId: "p-1" });
122
123 s = progress(s, progressTool("p-1/sub-1", SUBAGENT_PROGRESS_REASONING, "AAAA"));
124 s = progress(s, progressTool("p-1/sub-2", SUBAGENT_PROGRESS_REASONING, "BBBB"));
125 s = progress(s, progressTool("p-1/sub-1", SUBAGENT_PROGRESS_TEXT, "1111"));
126 s = progress(s, progressTool("p-1/sub-2", SUBAGENT_PROGRESS_TEXT, "2222"));
127
128 eq(toolById(s, "p-1/sub-1").subagentProgress?.reasoning, "AAAA", "child 1 reasoning isolated");
129 eq(toolById(s, "p-1/sub-1").subagentProgress?.text, "1111", "child 1 text isolated");
130 eq(toolById(s, "p-1/sub-2").subagentProgress?.reasoning, "BBBB", "child 2 reasoning isolated");
131 eq(toolById(s, "p-1/sub-2").subagentProgress?.text, "2222", "child 2 text isolated");
132 ok(!toolById(s, "p-1/sub-1").output && !toolById(s, "p-1/sub-2").output, "no child preview leaks into outputs");
133 }
134
135 // --- 4. Background call keeps running until its terminal progress ----------
136
137 {
138 let s = initialState;
139 s = dispatch(s, { id: "bg-1", name: "task", args: "{}", readOnly: true });
140 s = result(s, { id: "bg-1", name: "task", readOnly: true, output: "Started background task \"bg\" (job-1)." });
141 eq(toolById(s, "bg-1").status, "running", "job id result keeps the card running");
142 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "queued"));
143 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "running"));
144 s = progress(s, progressTool("bg-1", SUBAGENT_PROGRESS_STATUS, "completed", { durationMs: 42 }));
145 eq(toolById(s, "bg-1").status, "done", "terminal progress settles the background card");
146 eq(toolById(s, "bg-1").subagentProgress?.phase, "completed", "background card phase completed");
147 eq(toolById(s, "bg-1").output, "Started background task \"bg\" (job-1).", "job id output retained");
148 }
149
150 // --- 5. Cancelled / failed terminals use stopped / error semantics ---------
151
152 {
153 let s = initialState;
154 s = dispatch(s, { id: "c-1", name: "task", args: "{}", readOnly: true });
155 s = progress(s, progressTool("c-1", SUBAGENT_PROGRESS_STATUS, "cancelled"));
156 s = result(s, { id: "c-1", name: "task", readOnly: true, err: "cancelled: context canceled" });
157 eq(toolById(s, "c-1").status, "stopped", "cancelled terminal wins over an error result");
158
159 s = dispatch(s, { id: "f-1", name: "task", args: "{}", readOnly: true });
160 s = progress(s, progressTool("f-1", SUBAGENT_PROGRESS_STATUS, "failed"));
161 s = result(s, { id: "f-1", name: "task", readOnly: true, err: "provider exploded" });
162 eq(toolById(s, "f-1").status, "error", "failed terminal maps to error status");
163 }
164
165 // --- 6. Group card settles only from its own lifecycle terminal ------------
166
167 {
168 let s = initialState;
169 s = dispatch(s, { id: "fl-1", name: "fleet", args: "{}", readOnly: true });
170 s = dispatch(s, { id: "fl-1/fleet-1", name: "task", args: "{}", readOnly: true, parentId: "fl-1" });
171 s = dispatch(s, { id: "fl-1/fleet-2", name: "task", args: "{}", readOnly: true, parentId: "fl-1" });
172
173 // Background fleet: the result (job id) arrives while children still run.
174 s = result(s, { id: "fl-1", name: "fleet", readOnly: true, output: "Started background fleet (job-2)." });
175 eq(toolById(s, "fl-1").status, "running", "fleet card stays running after the job-id result");
176
177 s = progress(s, progressTool("fl-1/fleet-1", SUBAGENT_PROGRESS_STATUS, "completed"));
178 s = progress(s, progressTool("fl-1/fleet-2", SUBAGENT_PROGRESS_STATUS, "completed"));
179 eq(toolById(s, "fl-1").status, "running", "all children terminal alone must not settle the fleet");
180
181 // The group settles from its own lifecycle terminal event.
182 s = progress(s, progressTool("fl-1", SUBAGENT_PROGRESS_STATUS, "completed", { durationMs: 9000 }));
183 eq(toolById(s, "fl-1").status, "done", "group completed terminal settles the fleet");
184 eq(toolById(s, "fl-1").subagentProgress?.phase, "completed", "settled fleet phase completed");
185 }
186
187 // --- 6b. Job-id first + fast child must not settle the group ----------------
188
189 {
190 let s = initialState;
191 s = dispatch(s, { id: "fl-0", name: "fleet", args: "{}", readOnly: true });
192 // Background order: job-id result, then child-1 dispatches and finishes
193 // while later children have not dispatched yet.
194 s = result(s, { id: "fl-0", name: "fleet", readOnly: true, output: "Started background fleet (job-3)." });
195 eq(toolById(s, "fl-0").status, "running", "fleet stays running after the job-id result");
196
197 s = dispatch(s, { id: "fl-0/fleet-1", name: "task", args: "{}", readOnly: true, parentId: "fl-0" });
198 s = progress(s, progressTool("fl-0/fleet-1", SUBAGENT_PROGRESS_STATUS, "completed"));
199 eq(toolById(s, "fl-0").status, "running", "a fast first child must not settle the fleet");
200
201 // A later child appears and runs while the group is still live.
202 s = dispatch(s, { id: "fl-0/fleet-2", name: "task", args: "{}", readOnly: true, parentId: "fl-0" });
203 s = progress(s, progressTool("fl-0/fleet-2", SUBAGENT_PROGRESS_STATUS, "reasoning"));
204 eq(toolById(s, "fl-0").status, "running", "fleet keeps running while a later child works");
205
206 s = progress(s, progressTool("fl-0/fleet-2", SUBAGENT_PROGRESS_STATUS, "completed"));
207 s = progress(s, progressTool("fl-0", SUBAGENT_PROGRESS_STATUS, "completed"));
208 eq(toolById(s, "fl-0").status, "done", "group terminal settles the fleet after all children");
209 }
210
211 // --- 6c. Zero-child cancellation and group failure --------------------------
212
213 {
214 // A background fleet cancelled before any child dispatched still receives
215 // its explicit cancelled terminal from the backend.
216 let s = initialState;
217 s = dispatch(s, { id: "zc-1", name: "fleet", args: "{}", readOnly: true });
218 s = progress(s, progressTool("zc-1", SUBAGENT_PROGRESS_STATUS, "cancelled"));
219 s = result(s, { id: "zc-1", name: "fleet", readOnly: true, err: "cancelled: context canceled" });
220 eq(toolById(s, "zc-1").status, "stopped", "zero-child cancelled fleet shows stopped");
221
222 // A group failed terminal maps to error regardless of children.
223 s = dispatch(s, { id: "gf-1", name: "parallel_tasks", args: "{}", readOnly: true });
224 s = dispatch(s, { id: "gf-1/sub-1", name: "task", args: "{}", readOnly: true, parentId: "gf-1" });
225 s = progress(s, progressTool("gf-1/sub-1", SUBAGENT_PROGRESS_STATUS, "failed"));
226 s = progress(s, progressTool("gf-1", SUBAGENT_PROGRESS_STATUS, "failed"));
227 s = result(s, { id: "gf-1", name: "parallel_tasks", readOnly: true, output: "Completed 1 parallel tasks..." });
228 eq(toolById(s, "gf-1").status, "error", "group failed terminal maps to error");
229 }
230
231 // --- 7. Preview caps keep recent tails -------------------------------------
232
233 {
234 let s = initialState;
235 s = dispatch(s, { id: "cap-1", name: "task", args: "{}", readOnly: true });
236 const big = "x".repeat(12_000);
237 const tail = "TAIL";
238 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_REASONING, big + tail));
239 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_TEXT, big + tail));
240 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_NOTICE, big + tail));
241
242 const sp = toolById(s, "cap-1").subagentProgress!;
243 eq(sp.reasoning.length, 8_192, "reasoning preview capped at 8 KiB");
244 ok(sp.reasoning.endsWith(tail), "reasoning keeps the recent tail");
245 eq(sp.text.length, 8_192, "text preview capped at 8 KiB");
246 eq(sp.notice.length, 2_048, "notice preview capped at 2 KiB");
247 eq(sp.truncated, false, "frontend cap alone does not mark truncated (backend sends the flag)");
248 s = progress(s, progressTool("cap-1", SUBAGENT_PROGRESS_TEXT, "more", { truncated: true }));
249 eq(toolById(s, "cap-1").subagentProgress?.truncated, true, "backend truncated flag honored");
250 }
251
252 // --- 8. Terminal keeps the preview; history hydration never restores it ----
253
254 {
255 let s = initialState;
256 s = dispatch(s, { id: "h-1", name: "task", args: "{}", readOnly: true });
257 s = progress(s, progressTool("h-1", SUBAGENT_PROGRESS_REASONING, "kept after terminal"));
258 s = progress(s, progressTool("h-1", SUBAGENT_PROGRESS_STATUS, "completed"));
259 eq(toolById(s, "h-1").subagentProgress?.reasoning, "kept after terminal", "preview retained after terminal");
260
261 const hydrated = historyMessagesToItems([
262 { role: "user", content: "go" },
263 { role: "assistant", content: "", toolCalls: [{ id: "h-1", name: "task", arguments: "{}" }] },
264 { role: "tool", toolCallId: "h-1", toolName: "task", content: "done" },
265 ] as HistoryMessage[], "h").items.filter((it) => it.kind === "tool");
266 ok(hydrated.every((it) => !it.subagentProgress), "history hydration never restores transient progress");
267 }
268
269 // --- 9. Nested real tool activity touches the parent card ------------------
270
271 {
272 let s = initialState;
273 s = dispatch(s, { id: "t-1", name: "task", args: "{}", readOnly: true });
274 const before = toolById(s, "t-1").subagentProgress!.lastActivityAt;
275 s = dispatch(s, { id: "t-1/bash_1", name: "bash", args: "ls", readOnly: false, parentId: "t-1" });
276 const after = toolById(s, "t-1").subagentProgress!;
277 eq(after.phase, "tool", "nested dispatch flips parent phase to tool");
278 ok(after.lastActivityAt >= before, "nested dispatch refreshes parent recent activity");
279 }
280
281 // --- 10. Ordinary tool progress behavior is unchanged ----------------------
282
283 {
284 let s = initialState;
285 s = dispatch(s, { id: "b-1", name: "bash", args: "ls", readOnly: false });
286 s = progress(s, progressTool("b-1", "bash", "file1\n"));
287 s = progress(s, progressTool("b-1", "bash", "file2\n"));
288 eq(toolById(s, "b-1").output, "file1\nfile2\n", "ordinary progress still appends to tool.output");
289 eq(toolById(s, "b-1").subagentProgress, undefined, "ordinary tools never gain a progress preview");
290
291 // Archiving on result still applies to sub-agent cards without dropping the
292 // preview (the tracker always emits its terminal before the result).
293 s = dispatch(s, { id: "arc-1", name: "task", args: "{}", readOnly: true });
294 s = progress(s, progressTool("arc-1", SUBAGENT_PROGRESS_STATUS, "completed"));
295 s = result(s, { id: "arc-1", name: "task", readOnly: true, output: "done" });
296 const archived = toolById(s, "arc-1");
297 eq(archived.status, "done", "archived sub-agent card settles");
298 ok(archived.subagentProgress !== undefined, "subagentProgress survives result archiving");
299 }
300
301 // --- 11. Terminal outcome metadata survives output archiving ---------------
302
303 {
304 let s = initialState;
305 s = dispatch(s, { id: "outcome-1", name: "task", args: "{}", readOnly: true });
306 s = progress(s, progressTool("outcome-1", SUBAGENT_PROGRESS_STATUS, "partial"));
307 s = result(s, {
308 id: "outcome-1",
309 name: "task",
310 readOnly: true,
311 output: "Subagent reference: sa_child\nSubagent outcome: status=partial retryable=true error_code=completion_uncertain",
312 subagentRef: "sa_child",
313 subagentStatus: "partial",
314 subagentErrorCode: "completion_uncertain",
315 subagentRetryable: true,
316 });
317 const archived = toolById(s, "outcome-1");
318 eq(JSON.stringify(archived.subagentOutcome), JSON.stringify(["sa_child", "partial", "completion_uncertain", true]), "terminal outcome is normalized once at the result boundary");
319 eq(archived.output, undefined, "outcome metadata survives without retaining archived tool output");
320 }
321
322 console.log(`\nsubagent progress: ${passed} passed, ${failed} failed`);
323 if (failed > 0) process.exit(1);
324
324 lines TYPESCRIPT