返回 DeepSeek-Reasonix
use-controller-meta.test.ts
根目录 / desktop / frontend / src / __tests__ / use-controller-meta.test.ts
1 // Run: tsx src/__tests__/use-controller-meta.test.ts
2
3 import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText, type Item } from "../lib/useController";
4 import { parseTodos } from "../lib/tools";
5 import { resolveTodoPanelTodos } from "../lib/todoVisibility";
6 import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
7
8 type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
9
10 let passed = 0;
11 let failed = 0;
12
13 function eq(a: unknown, b: unknown, label: string) {
14 if (a === b) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
19 failed += 1;
20 }
21 }
22
23 function ok(value: boolean, label: string) {
24 if (value) {
25 process.stdout.write(` PASS ${label}\n`);
26 passed += 1;
27 } else {
28 process.stdout.write(` FAIL ${label}\n`);
29 failed += 1;
30 }
31 }
32
33 function meta(overrides: Partial<Meta> = {}): Meta {
34 return {
35 label: "DeepSeek-R1",
36 ready: true,
37 eventChannel: "events",
38 cwd: "/repo",
39 workspaceRoot: "/repo",
40 workspaceName: "repo",
41 workspacePath: "/repo",
42 gitBranch: "main",
43 imageInputEnabled: true,
44 autoApproveTools: false,
45 bypass: false,
46 collaborationMode: "normal",
47 toolApprovalMode: "ask",
48 tokenMode: "full",
49 goal: "",
50 goalStatus: "stopped",
51 ...overrides,
52 };
53 }
54
55 function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
56 return {
57 id: "tab-1",
58 scope: "project",
59 workspaceRoot: "/repo",
60 workspaceName: "repo",
61 workspacePath: "/repo",
62 gitBranch: "main",
63 topicId: "topic-1",
64 topicTitle: "Topic",
65 label: "DeepSeek-R1",
66 ready: true,
67 running: false,
68 mode: "normal",
69 collaborationMode: "normal",
70 toolApprovalMode: "ask",
71 tokenMode: "full",
72 goal: "",
73 goalStatus: "stopped",
74 active: true,
75 cwd: "/repo",
76 ...overrides,
77 } as TabMeta;
78 }
79
80 function usage(source: string): WireUsage {
81 return {
82 promptTokens: 100,
83 completionTokens: 20,
84 totalTokens: 120,
85 cacheHitTokens: 80,
86 cacheMissTokens: 20,
87 sessionCacheHitTokens: 80,
88 sessionCacheMissTokens: 20,
89 source,
90 cost: 0.001,
91 currency: "$",
92 };
93 }
94
95 console.log("\nuse controller meta");
96
97 {
98 eq(
99 modelSwitchNoticeText("active work is still running; running=false; pending_prompt=false; background_jobs=2; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
100 "The model cannot change while 2 background jobs are running. Open Background jobs in the status bar to stop them.",
101 "model busy guard names the background-job blocker",
102 );
103 eq(
104 effortSwitchNoticeText("active work is still running; running=true; pending_prompt=false; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
105 "Reasoning effort cannot change while the current answer is running. Stop it first.",
106 "effort busy guard names the running-answer blocker",
107 );
108 eq(
109 tokenModeSwitchNoticeText("active work is still running; running=true; pending_prompt=true; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
110 "Work mode cannot change while a prompt is waiting for your response. Handle it first.",
111 "work mode busy guard prioritizes the pending prompt blocker",
112 );
113 eq(
114 modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
115 "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
116 "model busy guard is localized",
117 );
118 eq(
119 modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
120 "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
121 "model lease conflict explains the safe path",
122 );
123 eq(
124 modelSwitchNoticeText("workspace is still starting"),
125 "This session is still starting. Try changing models again in a moment.",
126 "model startup race asks the user to retry later",
127 );
128 eq(
129 modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
130 "The current session changed while switching models. Try once more.",
131 "model tab race asks the user to retry",
132 );
133 eq(
134 modelSwitchNoticeText('unknown model "missing"'),
135 'Unknown model "missing".',
136 "model unknown error is localized",
137 );
138 eq(
139 modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
140 'Model "other/other-model" is unavailable because provider "other" is not added.',
141 "model provider access error is localized",
142 );
143 }
144
145 {
146 eq(
147 effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
148 "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
149 "effort busy guard is worded as temporary",
150 );
151 eq(
152 effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
153 "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
154 "effort lease conflict explains the safe path",
155 );
156 eq(
157 effortSwitchNoticeText("workspace is still starting"),
158 "This session is still starting. Try changing reasoning effort again in a moment.",
159 "effort startup race asks the user to retry later",
160 );
161 eq(
162 effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
163 "The current session changed while switching reasoning effort. Try once more.",
164 "effort tab race asks the user to retry",
165 );
166 eq(
167 effortSwitchNoticeText("unknown model \"missing\""),
168 "Reasoning effort switch failed: unknown model \"missing\"",
169 "effort true failure keeps the underlying error",
170 );
171 }
172
173 {
174 eq(
175 tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
176 "Work mode cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
177 "work mode busy guard is localized",
178 );
179 eq(
180 tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
181 "The current session changed while switching work mode. Try once more.",
182 "work mode tab race asks the user to retry",
183 );
184 }
185
186 {
187 eq(
188 localizedBackendNoticeText("Session autosave failed: disk full"),
189 "Session autosave failed: disk full",
190 "backend autosave notice is localized through the active dictionary",
191 );
192 eq(
193 localizedBackendNoticeText("Session save failed before changing model: disk full"),
194 "Session save failed before changing models: disk full",
195 "backend save-before-action notice localizes the action",
196 );
197 eq(
198 localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
199 'Model "old/model" is no longer available; switched to new/model.',
200 "backend model fallback notice is localized",
201 );
202 eq(
203 localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
204 "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
205 "legacy recovery branch notice can be normalized without exposing internal branch id",
206 );
207 eq(
208 localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
209 "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
210 "recovery copy notice can be normalized",
211 );
212 eq(
213 localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
214 "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
215 "legacy repeated recovery conflict notice can be normalized",
216 );
217 eq(
218 localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
219 "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
220 "repeated recovery conflict notice can be normalized",
221 );
222 eq(
223 localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
224 "The session changed on disk, so Reasonix adopted the newer transcript.",
225 "adopted transcript notice can be normalized",
226 );
227 eq(
228 localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
229 "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
230 "covered adopted transcript notice can be normalized",
231 );
232 eq(
233 localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
234 "The assistant answered before taking action; asking it to use the required tools.",
235 "canonical backend notice is routed through the locale dictionary",
236 );
237 eq(
238 localizedBackendNoticeText("background export failed: needs attention"),
239 "Background export needs attention.",
240 "dynamic background job notice is user-facing",
241 );
242 }
243
244 {
245 eq(
246 localizedNoticeText("Task status needs one more check (reworded backend copy).", "final_readiness"),
247 "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
248 "a stable notice code localizes the main copy even after backend copy edits",
249 );
250 eq(
251 localizedNoticeText("reworded workspace contention copy", "workspace_lease"),
252 "Another Delivery session is writing to this workspace; this session will continue automatically when it is safe.",
253 "workspace lease contention uses its stable localized notice code",
254 );
255 eq(
256 localizedNoticeText("reworded cancelled-turn copy", "cancelled_turn_display"),
257 "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.",
258 "cancelled turn history explains the model-context boundary",
259 );
260 eq(
261 localizedNoticeText("reworded unapplied copy\nuse plan B", "unapplied_steer"),
262 "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:\nuse plan B",
263 "unapplied steer keeps the user's guidance while localizing the warning",
264 );
265 eq(
266 localizedNoticeText("reworded recovery copy", "session_recovery_forked"),
267 "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
268 "session recovery fork localization uses its stable notice code",
269 );
270 eq(
271 localizedNoticeText("reworded covered adoption", "session_recovery_adopted_covered"),
272 "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
273 "covered session adoption localization uses its stable notice code",
274 );
275 eq(
276 localizedNoticeText("reworded depth cap", "session_recovery_depth_cap"),
277 "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
278 "session recovery depth-cap localization uses its stable notice code",
279 );
280 eq(
281 localizedNoticeText("Tool round limit reached; asking the assistant to summarize progress.", "unknown_future_code"),
282 "Tool round limit reached; asking the assistant to summarize progress.",
283 "an unknown notice code falls back to exact-text matching",
284 );
285 eq(
286 localizedNoticeText("some free-form backend message"),
287 "some free-form backend message",
288 "a codeless unmatched notice keeps its raw text",
289 );
290 }
291
292 {
293 let s = reducer(initialState, {
294 type: "event",
295 e: { kind: "notice", level: "warn", code: "session_recovery_depth_cap", text: "reworded recovery maintenance" },
296 });
297 s = reducer(s, {
298 type: "event",
299 e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
300 });
301 const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
302 eq(recoveryNotices.length, 0, "recovery conflict notices stay silent in the live transcript");
303 eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
304
305 s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
306 s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
307 const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
308 eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
309 }
310
311 {
312 const quietLifecycleMessages = [
313 { level: "info", text: "guardian enabled · model=guardian-test" },
314 { level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
315 { level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
316 { level: "info", text: "settings applied: session refreshed after the lease was released" },
317 { level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
318 ] as const;
319 let s = initialState;
320 for (const message of quietLifecycleMessages) {
321 s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
322 }
323 eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
324 eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
325
326 const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
327 const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
328 eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
329 eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
330 }
331
332 {
333 const history: HistoryMessage[] = [
334 { role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
335 { role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
336 { role: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
337 { role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
338 { role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
339 { role: "user", content: "continue" },
340 ];
341 const hydrated = historyMessagesToItems(history, "h");
342 const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
343 const lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
344 const users = hydrated.items.filter((item) => item.kind === "user");
345 eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
346 eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
347 eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
348 eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
349 }
350
351 {
352 const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
353 const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
354 eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
355 }
356
357 {
358 const hydrated = historyMessagesToItems([{ role: "notice", level: "info", content: "Tool round limit reached (reworded backend copy).", code: "tool_budget" }], "h");
359 const notice = hydrated.items.find((item) => item.kind === "notice");
360 eq(
361 notice?.kind === "notice" && notice.text,
362 "Tool round limit reached; asking the assistant to summarize progress.",
363 "history notices localize by stable code when the replayed record carries one",
364 );
365 }
366
367 {
368 const hydrated = historyMessagesToItems([
369 { role: "user", content: "finish" },
370 { role: "assistant", content: "done", reasoning: "worked", workDurationMs: 24_000 },
371 ], "h");
372 const assistant = hydrated.items.find((item) => item.kind === "assistant");
373 eq(assistant?.kind === "assistant" && assistant.workDurationMs, 24_000, "history restores persisted turn work duration");
374 }
375
376 {
377 eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
378 eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
379 eq(sameMeta(meta({ workspacePath: "/repo" }), meta({ workspacePath: "/other" })), false, "workspace path changes invalidate meta equality");
380 eq(sameMeta(meta({ gitBranch: "main" }), meta({ gitBranch: "feature" })), false, "git branch changes invalidate meta equality");
381 eq(sameMeta(meta({ imageInputEnabled: true }), meta({ imageInputEnabled: false })), false, "image input capability changes invalidate meta equality");
382 eq(
383 sameMeta(
384 meta({ canonicalTodos: [{ content: "Ship", status: "in_progress" }] }),
385 meta({ canonicalTodos: [{ content: "Ship", status: "completed" }] }),
386 ),
387 false,
388 "canonical todo progress invalidates meta equality",
389 );
390 eq(
391 sameMeta(meta({ canonicalTodos: [] }), meta({ canonicalTodos: [] })),
392 true,
393 "equivalent empty canonical todo lists keep meta stable",
394 );
395 }
396
397 {
398 const preserved = metaFromTab(tab({ toolApprovalMode: "" }), meta({ toolApprovalMode: "auto", autoApproveTools: false }));
399 eq(preserved.toolApprovalMode, "auto", "blank tab snapshot preserves explicit auto approval mode");
400 eq(preserved.autoApproveTools, false, "blank tab snapshot does not silently resurrect yolo approval");
401 const todos = [{ content: "Keep task state", status: "in_progress" }];
402 const withTodos = metaFromTab(tab(), meta({ canonicalTodos: todos }));
403 eq(withTodos.canonicalTodos, todos, "optimistic tab metadata preserves canonical todos for the same session");
404 }
405
406 {
407 const before = meta({ canonicalTodos: [{ content: "Ship", status: "in_progress" }] });
408 const completed = meta({ canonicalTodos: [{ content: "Ship", status: "completed" }] });
409 const updated = reducer({ ...initialState, meta: before }, { type: "meta", meta: completed });
410 eq(updated.meta?.canonicalTodos?.[0]?.status, "completed", "meta refresh applies canonical todo progress");
411
412 const reset = reducer(updated, { type: "reset" });
413 eq(reset.meta?.canonicalTodos, undefined, "session reset clears canonical todos from the previous session");
414
415 const cleared = reducer(reset, { type: "meta", meta: meta({ canonicalTodos: [] }) });
416 eq(cleared.meta?.canonicalTodos?.length, 0, "authoritative empty canonical todos survive meta refresh");
417 }
418
419 {
420 const delayedLiveMeta = meta({
421 canonicalTodos: [
422 { content: "Inspect the report", status: "completed" },
423 { content: "Ship the fix", status: "in_progress" },
424 ],
425 });
426 const hydrated = reducer({ ...initialState, meta: delayedLiveMeta }, { type: "meta", meta: delayedLiveMeta });
427 const noLiveTodo = hydrated.items.find(
428 (item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.name === "todo_write",
429 );
430 eq(
431 resolveTodoPanelTodos(hydrated.meta?.canonicalTodos, noLiveTodo ? parseTodos(noLiveTodo.args) : undefined),
432 delayedLiveMeta.canonicalTodos,
433 "panel uses fresh Meta todos while the live todo_write event is delayed",
434 );
435
436 const staleMeta = meta({
437 canonicalTodos: [
438 { content: "Inspect the report", status: "in_progress" },
439 { content: "Ship the fix", status: "pending" },
440 ],
441 });
442 const liveArgs = JSON.stringify({
443 todos: [
444 { content: "Inspect the report", status: "completed" },
445 { content: "Ship the fix", status: "in_progress" },
446 ],
447 });
448 let liveState = reducer({ ...initialState, meta: staleMeta }, { type: "event", e: { kind: "turn_started" } });
449 liveState = reducer(liveState, {
450 type: "event",
451 e: { kind: "tool_dispatch", tool: { id: "todo-live", name: "todo_write", args: liveArgs, readOnly: true } },
452 });
453 liveState = reducer(liveState, {
454 type: "event",
455 e: { kind: "tool_result", tool: { id: "todo-live", name: "todo_write", readOnly: true, output: "Todos updated" } },
456 });
457 const liveTodo = liveState.items.find(
458 (item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.name === "todo_write",
459 );
460 eq(
461 JSON.stringify(resolveTodoPanelTodos(liveState.meta?.canonicalTodos, liveTodo ? parseTodos(liveTodo.args) : undefined)),
462 JSON.stringify(JSON.parse(liveArgs).todos),
463 "panel switches to the live todo_write snapshot after it arrives",
464 );
465 }
466
467 {
468 const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
469 const rendered = reducer(started, { type: "event", e: { kind: "message", text: "done", reasoning: "" } });
470 eq(rendered.running, true, "message without turn_done leaves local runtime marked running");
471 eq(rendered.turnActive, true, "message without turn_done still belongs to an active turn");
472 eq(rendered.live, undefined, "final message closes the live stream before turn_done");
473 eq(shouldReconcileStaleTurn(rendered, 1_000, 31_000), true, "stale completed stream still reconciles missed turn_done");
474 eq(shouldReconcileStaleTurn(rendered, 1_000, 20_000), false, "fresh completed stream waits before reconciling");
475 eq(shouldReconcileStaleTurn({ ...rendered, turnActive: false }, 1_000, 31_000), false, "local pending send before turn_started does not reconcile");
476 }
477
478 {
479 const originalNow = Date.now;
480 let now = 1_000;
481 Date.now = () => now;
482 try {
483 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
484 now = 1_200;
485 s = reducer(s, { type: "event", e: { kind: "reasoning", reasoning: "plan" } });
486 eq(s.live?.reasoningStartedAt, 1_200, "first reasoning delta records a reasoning start time");
487 now = 3_700;
488 s = reducer(s, { type: "event", e: { kind: "text", text: "answer" } });
489 eq(s.live?.reasoningComplete, true, "first answer token marks reasoning complete");
490 eq(s.live?.reasoningCompletedAt, 3_700, "first answer token records reasoning completion time");
491 now = 4_200;
492 s = reducer(s, { type: "event", e: { kind: "turn_done" } });
493 const assistant = s.items.find((item) => item.kind === "assistant");
494 eq(assistant?.kind === "assistant" && assistant.reasoningDurationMs, 2_500, "turn_done persists the live reasoning duration");
495 eq(assistant?.kind === "assistant" && assistant.workDurationMs, 3_200, "turn_done persists the full turn wall-clock duration");
496 } finally {
497 Date.now = originalNow;
498 }
499 }
500
501 {
502 const originalNow = Date.now;
503 let now = 5_000;
504 Date.now = () => now;
505 try {
506 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
507 now = 5_100;
508 s = reducer(s, { type: "event", e: { kind: "reasoning", reasoning: "diagnose" } });
509 now = 6_400;
510 s = reducer(s, { type: "event", e: { kind: "message", text: "done", reasoning: "diagnose" } });
511 const assistant = s.items.find((item) => item.kind === "assistant");
512 eq(assistant?.kind === "assistant" && assistant.reasoningDurationMs, 1_300, "final message records reasoning duration when no text delta arrived");
513 eq(assistant?.kind === "assistant" && assistant.workDurationMs, 1_400, "final message records cumulative turn work duration before turn_done");
514 eq(s.live, undefined, "final message still closes live reasoning state");
515 } finally {
516 Date.now = originalNow;
517 }
518 }
519
520 {
521 const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
522 const waiting = reducer(started, { type: "event", e: { kind: "approval_request", approval: { id: "1", tool: "bash", subject: "go test" } } });
523 eq(waiting.running, true, "approval prompt keeps the turn running");
524 eq(waiting.pendingPrompt, true, "approval prompt marks pendingPrompt");
525 eq(waiting.cancellable, true, "approval prompt remains cancellable");
526 ok(typeof waiting.promptWaitStartedAt === "number" && waiting.promptWaitStartedAt > 0, "approval_request starts tab-scoped prompt wait");
527
528 const canceling = reducer(waiting, { type: "cancel_requested" });
529 eq(canceling.approval, undefined, "cancel_requested clears approval prompt locally");
530 eq(canceling.pendingPrompt, false, "cancel_requested clears pendingPrompt locally");
531 eq(canceling.cancelRequested, true, "cancel_requested marks cancelling");
532 eq(canceling.running, true, "cancel_requested waits for backend turn_done before idling");
533 eq(canceling.promptWaitStartedAt, undefined, "cancel_requested closes the open prompt wait");
534 ok((canceling.turnWaitAccumMs ?? 0) >= 0, "cancel_requested accumulates closed wait into the turn");
535 const stalePrompt = reducer(canceling, { type: "event", e: { kind: "approval_request", approval: { id: "late", tool: "bash", subject: "sleep" } } });
536 eq(stalePrompt.approval, undefined, "late approval after cancel_requested stays hidden");
537
538 const backgroundOnly = reducer(initialState, { type: "backend_status", running: false, backgroundJobs: 1, cancellable: false });
539 eq(backgroundOnly.running, false, "background jobs alone do not make the composer runstatus active");
540 eq(backgroundOnly.backgroundJobs, 1, "backend_status stores background job count");
541 eq(backgroundOnly.cancellable, false, "background jobs alone are not foreground-cancellable");
542
543 const omittedCancellableBackgroundOnly = reducer(initialState, { type: "backend_status", running: true, backgroundJobs: 1 });
544 eq(omittedCancellableBackgroundOnly.running, false, "missing cancellable does not promote background-only metadata");
545 eq(omittedCancellableBackgroundOnly.cancellable, false, "missing cancellable stays non-cancellable with background-only metadata");
546 eq(foregroundRunningFromRuntimeMeta({ running: true }), true, "legacy running metadata remains foreground-running");
547 eq(foregroundRunningFromRuntimeMeta({ running: true, pendingPrompt: true, backgroundJobs: 1 }), true, "pending prompts remain foreground-running");
548 eq(foregroundRunningFromRuntimeMeta({ running: true, backgroundJobs: 1 }), false, "background jobs without cancellable are background-only");
549 }
550
551 // User-wait is tab-scoped: approval_request starts the clock even while the tab
552 // is not rendered; clearApproval folds the open interval into turnWaitAccumMs.
553 // workDurationMs excludes that wait so background suspension is not model work.
554 {
555 const originalNow = Date.now;
556 let now = 10_000;
557 Date.now = () => now;
558 try {
559 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
560 eq(s.turnStartAt, 10_000, "turn starts at t0");
561 eq(s.turnWaitAccumMs, 0, "fresh turn has no wait accum");
562 now = 12_000;
563 s = reducer(s, {
564 type: "event",
565 e: { kind: "approval_request", approval: { id: "bg-1", tool: "bash", subject: "sleep 1" } },
566 });
567 eq(s.promptWaitStartedAt, 12_000, "approval_request records wait start at event time");
568 now = 15_000;
569 // Tab stays off-screen for 3s; controller still counts via open interval.
570 eq(currentTurnWaitMs(s, now), 3_000, "open wait counts while tab is backgrounded");
571 s = reducer(s, { type: "clearApproval" });
572 eq(s.promptWaitStartedAt, undefined, "clearApproval closes the open wait");
573 eq(s.turnWaitAccumMs, 3_000, "clearApproval accumulates background wait into the turn");
574 now = 16_000;
575 s = reducer(s, { type: "event", e: { kind: "message", text: "done", reasoning: "" } });
576 s = reducer(s, { type: "event", e: { kind: "turn_done" } });
577 const assistant = s.items.find((item) => item.kind === "assistant");
578 // Wall 6s (10k→16k) − 3s wait = 3s model work.
579 eq(assistant?.kind === "assistant" && assistant.workDurationMs, 3_000, "workDurationMs excludes user-wait including background wait");
580 } finally {
581 Date.now = originalNow;
582 }
583 }
584
585 {
586 const restoredContext = reducer(initialState, {
587 type: "context",
588 context: {
589 used: 42,
590 window: 200,
591 sessionTokens: 120,
592 compactRatio: 0.5,
593 sessionCost: 0.012,
594 sessionCurrency: "$",
595 cacheHitTokens: 80,
596 cacheMissTokens: 20,
597 },
598 });
599 const reset = reducer(restoredContext, { type: "reset" });
600 eq(reset.context.used, 0, "reset clears context used tokens");
601 eq(reset.context.window, 200, "reset preserves context window");
602 eq(reset.context.sessionTokens, 0, "reset clears context session tokens");
603 eq(reset.context.cacheHitTokens, undefined, "reset clears restored cache hit tokens");
604 eq(reset.context.cacheMissTokens, undefined, "reset clears restored cache miss tokens");
605 eq(reset.context.sessionCost, undefined, "reset clears restored context session cost");
606 eq(reset.sessionCost, 0, "reset clears restored session cost state");
607 eq(reset.sessionCurrency, "¥", "reset restores default session currency");
608 }
609
610 {
611 const idleExecutor = reducer(
612 { ...initialState, context: { used: 0, window: 200, sessionTokens: 0 } },
613 { type: "event", e: { kind: "usage", usage: usage("executor") } },
614 );
615 eq(idleExecutor.sessionTokens, 0, "executor usage outside a turn does not inflate session tokens");
616 eq(idleExecutor.context.used, 0, "executor usage outside a turn does not refresh context used tokens");
617
618 const idleHelper = reducer(initialState, { type: "event", e: { kind: "usage", usage: usage("classifier") } });
619 eq(idleHelper.sessionTokens, 0, "helper usage outside a turn does not inflate session tokens");
620 eq(idleHelper.sessionCost, 0, "helper usage outside a turn does not inflate session cost");
621
622 const pendingClassifier = reducer(
623 { ...initialState, running: true, context: { used: 0, window: 200, sessionTokens: 0 } },
624 { type: "event", e: { kind: "usage", usage: usage("classifier") } },
625 );
626 eq(pendingClassifier.sessionTokens, 120, "classifier usage while send is running counts toward session tokens");
627 eq(pendingClassifier.sessionCost, 0.001, "classifier usage while send is running counts toward session cost");
628 eq(pendingClassifier.context.used, 0, "classifier usage while send is running does not refresh context used tokens");
629
630 const active = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
631 const activeHelper = reducer(active, { type: "event", e: { kind: "usage", usage: usage("subagent") } });
632 eq(activeHelper.sessionTokens, 120, "helper usage inside a turn still counts toward session tokens");
633 eq(activeHelper.sessionCost, 0.001, "helper usage inside a turn still counts toward session cost");
634 eq(activeHelper.usage, undefined, "helper usage inside a turn does not become displayed latest usage");
635
636 const plannerFirst = reducer(active, { type: "event", e: { kind: "usage", usage: usage("planner") } });
637 eq(plannerFirst.sessionTokens, 120, "planner usage inside a turn still counts toward session tokens");
638 eq(plannerFirst.usage, undefined, "planner usage does not fill the single displayed usage slot");
639
640 const activeExecutor = reducer(active, { type: "event", e: { kind: "usage", usage: usage("executor") } });
641 const afterCompaction = reducer(activeExecutor, { type: "event", e: { kind: "usage", usage: usage("compaction") } });
642 eq(afterCompaction.usage?.source, "executor", "compaction usage does not overwrite displayed executor usage");
643 eq(afterCompaction.sessionTokens, 240, "compaction usage still contributes to session token totals");
644 }
645
646 {
647 let s = reducer(initialState, { type: "user", text: "first", seq: 0 });
648 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
649 s = reducer(s, { type: "event", e: { kind: "notice", level: "info", text: "runtime notice" } });
650 s = reducer(s, { type: "event", e: { kind: "turn_done" } });
651 const merged = reducer(s, {
652 type: "history_checkpoint_turns",
653 turns: [0],
654 });
655 const user = merged.items.find((item) => item.kind === "user");
656 const notice = merged.items.find((item) => item.kind === "notice" && item.text === "runtime notice");
657 eq(user?.kind === "user" && user.checkpointTurn, 0, "turn_done checkpoint merge stamps user turn zero");
658 eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
659 }
660
661 {
662 const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
663 const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
664 eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
665 }
666
667 {
668 let s = reducer(initialState, {
669 type: "history_page",
670 mode: "replace",
671 page: {
672 messages: [
673 { role: "user", content: "recent prompt" },
674 { role: "assistant", content: "recent answer" },
675 ],
676 startTurn: 60,
677 endTurn: 61,
678 totalTurns: 61,
679 hasOlder: true,
680 },
681 });
682 eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
683 eq(s.historyStartTurn, 60, "history page stores the older cursor");
684 eq(s.historyHasOlder, true, "history page records older availability");
685 const checkpointed = reducer(s, {
686 type: "history_checkpoint_turns",
687 turns: Array.from({ length: 61 }, (_, index) => index + 1000),
688 });
689 const recentUser = checkpointed.items.find((item) => item.kind === "user" && item.text === "recent prompt");
690 eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged checkpoint merge uses the window start turn");
691 s = reducer(s, { type: "history_older_start" });
692 eq(s.historyOlderLoading, true, "older history request marks loading");
693 s = reducer(s, {
694 type: "history_page",
695 mode: "prepend",
696 page: {
697 messages: [
698 { role: "user", content: "older prompt" },
699 { role: "assistant", content: "older answer" },
700 ],
701 startTurn: 0,
702 endTurn: 1,
703 totalTurns: 61,
704 hasOlder: false,
705 },
706 });
707 const users = s.items.filter((item) => item.kind === "user");
708 eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
709 eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
710 eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
711 eq(s.historyOlderLoading, false, "older history clears loading");
712 }
713
714 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
715 if (failed > 0) process.exit(1);
716
716 lines TYPESCRIPT