| 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 |