返回 DeepSeek-Reasonix
use-controller-stream-progress.test.ts
根目录 / desktop / frontend / src / __tests__ / use-controller-stream-progress.test.ts
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
229 lines TYPESCRIPT