| 1 | // Run: tsx src/__tests__/use-controller-stream-progress.test.ts |
| 2 | // |
| 3 | // Covers the delivery-mode liveness fixes: |
| 4 | // 1. Partial tool dispatches upsert a running card (instead of being dropped) |
| 5 | // and carry streaming argChars progress. |
| 6 | // 2. usageSeq bumps on every usage event regardless of source, so the right |
| 7 | // panel keeps refreshing during sub-agent runs. |
| 8 | // 3. A mid-turn context snapshot reporting used=0 does not collapse a gauge |
| 9 | // that already shows real usage. |
| 10 | // 4. A retry event repairs stale idle snapshots in either delivery order so |
| 11 | // the turn remains stoppable. |
| 12 | |
| 13 | import { initialState, promptEventClock, reducer } from "../lib/useController"; |
| 14 | import type { WireEvent } from "../lib/types"; |
| 15 | |
| 16 | let passed = 0; |
| 17 | let failed = 0; |
| 18 | |
| 19 | function eq(a: unknown, b: unknown, 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 ev(s: typeof initialState, e: WireEvent) { |
| 30 | return reducer(s, { type: "event", e }); |
| 31 | } |
| 32 | |
| 33 | // --- 1. partial dispatch upserts a running card with argChars --- |
| 34 | { |
| 35 | let s = { ...initialState, running: true, turnActive: true }; |
| 36 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true } } as WireEvent); |
| 37 | const card = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 38 | eq(Boolean(card), true, "partial dispatch creates a running tool card"); |
| 39 | eq(card?.kind === "tool" ? card.status : "", "running", "partial card is running"); |
| 40 | eq(card?.kind === "tool" ? card.args : "x", "", "partial card has no args yet"); |
| 41 | |
| 42 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true, argChars: 8192 } } as WireEvent); |
| 43 | const card2 = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 44 | eq(card2?.kind === "tool" ? card2.argChars : 0, 8192, "arg progress updates the card"); |
| 45 | eq(s.turnArgChars, 8192, "turnArgChars mirrors streaming progress"); |
| 46 | |
| 47 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", args: '{"path":"a"}', readOnly: false } } as WireEvent); |
| 48 | const card3 = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 49 | eq(card3?.kind === "tool" ? card3.args : "", '{"path":"a"}', "full dispatch merges args into the same card"); |
| 50 | eq(card3?.kind === "tool" ? card3.argChars : 1, undefined, "full dispatch clears argChars"); |
| 51 | eq( |
| 52 | s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, |
| 53 | 1, |
| 54 | "partial + full dispatch never duplicate the card", |
| 55 | ); |
| 56 | |
| 57 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", args: '{"path":"a"}', readOnly: false, refreshed: true, diff: "@@ -1 +1 @@\n-old\n+new\n", added: 1, removed: 1 } } as WireEvent); |
| 58 | const refreshed = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 59 | eq(refreshed?.kind === "tool" ? refreshed.fileDiff?.diff : "", "@@ -1 +1 @@\n-old\n+new\n", "same-ID refresh replaces the live preview"); |
| 60 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 1, "preview refresh never duplicates the card"); |
| 61 | |
| 62 | s = ev(s, { kind: "tool_dispatch", tool: { |
| 63 | id: "c1", |
| 64 | name: "write_file", |
| 65 | args: '{"path":"a"}', |
| 66 | readOnly: false, |
| 67 | refreshed: true, |
| 68 | resolvedName: "mcp__db__write", |
| 69 | capabilityId: "mcp-tool:db/write", |
| 70 | } } as WireEvent); |
| 71 | const resolved = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 72 | eq(resolved?.kind === "tool" ? resolved.resolvedName : "", "mcp__db__write", "same-ID refresh stores resolved target"); |
| 73 | eq(resolved?.kind === "tool" ? resolved.capabilityId : "", "mcp-tool:db/write", "same-ID refresh stores capability id"); |
| 74 | eq(resolved?.kind === "tool" ? resolved.readOnly : true, false, "same-ID refresh replaces proxy read-only classification"); |
| 75 | |
| 76 | s = ev(s, { kind: "tool_result", tool: { |
| 77 | id: "c1", |
| 78 | name: "use_capability", |
| 79 | args: '{"action":"call","capability_id":"mcp-tool:db/write-v2"}', |
| 80 | readOnly: false, |
| 81 | resolvedName: "mcp__db__write_v2", |
| 82 | capabilityId: "mcp-tool:db/write-v2", |
| 83 | output: "done", |
| 84 | } } as WireEvent); |
| 85 | const completed = s.items.find((it) => it.kind === "tool" && it.id === "c1"); |
| 86 | eq(completed?.kind === "tool" ? completed.resolvedName : "", "mcp__db__write_v2", "tool result also refreshes resolved target"); |
| 87 | eq(completed?.kind === "tool" ? completed.capabilityId : "", "mcp-tool:db/write-v2", "tool result also refreshes capability id"); |
| 88 | eq(completed?.kind === "tool" ? completed.readOnly : true, false, "tool result preserves resolved writer classification"); |
| 89 | |
| 90 | s = ev(s, { kind: "usage", usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheHitTokens: 0, cacheMissTokens: 0 } } as WireEvent); |
| 91 | eq(s.turnArgChars, 0, "usage event resets the streaming estimate"); |
| 92 | } |
| 93 | |
| 94 | // --- 1b. partial dispatch without an ID never creates an orphan card --- |
| 95 | { |
| 96 | let s = { ...initialState, running: true, turnActive: true }; |
| 97 | // OpenAI-compatible streams can surface the name before the call ID. |
| 98 | s = ev(s, { kind: "tool_dispatch", tool: { name: "write_file", readOnly: false, partial: true, argChars: 2048 } } as WireEvent); |
| 99 | eq(s.items.filter((it) => it.kind === "tool").length, 0, "id-less partial creates no card"); |
| 100 | eq(s.turnArgChars, 2048, "id-less partial still counts streaming progress"); |
| 101 | |
| 102 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true, argChars: 4096 } } as WireEvent); |
| 103 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", args: '{"path":"a"}', readOnly: false } } as WireEvent); |
| 104 | eq(s.items.filter((it) => it.kind === "tool").length, 1, "late ID yields exactly one card, no orphan"); |
| 105 | const only = s.items.find((it) => it.kind === "tool"); |
| 106 | eq(only?.kind === "tool" ? only.id : "", "c1", "surviving card carries the real call ID"); |
| 107 | } |
| 108 | |
| 109 | // --- 1c. stream_attempt discard rolls back partial tool cards and text --- |
| 110 | { |
| 111 | let s = { ...initialState, running: true, turnActive: true }; |
| 112 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "begin", attempt: 1, max: 6 } } as WireEvent); |
| 113 | s = ev(s, { kind: "text", text: "partial half" } as WireEvent); |
| 114 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "edit_file", readOnly: false, partial: true, argChars: 6000, attemptId: "sa-1" } } as WireEvent); |
| 115 | // Concurrent background sub-agent tool must not be journaled. |
| 116 | s = ev(s, { kind: "tool_dispatch", tool: { id: "child-1", name: "read_file", readOnly: true, partial: true, parentId: "task-1", attemptId: "sa-1" } } as WireEvent); |
| 117 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 1, "partial edit_file card appears during attempt"); |
| 118 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "sub-agent partial is still shown"); |
| 119 | eq(s.live?.text, "partial half", "partial text is live during attempt"); |
| 120 | eq(s.turnArgChars, 6000, "arg progress tracked during attempt"); |
| 121 | |
| 122 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "discard", attempt: 1, max: 6, reason: "premature_eof" } } as WireEvent); |
| 123 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 0, "discard removes uncommitted parent tool card"); |
| 124 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "discard keeps concurrent sub-agent tool card"); |
| 125 | eq(s.live?.text ?? "", "", "discard clears attempt text (not concatenate)"); |
| 126 | eq(s.turnArgChars, 0, "discard restores turnArgChars baseline"); |
| 127 | |
| 128 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-2", action: "begin", attempt: 2, max: 6 } } as WireEvent); |
| 129 | s = ev(s, { kind: "text", text: "full answer" } as WireEvent); |
| 130 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c2", name: "edit_file", readOnly: false, partial: true, argChars: 12000, attemptId: "sa-2" } } as WireEvent); |
| 131 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-2", action: "commit", attempt: 2, max: 6 } } as WireEvent); |
| 132 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c2", name: "edit_file", args: '{"path":"a"}', readOnly: false } } as WireEvent); |
| 133 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "c2").length, 1, "final success has committed parent tool card"); |
| 134 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "sub-agent card still present after commit"); |
| 135 | eq(s.live?.text, "full answer", "final text is the committed attempt only"); |
| 136 | } |
| 137 | |
| 138 | // --- 1d. stale discard must not clear a newer attempt journal --- |
| 139 | { |
| 140 | let s = { ...initialState, running: true, turnActive: true }; |
| 141 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-new", action: "begin", attempt: 2, max: 6 } } as WireEvent); |
| 142 | s = ev(s, { kind: "tool_dispatch", tool: { id: "c-new", name: "edit_file", readOnly: false, partial: true, attemptId: "sa-new" } } as WireEvent); |
| 143 | eq(s.streamAttemptJournal?.id, "sa-new", "journal tracks current attempt"); |
| 144 | s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-old", action: "discard", attempt: 1, max: 6, reason: "premature_eof" } } as WireEvent); |
| 145 | eq(s.streamAttemptJournal?.id, "sa-new", "stale discard leaves current journal"); |
| 146 | eq(s.items.filter((it) => it.kind === "tool" && it.id === "c-new").length, 1, "stale discard does not remove current partial card"); |
| 147 | s = ev(s, { kind: "turn_done" } as WireEvent); |
| 148 | eq(s.streamAttemptJournal, undefined, "turn_done clears stream attempt journal"); |
| 149 | } |
| 150 | |
| 151 | // --- 2. usageSeq bumps for every source --- |
| 152 | { |
| 153 | let s = { ...initialState, running: true, turnActive: true }; |
| 154 | s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cacheHitTokens: 0, cacheMissTokens: 0 } } as WireEvent); |
| 155 | eq(s.usageSeq, 1, "executor usage bumps usageSeq"); |
| 156 | s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cacheHitTokens: 0, cacheMissTokens: 0, source: "subagent" } } as WireEvent); |
| 157 | eq(s.usageSeq, 2, "subagent usage bumps usageSeq"); |
| 158 | eq(s.usage?.source ?? "", "", "subagent usage does not replace executor gauge usage"); |
| 159 | } |
| 160 | |
| 161 | // --- 3. context no-regress guard while a turn runs --- |
| 162 | { |
| 163 | let s = { ...initialState, running: true, turnActive: true, context: { used: 14000, window: 1000000, sessionTokens: 20000 } }; |
| 164 | s = reducer(s, { type: "context", context: { used: 0, window: 1000000, sessionTokens: 20000 } } as never); |
| 165 | eq(s.context.used, 14000, "mid-turn used=0 snapshot keeps last known fill"); |
| 166 | |
| 167 | let idle = { ...initialState, context: { used: 14000, window: 1000000, sessionTokens: 20000 } }; |
| 168 | idle = reducer(idle, { type: "context", context: { used: 0, window: 1000000, sessionTokens: 0 } } as never); |
| 169 | eq(idle.context.used, 0, "idle used=0 snapshot applies (genuine reset)"); |
| 170 | } |
| 171 | |
| 172 | // --- 4. retrying is authoritative foreground activity --- |
| 173 | { |
| 174 | let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent); |
| 175 | s = reducer(s, { |
| 176 | type: "backend_status", |
| 177 | running: false, |
| 178 | pendingPrompt: false, |
| 179 | backgroundJobs: 0, |
| 180 | cancelRequested: false, |
| 181 | cancellable: false, |
| 182 | }); |
| 183 | eq(s.running, false, "stale idle snapshot reproduces the hidden-stop state"); |
| 184 | |
| 185 | s = ev(s, { kind: "retrying", retryAttempt: 3, retryMax: 10 } as WireEvent); |
| 186 | eq(s.retry?.attempt, 3, "retry status keeps the current attempt"); |
| 187 | eq(s.retry?.max, 10, "retry status keeps the retry budget"); |
| 188 | eq(s.running, true, "retry event restores the active turn"); |
| 189 | eq(s.turnActive, true, "retry event restores the turn epoch"); |
| 190 | eq(s.cancellable, true, "retry event keeps Stop and Escape cancellation available"); |
| 191 | eq(s.turnStartAt > 0, true, "retry event restores timing for a reattached turn"); |
| 192 | |
| 193 | const repaired = s; |
| 194 | const completed = ev(repaired, { kind: "turn_done" } as WireEvent); |
| 195 | eq(completed.running, false, "turn_done still ends the repaired turn"); |
| 196 | eq(completed.retry, undefined, "turn_done clears the retry indicator"); |
| 197 | |
| 198 | const staleSnapshotAt = promptEventClock(); |
| 199 | s = ev(repaired, { kind: "retrying", retryAttempt: 4, retryMax: 10 } as WireEvent); |
| 200 | s = reducer(s, { |
| 201 | type: "backend_status", |
| 202 | running: false, |
| 203 | pendingPrompt: false, |
| 204 | backgroundJobs: 0, |
| 205 | cancelRequested: false, |
| 206 | cancellable: false, |
| 207 | snapshotAt: staleSnapshotAt, |
| 208 | }); |
| 209 | eq(s.running, true, "idle snapshot fetched before retry cannot hide Stop when it returns later"); |
| 210 | eq(s.turnActive, true, "idle snapshot fetched before retry cannot end the active turn"); |
| 211 | eq(s.cancellable, true, "idle snapshot fetched before retry preserves cancellation"); |
| 212 | eq(s.retry?.attempt, 4, "stale idle snapshot preserves the newer retry status"); |
| 213 | |
| 214 | s = reducer(s, { |
| 215 | type: "backend_status", |
| 216 | running: false, |
| 217 | pendingPrompt: false, |
| 218 | backgroundJobs: 0, |
| 219 | cancelRequested: false, |
| 220 | cancellable: false, |
| 221 | snapshotAt: Number.MAX_SAFE_INTEGER, |
| 222 | }); |
| 223 | eq(s.running, false, "fresh idle snapshot can reconcile a missed turn_done"); |
| 224 | eq(s.retry, undefined, "fresh idle snapshot clears the retry indicator"); |
| 225 | } |
| 226 | |
| 227 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 228 | if (failed > 0) process.exit(1); |
| 229 |