返回 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 // 5. TPS telemetry accumulates provider-output intervals without tool gaps and
13 // survives both missing usage and missing turn_done events.
14
15 import { initialState, promptEventClock, reducer } from "../lib/useController";
16 import type { WireEvent } from "../lib/types";
17
18 let passed = 0;
19 let failed = 0;
20
21 function eq(a: unknown, b: unknown, label: string) {
22 if (a === b) {
23 process.stdout.write(` PASS ${label}\n`);
24 passed += 1;
25 } else {
26 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
27 failed += 1;
28 }
29 }
30
31 function ev(s: typeof initialState, e: WireEvent) {
32 return reducer(s, { type: "event", e });
33 }
34
35 // Completed answer chrome receives the full UI-turn aggregate, including
36 // auxiliary requests, while the provider-speed calculation stays executor-only.
37 {
38 let s = { ...initialState, running: true, turnActive: true, turnStartAt: Date.now() - 29_000 };
39 s = ev(s, { kind: "text", messageId: "usage-answer", text: "answer" } as WireEvent);
40 s = ev(s, { kind: "usage", usage: {
41 promptTokens: 100, completionTokens: 20, totalTokens: 120, cacheHitTokens: 80, cacheMissTokens: 20,
42 reasoningTokens: 10, costQuote: { modelRef: "deepseek-official/deepseek-flash" },
43 } } as WireEvent);
44 s = ev(s, { kind: "usage", usage: {
45 promptTokens: 50, completionTokens: 5, totalTokens: 55, cacheHitTokens: 40, cacheMissTokens: 10,
46 source: "subagent", costQuote: { modelRef: "deepseek-official/deepseek-flash" },
47 } } as WireEvent);
48 s = ev(s, { kind: "turn_done" } as WireEvent);
49 const answer = [...s.items].reverse().find(item => item.kind === "assistant");
50 eq(answer?.kind === "assistant" ? answer.turnUsage?.totalTokens : 0, 175, "turn footer aggregates all usage events");
51 eq(answer?.kind === "assistant" ? answer.turnUsage?.cacheReadTokens : 0, 120, "turn footer aggregates cache reads");
52 eq(answer?.kind === "assistant" ? answer.turnUsage?.routes?.join(",") : "", "deepseek-official/deepseek-flash", "turn footer deduplicates model routes");
53 eq(answer?.kind === "assistant" ? Boolean(answer.createdAt) : false, true, "completed answer receives its display timestamp");
54 eq(answer?.kind === "assistant" ? (answer.turnDurationMs ?? 0) >= 29_000 : false, true, "completed answer receives wall-clock turn duration");
55 }
56
57 // Desktop keeps ordinary completion receipts off the transcript, but retains
58 // details for the change panel and surfaces actionable gaps as a short notice.
59 {
60 const before = {
61 ...initialState,
62 seq: 2,
63 items: [{ kind: "user" as const, id: "u1", text: "update it" }],
64 };
65 const complete = ev(before, {
66 kind: "completion_summary",
67 completion: {
68 preset: "balanced",
69 verdict: "complete",
70 mutations: 3,
71 checks_passed: 12,
72 checks_failed: 0,
73 checks_suppressed: 0,
74 review: "passed",
75 gap_kinds: [],
76 constraint_degraded: false,
77 floor: "standard",
78 attention: false,
79 },
80 });
81 eq(complete.items.length, before.items.length + 1, "workspace mutations add a neutral change notice");
82 const changeNotice = complete.items[complete.items.length - 1];
83 eq(changeNotice?.kind === "notice" ? changeNotice.level : "", "info", "workspace mutations use an info notice, not a warning");
84 eq(changeNotice?.kind === "notice" ? changeNotice.completionSummary : undefined, complete.completionSummary, "neutral change notice retains its own normalized summary");
85 eq(complete.completionSummary?.preset, "balanced", "ordinary completion summary remains available to the change panel");
86
87 const after = ev(complete, {
88 kind: "completion_summary",
89 completion: {
90 preset: "balanced",
91 verdict: "partial",
92 mutations: 3,
93 checks_passed: 12,
94 checks_failed: 1,
95 checks_suppressed: 2,
96 review: "passed",
97 gap_kinds: ["stale_check"],
98 constraint_degraded: true,
99 floor: "delivery",
100 attention: true,
101 },
102 });
103 eq(after.items.length, complete.items.length, "summary refresh replaces the same result notice");
104 const notice = after.items[after.items.length - 1];
105 eq(notice?.kind === "notice" ? notice.variant : "", "completion", "quality gap uses the completion notice variant");
106 eq(notice?.kind === "notice" ? notice.action : "", "open_changes", "quality gap links to the change panel");
107 eq(notice?.kind === "notice" ? notice.completionSummary : undefined, after.completionSummary, "completion notice retains its own normalized summary");
108 eq(notice?.kind === "notice" ? notice.text.includes("balanced") : true, false, "compact notice does not expose internal preset values");
109 eq(after.completionSummary?.checks_failed, 1, "actionable completion summary is retained for details");
110
111 const switchedFloor = ev({
112 ...complete,
113 meta: { label: "test", ready: true, eventChannel: "", cwd: "/repo", qualityFloor: "delivery" },
114 }, {
115 kind: "completion_summary",
116 completion: {
117 ...complete.completionSummary!,
118 verdict: "partial",
119 gap_kinds: ["unverified_change"],
120 floor: "standard",
121 attention: false,
122 },
123 });
124 const switchedNotice = switchedFloor.items[switchedFloor.items.length - 1];
125 eq(switchedNotice?.kind === "notice" ? switchedNotice.level : "", "info", "turn-time standard summary stays neutral after switching to delivery");
126
127 const suppressed = ev(complete, {
128 kind: "completion_summary",
129 completion: {
130 ...complete.completionSummary!,
131 mutations: 0,
132 checks_suppressed: 1,
133 gap_kinds: ["suppressed_requirement"],
134 floor: "delivery",
135 attention: true,
136 },
137 });
138 const suppressedNotice = suppressed.items[suppressed.items.length - 1];
139 eq(suppressedNotice?.kind === "notice" ? suppressedNotice.title : "", "Turn result", "required suppression retains the fixed result title");
140
141 const restarted = ev(after, { kind: "turn_started" });
142 eq(restarted.completionSummary, undefined, "a new turn clears the previous turn's quality details");
143 }
144
145 // --- 1. partial dispatch upserts a running card with argChars ---
146 {
147 let s = { ...initialState, running: true, turnActive: true };
148 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true } } as WireEvent);
149 const card = s.items.find((it) => it.kind === "tool" && it.id === "c1");
150 eq(Boolean(card), true, "partial dispatch creates a running tool card");
151 eq(card?.kind === "tool" ? card.status : "", "running", "partial card is running");
152 eq(card?.kind === "tool" ? card.args : "x", "", "partial card has no args yet");
153
154 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true, argChars: 8192 } } as WireEvent);
155 const card2 = s.items.find((it) => it.kind === "tool" && it.id === "c1");
156 eq(card2?.kind === "tool" ? card2.argChars : 0, 8192, "arg progress updates the card");
157 eq(s.turnArgChars, 8192, "turnArgChars mirrors streaming progress");
158
159 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", args: '{"path":"a"}', readOnly: false } } as WireEvent);
160 const card3 = s.items.find((it) => it.kind === "tool" && it.id === "c1");
161 eq(card3?.kind === "tool" ? card3.args : "", '{"path":"a"}', "full dispatch merges args into the same card");
162 eq(card3?.kind === "tool" ? card3.argChars : 1, undefined, "full dispatch clears argChars");
163 eq(
164 s.items.filter((it) => it.kind === "tool" && it.id === "c1").length,
165 1,
166 "partial + full dispatch never duplicate the card",
167 );
168
169 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);
170 const refreshed = s.items.find((it) => it.kind === "tool" && it.id === "c1");
171 eq(refreshed?.kind === "tool" ? refreshed.fileDiff?.diff : "", "@@ -1 +1 @@\n-old\n+new\n", "same-ID refresh replaces the live preview");
172 eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 1, "preview refresh never duplicates the card");
173
174 s = ev(s, { kind: "tool_dispatch", tool: {
175 id: "c1",
176 name: "write_file",
177 args: '{"path":"a"}',
178 readOnly: false,
179 refreshed: true,
180 resolvedName: "mcp__db__write",
181 capabilityId: "mcp-tool:db/write",
182 } } as WireEvent);
183 const resolved = s.items.find((it) => it.kind === "tool" && it.id === "c1");
184 eq(resolved?.kind === "tool" ? resolved.resolvedName : "", "mcp__db__write", "same-ID refresh stores resolved target");
185 eq(resolved?.kind === "tool" ? resolved.capabilityId : "", "mcp-tool:db/write", "same-ID refresh stores capability id");
186 eq(resolved?.kind === "tool" ? resolved.readOnly : true, false, "same-ID refresh replaces proxy read-only classification");
187
188 s = ev(s, { kind: "tool_result", tool: {
189 id: "c1",
190 name: "use_capability",
191 args: '{"action":"call","capability_id":"mcp-tool:db/write-v2"}',
192 readOnly: false,
193 resolvedName: "mcp__db__write_v2",
194 capabilityId: "mcp-tool:db/write-v2",
195 output: "done",
196 } } as WireEvent);
197 const completed = s.items.find((it) => it.kind === "tool" && it.id === "c1");
198 eq(completed?.kind === "tool" ? completed.resolvedName : "", "mcp__db__write_v2", "tool result also refreshes resolved target");
199 eq(completed?.kind === "tool" ? completed.capabilityId : "", "mcp-tool:db/write-v2", "tool result also refreshes capability id");
200 eq(completed?.kind === "tool" ? completed.readOnly : true, false, "tool result preserves resolved writer classification");
201
202 s = ev(s, { kind: "usage", usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheHitTokens: 0, cacheMissTokens: 0 } } as WireEvent);
203 eq(s.turnArgChars, 0, "usage event resets the streaming estimate");
204 }
205
206 // --- 1b. partial dispatch without an ID never creates an orphan card ---
207 {
208 let s = { ...initialState, running: true, turnActive: true };
209 // OpenAI-compatible streams can surface the name before the call ID.
210 s = ev(s, { kind: "tool_dispatch", tool: { name: "write_file", readOnly: false, partial: true, argChars: 2048 } } as WireEvent);
211 eq(s.items.filter((it) => it.kind === "tool").length, 0, "id-less partial creates no card");
212 eq(s.turnArgChars, 2048, "id-less partial still counts streaming progress");
213
214 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", readOnly: false, partial: true, argChars: 4096 } } as WireEvent);
215 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "write_file", args: '{"path":"a"}', readOnly: false } } as WireEvent);
216 eq(s.items.filter((it) => it.kind === "tool").length, 1, "late ID yields exactly one card, no orphan");
217 const only = s.items.find((it) => it.kind === "tool");
218 eq(only?.kind === "tool" ? only.id : "", "c1", "surviving card carries the real call ID");
219 }
220
221 // --- 1c. stream_attempt discard rolls back partial tool cards and text ---
222 {
223 let s = { ...initialState, running: true, turnActive: true };
224 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "begin", attempt: 1, max: 6 } } as WireEvent);
225 s = ev(s, { kind: "text", text: "partial half" } as WireEvent);
226 s = ev(s, { kind: "tool_dispatch", tool: { id: "c1", name: "edit_file", readOnly: false, partial: true, argChars: 6000, attemptId: "sa-1" } } as WireEvent);
227 // Concurrent background sub-agent tool must not be journaled.
228 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);
229 eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 1, "partial edit_file card appears during attempt");
230 eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "sub-agent partial is still shown");
231 eq(s.live?.text, "partial half", "partial text is live during attempt");
232 eq(s.turnArgChars, 6000, "arg progress tracked during attempt");
233
234 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "discard", attempt: 1, max: 6, reason: "premature_eof" } } as WireEvent);
235 eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 0, "discard removes uncommitted parent tool card");
236 eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "discard keeps concurrent sub-agent tool card");
237 eq(s.live?.text ?? "", "", "discard clears attempt text (not concatenate)");
238 eq(s.turnArgChars, 0, "discard restores turnArgChars baseline");
239
240 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-2", action: "begin", attempt: 2, max: 6 } } as WireEvent);
241 s = ev(s, { kind: "text", text: "full answer" } as WireEvent);
242 s = ev(s, { kind: "tool_dispatch", tool: { id: "c2", name: "edit_file", readOnly: false, partial: true, argChars: 12000, attemptId: "sa-2" } } as WireEvent);
243 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-2", action: "commit", attempt: 2, max: 6 } } as WireEvent);
244 s = ev(s, { kind: "tool_dispatch", tool: { id: "c2", name: "edit_file", args: '{"path":"a"}', readOnly: false } } as WireEvent);
245 eq(s.items.filter((it) => it.kind === "tool" && it.id === "c2").length, 1, "final success has committed parent tool card");
246 eq(s.items.filter((it) => it.kind === "tool" && it.id === "child-1").length, 1, "sub-agent card still present after commit");
247 const committedAssistant = s.items.find((it) => it.kind === "assistant" && it.text === "full answer");
248 eq(Boolean(committedAssistant), true, "full dispatch settles the committed attempt text");
249 eq(s.live, undefined, "full dispatch closes the compatibility-path assistant segment");
250 }
251
252 // --- 1d. stale discard must not clear a newer attempt journal ---
253 {
254 let s = { ...initialState, running: true, turnActive: true };
255 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-new", action: "begin", attempt: 2, max: 6 } } as WireEvent);
256 s = ev(s, { kind: "tool_dispatch", tool: { id: "c-new", name: "edit_file", readOnly: false, partial: true, attemptId: "sa-new" } } as WireEvent);
257 eq(s.streamAttemptJournal?.id, "sa-new", "journal tracks current attempt");
258 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "sa-old", action: "discard", attempt: 1, max: 6, reason: "premature_eof" } } as WireEvent);
259 eq(s.streamAttemptJournal?.id, "sa-new", "stale discard leaves current journal");
260 eq(s.items.filter((it) => it.kind === "tool" && it.id === "c-new").length, 1, "stale discard does not remove current partial card");
261 s = ev(s, { kind: "turn_done" } as WireEvent);
262 eq(s.streamAttemptJournal, undefined, "turn_done clears stream attempt journal");
263 }
264
265 // --- 1e. one backend turn keeps each provider sampling round in timeline order ---
266 {
267 let s = ev({ ...initialState }, { kind: "turn_started", turnId: "multi-round" } as WireEvent);
268 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "round-1", action: "begin", attempt: 1, max: 3 } } as WireEvent);
269 s = ev(s, { kind: "reasoning", reasoning: "first analysis" } as WireEvent);
270 s = ev(s, { kind: "message", text: "", reasoning: "first analysis" } as WireEvent);
271 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "round-1", action: "commit", attempt: 1, max: 3 } } as WireEvent);
272 s = ev(s, { kind: "tool_dispatch", tool: { id: "lookup-1", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
273 s = ev(s, { kind: "tool_result", tool: { id: "lookup-1", name: "read_file", args: "{}", readOnly: true, output: "ok" } } as WireEvent);
274 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "round-2", action: "begin", attempt: 1, max: 3 } } as WireEvent);
275 s = ev(s, { kind: "reasoning", reasoning: "second analysis" } as WireEvent);
276 s = ev(s, { kind: "message", text: "final answer", reasoning: "second analysis" } as WireEvent);
277 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "round-2", action: "commit", attempt: 1, max: 3 } } as WireEvent);
278 const replayedStart = ev(s, { kind: "turn_started", turnId: "multi-round" } as WireEvent);
279 eq(replayedStart.items.filter((item) => item.kind === "assistant").length, 2, "a replayed turn_started event does not duplicate settled segments");
280 s = replayedStart;
281 s = ev(s, { kind: "turn_done", turnId: "multi-round" } as WireEvent);
282
283 const timeline = s.items.filter((item) => item.kind === "assistant" || item.kind === "tool");
284 eq(timeline.map((item) => item.kind).join(","), "assistant,tool,assistant", "multi-round live order matches persisted history order");
285 eq(timeline[0]?.id, "a:multi-round:0", "first sample owns ordinal zero");
286 eq(timeline[2]?.id, "a:multi-round:1", "second sample owns a distinct ordinal");
287 eq(timeline[0]?.kind === "assistant" ? timeline[0].reasoning : "", "first analysis", "first reasoning is not overwritten");
288 eq(timeline[2]?.kind === "assistant" ? timeline[2].text : "", "final answer", "final answer remains in the second segment");
289 eq(timeline[2]?.kind === "assistant" ? timeline[2].wasStreamed : undefined, true, "live-origin identity survives completion");
290
291 }
292
293 // --- 1f. tool-only samples remove their placeholder before the next round ---
294 {
295 let s = ev({ ...initialState }, { kind: "turn_started", turnId: "tool-only" } as WireEvent);
296 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "tool-round", action: "begin", attempt: 1, max: 2 } } as WireEvent);
297 s = ev(s, { kind: "tool_dispatch", tool: { id: "shell-1", name: "bash", readOnly: false, partial: true, attemptId: "tool-round" } } as WireEvent);
298 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "tool-round", action: "commit", attempt: 1, max: 2 } } as WireEvent);
299 eq(s.items.some((item) => item.kind === "assistant"), false, "tool-only commit removes its empty assistant placeholder");
300 s = ev(s, { kind: "tool_dispatch", tool: { id: "shell-1", name: "bash", args: "{}", readOnly: false } } as WireEvent);
301 s = ev(s, { kind: "tool_result", tool: { id: "shell-1", name: "bash", args: "{}", readOnly: false, output: "ok" } } as WireEvent);
302 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "answer-round", action: "begin", attempt: 1, max: 2 } } as WireEvent);
303 s = ev(s, { kind: "reasoning", reasoning: "after tool" } as WireEvent);
304 s = ev(s, { kind: "message", text: "done", reasoning: "after tool" } as WireEvent);
305 const visible = s.items.filter((item) => item.kind === "tool" || item.kind === "assistant");
306 eq(visible.map((item) => item.kind).join(","), "tool,assistant", "next reasoning is appended below the committed tool");
307 eq(visible[1]?.id, "a:tool-only:1", "tool-only placeholder ordinal is not reused");
308 }
309
310 // --- 1g. discard retries preserve segment identity; legacy full tools retire placeholders ---
311 {
312 let s = ev({ ...initialState }, { kind: "turn_started", turnId: "retry-segment" } as WireEvent);
313 const segmentId = s.currentAssistant;
314 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "bad", action: "begin", attempt: 1, max: 2 } } as WireEvent);
315 s = ev(s, { kind: "reasoning", reasoning: "discard me" } as WireEvent);
316 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "bad", action: "discard", attempt: 1, max: 2 } } as WireEvent);
317 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "good", action: "begin", attempt: 2, max: 2 } } as WireEvent);
318 eq(s.currentAssistant, segmentId, "retry reuses the same sampling segment identity");
319 eq(s.live?.reasoning, "", "retry starts from the rolled-back reasoning baseline");
320
321 let legacy = ev({ ...initialState }, { kind: "turn_started", turnId: "legacy" } as WireEvent);
322 legacy = ev(legacy, { kind: "tool_dispatch", tool: { id: "legacy-tool", name: "bash", args: "{}", readOnly: false } } as WireEvent);
323 legacy = ev(legacy, { kind: "reasoning", reasoning: "legacy follow-up" } as WireEvent);
324 const legacyTimeline = legacy.items.filter((item) => item.kind === "tool" || item.kind === "assistant");
325 eq(legacyTimeline.map((item) => item.kind).join(","), "tool,assistant", "full dispatch compatibility path retires the empty placeholder");
326 eq(legacyTimeline[1]?.id, "a:legacy:1", "legacy follow-up gets the next segment ordinal");
327
328 let partialLegacy = ev({ ...initialState, activeTurnId: "partial-legacy", running: true, turnActive: true }, {
329 kind: "tool_dispatch",
330 tool: { id: "partial-legacy-tool", name: "bash", readOnly: false, partial: true },
331 } as WireEvent);
332 eq(partialLegacy.currentAssistant, "a:partial-legacy:0", "first partial dispatch backfills a sampling segment");
333 partialLegacy = ev(partialLegacy, {
334 kind: "tool_dispatch",
335 tool: { id: "partial-legacy-tool", name: "bash", args: "{}", readOnly: false },
336 } as WireEvent);
337 eq(partialLegacy.items.some((item) => item.kind === "assistant"), false, "legacy full dispatch removes the partial path's empty segment");
338 }
339
340 // --- 1h. terminal duration belongs to the last retained assistant segment ---
341 {
342 const originalNow = Date.now;
343 let now = 100_000;
344 Date.now = () => now;
345 try {
346 let s = ev({ ...initialState }, { kind: "turn_started", turnId: "terminal-duration" } as WireEvent);
347 now = 101_000;
348 s = ev(s, { kind: "message", text: "first answer" } as WireEvent);
349 s = ev(s, { kind: "tool_dispatch", tool: { id: "duration-tool", name: "bash", args: "{}", readOnly: false } } as WireEvent);
350 s = ev(s, { kind: "tool_result", tool: { id: "duration-tool", name: "bash", args: "{}", readOnly: false, output: "ok" } } as WireEvent);
351 now = 110_000;
352 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "empty-final", action: "begin", attempt: 1, max: 1 } } as WireEvent);
353 s = ev(s, { kind: "turn_done", turnId: "terminal-duration", status: "interrupted" } as WireEvent);
354
355 const assistants = s.items.filter((item) => item.kind === "assistant");
356 eq(assistants.length, 1, "turn_done removes the empty terminal placeholder");
357 eq(assistants[0]?.kind === "assistant" ? assistants[0].workDurationMs : 0, 10_000, "turn_done assigns total work duration to the last visible assistant");
358 } finally {
359 Date.now = originalNow;
360 }
361 }
362
363 // --- 2. usageSeq bumps for every source ---
364 {
365 let s = { ...initialState, running: true, turnActive: true };
366 s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cacheHitTokens: 0, cacheMissTokens: 0 } } as WireEvent);
367 eq(s.usageSeq, 1, "executor usage bumps usageSeq");
368 s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cacheHitTokens: 0, cacheMissTokens: 0, source: "subagent" } } as WireEvent);
369 eq(s.usageSeq, 2, "subagent usage bumps usageSeq");
370 eq(s.usage?.source ?? "", "", "subagent usage does not replace executor gauge usage");
371 }
372
373 // --- 3. context no-regress guard while a turn runs ---
374 {
375 let s = { ...initialState, running: true, turnActive: true, context: { used: 14000, window: 1000000, sessionTokens: 20000 } };
376 s = reducer(s, { type: "context", context: { used: 0, window: 1000000, sessionTokens: 20000 } } as never);
377 eq(s.context.used, 14000, "mid-turn used=0 snapshot keeps last known fill");
378
379 let idle = { ...initialState, context: { used: 14000, window: 1000000, sessionTokens: 20000 } };
380 idle = reducer(idle, { type: "context", context: { used: 0, window: 1000000, sessionTokens: 0 } } as never);
381 eq(idle.context.used, 0, "idle used=0 snapshot applies (genuine reset)");
382 }
383
384 // --- 4. retrying is authoritative foreground activity ---
385 {
386 let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
387 s = reducer(s, {
388 type: "backend_status",
389 running: false,
390 pendingPrompt: false,
391 backgroundJobs: 0,
392 cancelRequested: false,
393 cancellable: false,
394 });
395 eq(s.running, false, "stale idle snapshot reproduces the hidden-stop state");
396
397 s = ev(s, { kind: "retrying", retryAttempt: 3, retryMax: 10 } as WireEvent);
398 eq(s.retry?.attempt, 3, "retry status keeps the current attempt");
399 eq(s.retry?.max, 10, "retry status keeps the retry budget");
400 const recovery = { phase: "headers", next_attempt_at: Date.now() + 60_000, waited_ms: 14_000, waiting: true };
401 s = ev(s, { kind: "retrying", retryAttempt: 4, retryMax: 3, recovery } as WireEvent);
402 eq(s.retry?.recovery?.waiting, true, "continuous wait survives the controller projection");
403 eq(s.retry?.recovery?.next_attempt_at, recovery.next_attempt_at, "countdown uses the provider recovery deadline");
404
405 eq(s.running, true, "retry event restores the active turn");
406 eq(s.turnActive, true, "retry event restores the turn epoch");
407 eq(s.cancellable, true, "retry event keeps Stop and Escape cancellation available");
408 eq(s.turnStartAt > 0, true, "retry event restores timing for a reattached turn");
409
410 const repaired = s;
411 const completed = ev(repaired, { kind: "turn_done" } as WireEvent);
412 eq(completed.running, false, "turn_done still ends the repaired turn");
413 eq(completed.retry, undefined, "turn_done clears the retry indicator");
414
415 const failed = ev(repaired, { kind: "turn_done", err: "shared window overflow" } as WireEvent);
416 eq(failed.running, false, "terminal context error clears running");
417 eq(failed.pendingPrompt, false, "terminal context error clears pending prompt");
418 eq(failed.messageAction, undefined, "terminal context error restores message actions");
419
420 const staleSnapshotAt = promptEventClock();
421 s = ev(repaired, { kind: "retrying", retryAttempt: 4, retryMax: 10 } as WireEvent);
422 s = reducer(s, {
423 type: "backend_status",
424 running: false,
425 pendingPrompt: false,
426 backgroundJobs: 0,
427 cancelRequested: false,
428 cancellable: false,
429 snapshotAt: staleSnapshotAt,
430 });
431 eq(s.running, true, "idle snapshot fetched before retry cannot hide Stop when it returns later");
432 eq(s.turnActive, true, "idle snapshot fetched before retry cannot end the active turn");
433 eq(s.cancellable, true, "idle snapshot fetched before retry preserves cancellation");
434 eq(s.retry?.attempt, 4, "stale idle snapshot preserves the newer retry status");
435
436 s = reducer(s, {
437 type: "backend_status",
438 running: false,
439 pendingPrompt: false,
440 backgroundJobs: 0,
441 cancelRequested: false,
442 cancellable: false,
443 snapshotAt: Number.MAX_SAFE_INTEGER,
444 });
445 eq(s.running, false, "fresh idle snapshot can reconcile a missed turn_done");
446 eq(s.retry, undefined, "fresh idle snapshot clears the retry indicator");
447 }
448
449 // --- 4b. runtime status sequence is monotonic within a controller epoch ---
450 {
451 let s = { ...initialState, running: true, turnActive: true };
452 s = reducer(s, {
453 type: "backend_status", running: true, turnId: "turn-new", runtimeEpoch: "epoch-a", turnEventSeq: 12,
454 });
455 const stale = reducer(s, {
456 type: "backend_status", running: false, runtimeEpoch: "epoch-a", turnEventSeq: 11,
457 });
458 eq(stale.running, true, "older idle runtime snapshot cannot hide a newer running turn");
459 eq(stale.activeTurnId, "turn-new", "older runtime snapshot cannot clear the active turn");
460 const duplicate = reducer(s, {
461 type: "backend_status", running: false, runtimeEpoch: "epoch-a", turnEventSeq: 12,
462 });
463 eq(duplicate.running, true, "duplicate runtime sequence cannot mutate turn state");
464 const rebuilt = reducer(stale, {
465 type: "backend_status", running: false, runtimeEpoch: "epoch-b", turnEventSeq: 1,
466 });
467 eq(rebuilt.running, false, "a new controller epoch may settle from its first snapshot");
468 }
469
470 // --- 5. TPS telemetry excludes tool gaps and preserves fallback estimates ---
471 {
472 const originalNow = Date.now;
473 let now = 1_000;
474 Date.now = () => now;
475 try {
476 let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
477 now = 1_100;
478 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
479 now = 2_100;
480 s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14, cacheHitTokens: 0, cacheMissTokens: 10 } } as WireEvent);
481 s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
482 eq(s.turnModelActiveMs, 1_000, "first provider output interval is accumulated");
483 eq(s.turnOutputCharsAtUsage, 0, "completed assistant message resets the live-character baseline");
484
485 // A long tool gap must not lower TPS for the next provider request.
486 now = 8_000;
487 s = ev(s, { kind: "text", text: "abcdefgh" } as WireEvent);
488 now = 9_000;
489 s = ev(s, { kind: "turn_done" } as WireEvent);
490 eq(s.lastTurnOutputTokens, 6, "missing final usage adds only the in-flight character estimate");
491 eq(s.lastTurnModelMs, 2_000, "tool gap is excluded from completed TPS duration");
492 eq(s.lastTurnOutputEstimated, true, "missing final usage marks completed TPS as estimated");
493
494 // Providers that omit per-request usage must still close the first model
495 // interval before the tool runs.
496 now = 12_000;
497 s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
498 now = 12_100;
499 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
500 now = 13_100;
501 s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
502 s = ev(s, { kind: "tool_dispatch", tool: { id: "missing-usage", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
503 now = 19_000;
504 s = ev(s, { kind: "text", text: "efgh" } as WireEvent);
505 now = 20_000;
506 s = ev(s, { kind: "turn_done" } as WireEvent);
507 eq(s.lastTurnOutputTokens, 2, "missing usage estimates output across provider requests");
508 eq(s.lastTurnModelMs, 2_000, "missing usage still excludes the tool gap");
509
510 now = 21_000;
511 s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
512 now = 21_100;
513 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
514 now = 22_100;
515 s = ev(s, { kind: "usage", usage: { promptTokens: 10, completionTokens: 1, totalTokens: 11, cacheHitTokens: 0, cacheMissTokens: 10, estimated: true } } as WireEvent);
516 s = ev(s, { kind: "turn_done" } as WireEvent);
517 eq(s.lastTurnOutputEstimated, true, "provider-estimated usage marks completed TPS as estimated");
518
519 now = 23_000;
520 s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
521 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
522 now = 24_000;
523 s = reducer(s, {
524 type: "backend_status",
525 running: false,
526 pendingPrompt: false,
527 backgroundJobs: 0,
528 cancelRequested: false,
529 cancellable: false,
530 });
531 eq(s.lastTurnOutputTokens, 1, "idle reconciliation snapshots fallback output telemetry");
532 eq(s.lastTurnModelMs, 1_000, "idle reconciliation closes the active provider interval");
533 eq(s.lastTurnOutputEstimated, true, "idle reconciliation preserves the estimated marker");
534 } finally {
535 Date.now = originalNow;
536 }
537 }
538
539 // --- 6. TPS telemetry follows executor output-token semantics and retry intervals ---
540 {
541 const originalNow = Date.now;
542 let now = 30_000;
543 Date.now = () => now;
544 try {
545 let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
546 now = 30_100;
547 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
548 now = 31_100;
549 s = ev(s, { kind: "usage", usage: {
550 promptTokens: 100,
551 completionTokens: 20,
552 reasoningTokens: 10,
553 totalTokens: 120,
554 cacheHitTokens: 0,
555 cacheMissTokens: 100,
556 source: "executor",
557 } } as WireEvent);
558 s = ev(s, { kind: "turn_done" } as WireEvent);
559 eq(s.lastTurnOutputTokens, 20, "reasoning tokens are not added twice to completed TPS");
560 eq(s.lastTurnModelMs, 1_000, "reasoning usage preserves the executor output interval");
561
562 now = 32_000;
563 s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
564 now = 32_100;
565 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
566 now = 32_500;
567 s = ev(s, { kind: "usage", usage: {
568 promptTokens: 50,
569 completionTokens: 100,
570 totalTokens: 150,
571 cacheHitTokens: 0,
572 cacheMissTokens: 50,
573 source: "subagent",
574 } } as WireEvent);
575 now = 33_100;
576 s = ev(s, { kind: "usage", usage: {
577 promptTokens: 10,
578 completionTokens: 10,
579 totalTokens: 20,
580 cacheHitTokens: 0,
581 cacheMissTokens: 10,
582 source: "executor",
583 } } as WireEvent);
584 s = ev(s, { kind: "turn_done" } as WireEvent);
585 eq(s.lastTurnOutputTokens, 10, "subagent usage is excluded from executor TPS tokens");
586 eq(s.lastTurnModelMs, 1_000, "subagent usage does not close the executor output interval");
587
588 now = 34_000;
589 s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
590 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "tps-a1", action: "begin", attempt: 1, max: 2 } } as WireEvent);
591 now = 34_100;
592 s = ev(s, { kind: "text", text: "abcdefgh" } as WireEvent);
593 now = 35_100;
594 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "tps-a1", action: "discard", attempt: 1, max: 2 } } as WireEvent);
595 now = 38_000;
596 s = ev(s, { kind: "stream_attempt", streamAttempt: { id: "tps-a2", action: "begin", attempt: 2, max: 2 } } as WireEvent);
597 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
598 now = 39_000;
599 s = ev(s, { kind: "turn_done" } as WireEvent);
600 eq(s.lastTurnModelMs, 2_000, "discarded sampling attempts exclude retry backoff from TPS");
601 } finally {
602 Date.now = originalNow;
603 }
604 }
605
606 // --- 7. lastRequestTps pairs the closed interval with the usage tokens ---
607 {
608 const originalNow = Date.now;
609 let now = 50_000;
610 Date.now = () => now;
611 try {
612 let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
613 now = 50_100;
614 s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
615 now = 51_100;
616 // The message event closes the interval BEFORE the usage event arrives.
617 s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
618 now = 51_200;
619 s = ev(s, { kind: "usage", usage: {
620 promptTokens: 130, completionTokens: 30, totalTokens: 160,
621 contextPromptTokens: 100, contextCompletionTokens: 20,
622 cacheHitTokens: 0, cacheMissTokens: 100, source: "executor",
623 } } as WireEvent);
624 eq(s.lastRequestTps, 20, "sampling recovery pairs the interval with latest-attempt tokens");
625
626 now = 52_000;
627 s = ev(s, { kind: "text", text: "more" } as WireEvent);
628 now = 52_050;
629 s = ev(s, { kind: "message", text: "more" } as WireEvent);
630 now = 52_100;
631 s = ev(s, { kind: "usage", usage: {
632 promptTokens: 5, completionTokens: 50, totalTokens: 55,
633 cacheHitTokens: 0, cacheMissTokens: 5, source: "subagent",
634 } } as WireEvent);
635 eq(s.lastRequestTps, 20, "non-executor usage neither computes nor consumes the pending interval");
636 now = 52_300;
637 s = ev(s, { kind: "usage", usage: {
638 promptTokens: 10, completionTokens: 30, totalTokens: 40,
639 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
640 } } as WireEvent);
641 eq(s.lastRequestTps, null, "intervals under the 500ms gate clear stale request TPS");
642
643 now = 53_000;
644 s = ev(s, { kind: "text", text: "second" } as WireEvent);
645 now = 54_000;
646 s = ev(s, { kind: "message", text: "second" } as WireEvent);
647 now = 54_100;
648 s = ev(s, { kind: "usage", usage: {
649 promptTokens: 10, completionTokens: 30, totalTokens: 40,
650 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
651 } } as WireEvent);
652 eq(s.lastRequestTps, 30, "a later executor usage refreshes the request TPS");
653
654 now = 55_000;
655 s = ev(s, { kind: "text", text: "direct" } as WireEvent);
656 now = 56_000;
657 s = ev(s, { kind: "usage", usage: {
658 promptTokens: 10, completionTokens: 40, totalTokens: 50,
659 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
660 } } as WireEvent);
661 eq(s.lastRequestTps, 40, "usage measures an interval still open at arrival");
662
663 now = 57_000;
664 s = ev(s, { kind: "turn_done" } as WireEvent);
665 eq(s.lastRequestTps, 40, "request TPS persists across turn boundaries");
666
667 now = 58_000;
668 s = ev(s, { kind: "turn_started" } as WireEvent);
669 now = 58_100;
670 s = ev(s, { kind: "usage", usage: {
671 promptTokens: 10, completionTokens: 8, totalTokens: 18,
672 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
673 } } as WireEvent);
674 eq(s.lastRequestTps, null, "usage without a provider interval clears stale request TPS");
675 now = 58_200;
676 s = ev(s, { kind: "tool_dispatch", tool: { id: "final-only", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
677 eq(s.lastRequestTps, null, "a final-only tool dispatch after usage cannot resurrect stale TPS");
678
679 now = 59_000;
680 s = ev(s, { kind: "text", text: "toolcall" } as WireEvent);
681 now = 60_000;
682 s = ev(s, { kind: "tool_dispatch", tool: { id: "t1", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
683 now = 60_100;
684 s = ev(s, { kind: "usage", usage: {
685 promptTokens: 10, completionTokens: 25, totalTokens: 35,
686 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
687 } } as WireEvent);
688 eq(s.lastRequestTps, 25, "tool_dispatch closes the interval the next executor usage pairs with");
689
690 now = 61_000;
691 s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", readOnly: false, partial: true, argChars: 600 } } as WireEvent);
692 now = 62_000;
693 s = ev(s, { kind: "usage", usage: {
694 promptTokens: 10, completionTokens: 30, totalTokens: 40,
695 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
696 } } as WireEvent);
697 eq(s.lastRequestTps, 30, "usage closes the interval started by a partial tool dispatch");
698 now = 62_100;
699 s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
700 eq(s.lastRequestTps, 30, "the later full tool dispatch preserves the measured request TPS");
701
702 now = 63_000;
703 s = ev(s, { kind: "text", text: "closing" } as WireEvent);
704 now = 64_000;
705 s = ev(s, { kind: "message", text: "closing" } as WireEvent);
706 now = 64_100;
707 s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", readOnly: false, partial: true, argChars: 300 } } as WireEvent);
708 now = 64_700;
709 s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
710 now = 64_800;
711 s = ev(s, { kind: "usage", usage: {
712 promptTokens: 10, completionTokens: 30, totalTokens: 40,
713 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
714 } } as WireEvent);
715 // The partial restart begins a new interval; the full dispatch closes it
716 // and overwrites the message-stashed pending with its own (≥500ms) tail.
717 eq(s.lastRequestTps, 50, "a full dispatch overwrites a message-stashed pending with its own tail close");
718
719 now = 65_000;
720 s = ev(s, { kind: "text", text: "slow" } as WireEvent);
721 now = 68_000;
722 s = ev(s, { kind: "message", text: "slow" } as WireEvent);
723 now = 68_100;
724 s = ev(s, { kind: "usage", usage: {
725 promptTokens: 10, completionTokens: 1, totalTokens: 11,
726 cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
727 } } as WireEvent);
728 eq(s.lastRequestTps, 1 / 3, "slow measurable requests retain their raw sub-one TPS");
729 } finally {
730 Date.now = originalNow;
731 }
732 }
733
734 // --- 8. context occupancy uses prompt tokens and keeps legacy fallback semantics ---
735 {
736 let s = ev({
737 ...initialState,
738 running: true,
739 turnActive: true,
740 context: { ...initialState.context, window: 1_000 },
741 }, { kind: "usage", usage: {
742 promptTokens: 500,
743 completionTokens: 20,
744 totalTokens: 520,
745 contextPromptTokens: 0,
746 contextCompletionTokens: 20,
747 source: "executor",
748 } } as WireEvent);
749 eq(s.context.used, 500, "completion-only latest usage falls back to aggregate prompt occupancy");
750
751 s = ev({ ...s, running: true, turnActive: true }, { kind: "usage", usage: {
752 promptTokens: 700,
753 completionTokens: 30,
754 totalTokens: 730,
755 contextPromptTokens: 450,
756 contextCompletionTokens: 0,
757 source: "executor",
758 } } as WireEvent);
759 eq(s.context.used, 450, "latest-attempt prompt occupancy excludes completion tokens");
760 }
761
762 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
763 if (failed > 0) process.exit(1);
764
764 lines TYPESCRIPT