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