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