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