| 1 | // Run: tsx src/__tests__/use-controller-cancel-reconcile.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { useController } from "../lib/useController"; |
| 7 | import type { AppBindings } from "../lib/bridge"; |
| 8 | import type { ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 9 | import { historySliceFromMessages } from "./mockHistorySlice"; |
| 10 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 11 | |
| 12 | let passed = 0; |
| 13 | let failed = 0; |
| 14 | |
| 15 | function ok(value: boolean, label: string) { |
| 16 | if (value) { |
| 17 | process.stdout.write(` PASS ${label}\n`); |
| 18 | passed += 1; |
| 19 | } else { |
| 20 | process.stdout.write(` FAIL ${label}\n`); |
| 21 | failed += 1; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | function eq(actual: unknown, expected: unknown, label: string) { |
| 26 | ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`); |
| 27 | } |
| 28 | |
| 29 | function flushPromises(): Promise<void> { |
| 30 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 31 | } |
| 32 | |
| 33 | async function waitFor(label: string, predicate: () => boolean) { |
| 34 | for (let attempt = 0; attempt < 50; attempt += 1) { |
| 35 | await act(async () => { |
| 36 | await flushPromises(20); |
| 37 | }); |
| 38 | if (predicate()) return; |
| 39 | } |
| 40 | throw new Error(`timed out waiting for ${label}`); |
| 41 | } |
| 42 | |
| 43 | function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta { |
| 44 | return { |
| 45 | id: "tab-a", |
| 46 | scope: "project", |
| 47 | workspaceRoot: "/repo", |
| 48 | workspaceName: "repo", |
| 49 | workspacePath: "/repo", |
| 50 | topicId: "topic-a", |
| 51 | topicTitle: "General", |
| 52 | session: { hostId: "local", sessionId: "canonical-a" }, |
| 53 | label: "model", |
| 54 | ready: true, |
| 55 | running: false, |
| 56 | cancellable: false, |
| 57 | mode: "normal", |
| 58 | toolApprovalMode: "ask", |
| 59 | tokenMode: "full", |
| 60 | active: true, |
| 61 | cwd: "/repo", |
| 62 | ...overrides, |
| 63 | }; |
| 64 | } |
| 65 | |
| 66 | function meta(): Meta { |
| 67 | return { |
| 68 | label: "model", |
| 69 | ready: true, |
| 70 | eventChannel: "agent:event", |
| 71 | cwd: "/repo", |
| 72 | workspaceRoot: "/repo", |
| 73 | workspaceName: "repo", |
| 74 | workspacePath: "/repo", |
| 75 | session: { hostId: "local", sessionId: "canonical-a" }, |
| 76 | autoApproveTools: false, |
| 77 | bypass: false, |
| 78 | collaborationMode: "normal", |
| 79 | toolApprovalMode: "ask", |
| 80 | tokenMode: "full", |
| 81 | goal: "", |
| 82 | goalStatus: "stopped", |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | console.log("\nuse controller cancel reconcile"); |
| 87 | |
| 88 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 89 | pretendToBeVisual: true, |
| 90 | url: "http://localhost/", |
| 91 | }); |
| 92 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 93 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 94 | globalThis.document = dom.window.document; |
| 95 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 96 | globalThis.Node = dom.window.Node; |
| 97 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 98 | globalThis.Event = dom.window.Event; |
| 99 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 100 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 101 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 102 | globalThis.localStorage = dom.window.localStorage; |
| 103 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 104 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 105 | |
| 106 | let backendRunning = false; |
| 107 | const backendHistory: HistoryMessage[] = [{ |
| 108 | role: "user", |
| 109 | content: "hello", |
| 110 | messageId: "initial-user", |
| 111 | createdAt: 1000, |
| 112 | checkpointTurn: 0, |
| 113 | attachments: [{ kind: "image", digest: "a".repeat(64), name: "photo.png", mime: "image/png", width: 1, height: 1, bytes: 68 }], |
| 114 | }]; |
| 115 | let cancelCalls = 0; |
| 116 | let cancelInboxCalls = 0; |
| 117 | let cancelInboxError: Error | null = null; |
| 118 | let cancelDiscardedItemIDs: string[] = []; |
| 119 | let interruptCalls = 0; |
| 120 | let interruptError: Error | null = null; |
| 121 | let effortCalls = 0; |
| 122 | let checkpointHistoryCalls = 0; |
| 123 | let historyLoads = 0; |
| 124 | let checkpointLoads = 0; |
| 125 | let turnReplayCalls = 0; |
| 126 | const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 }; |
| 127 | const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] }; |
| 128 | |
| 129 | const desktopStub = installDesktopHostStub(({ |
| 130 | main: { |
| 131 | App: { |
| 132 | ListTabs: async () => [tabMeta({ running: backendRunning, cancellable: backendRunning })], |
| 133 | MetaForTab: async () => meta(), |
| 134 | ContextUsageForTab: async () => context, |
| 135 | EffortForTab: async () => effort, |
| 136 | SetEffortForTab: async () => { |
| 137 | effortCalls += 1; |
| 138 | throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"); |
| 139 | }, |
| 140 | BalanceForTab: async () => ({ available: false, display: "" }), |
| 141 | JobsForTab: async () => [], |
| 142 | CheckpointsForTab: async () => { |
| 143 | checkpointLoads += 1; |
| 144 | return [{ turn: 0, prompt: "hello", files: [], time: Date.now(), canConversation: true }]; |
| 145 | }, |
| 146 | ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 147 | HistoryForTab: async () => [], |
| 148 | HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => { |
| 149 | historyLoads += 1; |
| 150 | return historySliceFromMessages( |
| 151 | tabID, |
| 152 | backendHistory, |
| 153 | req, |
| 154 | ); |
| 155 | }, |
| 156 | HistoryCheckpointTurnsForTab: async () => { |
| 157 | checkpointHistoryCalls += 1; |
| 158 | return []; |
| 159 | }, |
| 160 | ReplayPendingPrompts: async () => {}, |
| 161 | TurnEventsForTab: async (_tabID: string, afterSeq: number) => { |
| 162 | turnReplayCalls += 1; |
| 163 | const events = afterSeq !== 1 ? [] : [ |
| 164 | { |
| 165 | turnId: "turn-gap", |
| 166 | seq: 2, |
| 167 | status: "in_progress", |
| 168 | event: { kind: "turn_started", turnId: "turn-gap", seq: 2, status: "in_progress" }, |
| 169 | }, |
| 170 | { |
| 171 | turnId: "turn-gap", |
| 172 | seq: 3, |
| 173 | status: "waiting_user", |
| 174 | event: { kind: "turn_status", turnId: "turn-gap", seq: 3, status: "waiting_user" }, |
| 175 | }, |
| 176 | ]; |
| 177 | return { |
| 178 | events, |
| 179 | floorSeq: 1, |
| 180 | latestSeq: 3, |
| 181 | nextAfterSeq: events.length > 0 ? 3 : afterSeq, |
| 182 | hasMore: false, |
| 183 | resetRequired: false, |
| 184 | }; |
| 185 | }, |
| 186 | SubmitToTab: async () => {}, |
| 187 | SubmitToTabWithID: async (tabId: string, text: string, submissionId: string) => { |
| 188 | const messageId = `user-${submissionId}`; |
| 189 | const createdAt = Date.now(); |
| 190 | backendHistory.push({ role: "user", content: text, messageId, submissionId, createdAt }); |
| 191 | desktopStub.emit("agent:event", { kind: "user_message", tabId, text, messageId, submissionId, createdAt }); |
| 192 | }, |
| 193 | CancelTab: async () => { |
| 194 | cancelCalls += 1; |
| 195 | backendRunning = false; |
| 196 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" }); |
| 197 | }, |
| 198 | CancelTabWithInboxItems: async () => { |
| 199 | cancelInboxCalls += 1; |
| 200 | if (cancelInboxError) throw cancelInboxError; |
| 201 | backendRunning = false; |
| 202 | }, |
| 203 | CancelTabWithInboxItemsResult: async () => { |
| 204 | cancelInboxCalls += 1; |
| 205 | if (cancelInboxError) throw cancelInboxError; |
| 206 | backendRunning = false; |
| 207 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" }); |
| 208 | return { discardedItemIds: [...cancelDiscardedItemIDs] }; |
| 209 | }, |
| 210 | InterruptTurnForTab: async () => { |
| 211 | interruptCalls += 1; |
| 212 | if (interruptError) throw interruptError; |
| 213 | backendRunning = false; |
| 214 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" }); |
| 215 | }, |
| 216 | } as Partial<AppBindings> as AppBindings, |
| 217 | }, |
| 218 | }).main.App); |
| 219 | |
| 220 | type Controller = ReturnType<typeof useController>; |
| 221 | let controller: Controller | undefined; |
| 222 | |
| 223 | function Probe() { |
| 224 | controller = useController(); |
| 225 | return null; |
| 226 | } |
| 227 | |
| 228 | const rootEl = document.getElementById("root"); |
| 229 | if (!rootEl) throw new Error("missing root"); |
| 230 | const root = createRoot(rootEl); |
| 231 | |
| 232 | await act(async () => { |
| 233 | root.render(<Probe />); |
| 234 | await flushPromises(); |
| 235 | }); |
| 236 | await waitFor("active tab", () => controller?.activeTabId === "tab-a"); |
| 237 | await act(async () => { |
| 238 | await flushPromises(50); |
| 239 | }); |
| 240 | historyLoads = 0; |
| 241 | checkpointLoads = 0; |
| 242 | |
| 243 | // A future event must pause projection until the missing durable prefix has |
| 244 | // been replayed. This interleaving is driven only by resolved promises (no |
| 245 | // timing sleeps), then a duplicate seq=3 is ignored idempotently. |
| 246 | await act(async () => { |
| 247 | desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 1, status: "queued" }); |
| 248 | desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "waiting_user" }); |
| 249 | for (let step = 0; step < 20; step += 1) await Promise.resolve(); |
| 250 | }); |
| 251 | eq(turnReplayCalls, 0, "v2 does not consult the retired ledger sequence space"); |
| 252 | eq(controller?.state.pendingPrompt, true, "future event projects only after the missing prefix"); |
| 253 | await act(async () => { |
| 254 | desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "in_progress" }); |
| 255 | await Promise.resolve(); |
| 256 | }); |
| 257 | eq(controller?.state.pendingPrompt, false, "ordered Follow revision is authoritative despite legacy sequence values"); |
| 258 | const historyLoadsBeforeSettlement = historyLoads; |
| 259 | const historyMutationBeforeSettlement = controller?.state.historyMutation.seq ?? 0; |
| 260 | await act(async () => { |
| 261 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", turnId: "turn-gap", seq: 4, status: "completed" }); |
| 262 | await Promise.resolve(); |
| 263 | }); |
| 264 | // A terminal turn is folded into the bounded durable window independently of |
| 265 | // cancellation. Let that expected read settle before measuring the later |
| 266 | // cancellation path, whose invariant is still that it schedules no reload. |
| 267 | await act(async () => { await flushPromises(); }); |
| 268 | eq(historyLoads, historyLoadsBeforeSettlement, "completion never rebases from history"); |
| 269 | ok((controller?.state.historyMutation.seq ?? 0) >= historyMutationBeforeSettlement, "completion retains the installed transcript"); |
| 270 | historyLoads = 0; |
| 271 | |
| 272 | backendRunning = true; |
| 273 | await act(async () => { |
| 274 | desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a" }); |
| 275 | await flushPromises(); |
| 276 | }); |
| 277 | eq(controller?.state.running, true, "turn_started marks the tab running"); |
| 278 | |
| 279 | await act(async () => { |
| 280 | controller?.cancel(); |
| 281 | await flushPromises(); |
| 282 | await flushPromises(); |
| 283 | }); |
| 284 | |
| 285 | for (let attempt = 0; attempt < 20 && controller?.state.running; attempt += 1) { |
| 286 | await act(async () => { |
| 287 | await flushPromises(50); |
| 288 | }); |
| 289 | } |
| 290 | |
| 291 | eq(controller?.state.running, false, "cancel reconciliation clears the running state"); |
| 292 | eq(cancelCalls, 1, "CancelTab is called once"); |
| 293 | eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested"); |
| 294 | await waitFor("cancelled checkpoint refresh", () => checkpointLoads > 0); |
| 295 | eq(historyLoads, 0, "cancellation never reloads or replaces the transcript"); |
| 296 | ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"), "cancelled prompt stays in the projected transcript"); |
| 297 | ok(controller?.state.checkpoints.some((checkpoint) => checkpoint.turn === 0 && checkpoint.canConversation), "cancelled prompt keeps its conversation checkpoint"); |
| 298 | |
| 299 | // The screenshot repro stops before turn_started, while the backend has |
| 300 | // already accepted the prompt and will persist it during cancellation cleanup. |
| 301 | historyLoads = 0; |
| 302 | checkpointLoads = 0; |
| 303 | backendRunning = true; |
| 304 | await act(async () => { |
| 305 | await controller?.send("hello"); |
| 306 | await flushPromises(); |
| 307 | }); |
| 308 | ok( |
| 309 | controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"), |
| 310 | "an immediate stop still has the optimistic prompt item", |
| 311 | ); |
| 312 | await act(async () => { |
| 313 | controller?.cancel(); |
| 314 | await flushPromises(); |
| 315 | }); |
| 316 | await waitFor("immediate cancellation", () => !controller?.state.running && checkpointLoads > 0); |
| 317 | eq(historyLoads, 0, "immediate cancellation does not schedule a transcript hydrate"); |
| 318 | ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"), "immediate cancellation keeps the optimistic prompt bubble"); |
| 319 | ok(controller?.state.checkpoints.some((checkpoint) => checkpoint.turn === 0 && checkpoint.canConversation), "immediate cancellation keeps the rewind checkpoint"); |
| 320 | |
| 321 | // A new submission after the interrupted terminal boundary must remain in the |
| 322 | // reducer because cancellation has no whole-history replacement path anymore. |
| 323 | historyLoads = 0; |
| 324 | await act(async () => { |
| 325 | await controller?.send("corrected"); |
| 326 | await flushPromises(); |
| 327 | }); |
| 328 | ok(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "corrected"), |
| 329 | "resubmission keeps its local echo through completed cancellation cleanup"); |
| 330 | eq(historyLoads, 0, "resubmission cannot race a stale cancellation history response"); |
| 331 | |
| 332 | await act(async () => { |
| 333 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", checkpointTurn: 0 }); |
| 334 | await flushPromises(); |
| 335 | }); |
| 336 | eq(checkpointHistoryCalls, 0, "TurnDone does not request the full checkpoint-turn history"); |
| 337 | |
| 338 | await act(async () => { |
| 339 | await controller?.setEffort("max"); |
| 340 | await flushPromises(); |
| 341 | }); |
| 342 | |
| 343 | const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet")); |
| 344 | eq(effortCalls, 1, "SetEffortForTab is called once"); |
| 345 | ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice"); |
| 346 | |
| 347 | cancelDiscardedItemIDs = ["withdrawn-guidance"]; |
| 348 | let cancelOutcome: Awaited<ReturnType<NonNullable<typeof controller>["cancel"]>> | undefined; |
| 349 | await act(async () => { |
| 350 | cancelOutcome = await controller?.cancel(["withdrawn-guidance", "delivered-guidance"]); |
| 351 | await flushPromises(); |
| 352 | }); |
| 353 | eq(cancelOutcome?.discardedItemIds.join(","), "withdrawn-guidance", "cancel returns only backend-confirmed withdrawn IDs"); |
| 354 | |
| 355 | cancelInboxError = new Error("reasonix_error:inbox_invalid_state"); |
| 356 | await act(async () => { |
| 357 | cancelOutcome = await controller?.cancel(["queued-guidance"]); |
| 358 | await flushPromises(); |
| 359 | }); |
| 360 | ok(Boolean(cancelOutcome?.error), "cancellation outcome preserves failure for the decision card"); |
| 361 | const inboxCancelNotice = controller?.state.items.find((item) => |
| 362 | item.kind === "notice" && item.text.includes("Cancel failed: This inbox instruction cannot be changed"), |
| 363 | ); |
| 364 | eq(cancelInboxCalls, 2, "receipt-capable cancellation is called for durable guidance"); |
| 365 | ok(Boolean(inboxCancelNotice), "cancel failure formats the stable inbox code for the active locale"); |
| 366 | ok(inboxCancelNotice?.kind === "notice" && !inboxCancelNotice.text.includes("reasonix_error:"), "cancel failure never renders the stable transport code"); |
| 367 | |
| 368 | // Stop is a session-level request: an exact-turn fence rejection (stale or |
| 369 | // replaced turn id) must fall back to the unconditional cancel instead of |
| 370 | // leaving the user with a "Cancel failed" notice and a running turn. |
| 371 | const noticesBefore = controller?.state.items.filter((item) => item.kind === "notice").length ?? 0; |
| 372 | const cancelCallsBefore = cancelCalls; |
| 373 | backendRunning = true; |
| 374 | interruptError = new Error('turn "turn-live" is not the active turn for tab "tab-a"'); |
| 375 | await act(async () => { |
| 376 | desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-live" }); |
| 377 | await flushPromises(); |
| 378 | }); |
| 379 | eq(controller?.state.activeTurnId, "turn-live", "turn_started with a turn id records the active turn"); |
| 380 | await act(async () => { |
| 381 | await controller?.cancel(); |
| 382 | await flushPromises(); |
| 383 | }); |
| 384 | eq(interruptCalls, 1, "exact-turn stop is attempted first"); |
| 385 | eq(cancelCalls, cancelCallsBefore + 1, "fence rejection falls back to the unconditional CancelTab"); |
| 386 | eq(controller?.state.items.filter((item) => item.kind === "notice").length, noticesBefore, "fence rejection does not surface a Cancel failed notice"); |
| 387 | await waitFor("fallback cancel reconciliation", () => controller?.state.running === false); |
| 388 | |
| 389 | // An idle backend answers with a stable code; the UI reconciles quietly. |
| 390 | backendRunning = false; |
| 391 | interruptError = new Error("reasonix_error:turn_not_running"); |
| 392 | await act(async () => { |
| 393 | desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-idle" }); |
| 394 | await flushPromises(); |
| 395 | }); |
| 396 | await act(async () => { |
| 397 | await controller?.cancel(); |
| 398 | await flushPromises(); |
| 399 | }); |
| 400 | eq(interruptCalls, 2, "idle stop still asks the backend once"); |
| 401 | eq(cancelCalls, cancelCallsBefore + 1, "idle stop does not retry through CancelTab"); |
| 402 | eq(controller?.state.items.filter((item) => item.kind === "notice").length, noticesBefore, "idle stop does not surface a Cancel failed notice"); |
| 403 | await waitFor("idle stop reconciliation", () => controller?.state.running === false); |
| 404 | |
| 405 | // A transport gap can hide the final event entirely: the resnapshot must |
| 406 | // settle core state while retaining the mounted transcript and never cancel. |
| 407 | backendRunning = true; |
| 408 | await act(async () => { |
| 409 | desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-gap-final" }); |
| 410 | await flushPromises(); |
| 411 | }); |
| 412 | const gapCancelCalls = cancelCalls; |
| 413 | const projectedUsers = controller?.state.items.filter(item => item.kind === "user") ?? []; |
| 414 | const gapHistoryLoads = historyLoads; |
| 415 | backendRunning = false; |
| 416 | await act(async () => { |
| 417 | desktopStub.emit("desktop:resync", { generation: "g-current", reason: "gap", expectedSeq: 2, actualSeq: 4 }); |
| 418 | await flushPromises(); |
| 419 | }); |
| 420 | await waitFor("event gap runtime snapshot", () => controller?.state.running === false); |
| 421 | eq(cancelCalls, gapCancelCalls, "event gap recovery never replays a state-changing command"); |
| 422 | ok(projectedUsers.every(item => controller?.state.items.includes(item)), "event gap recovery retains projected user item identities"); |
| 423 | eq(historyLoads, gapHistoryLoads + 1, "transport gap obtains one consistent Follow snapshot"); |
| 424 | |
| 425 | await act(async () => { |
| 426 | root.unmount(); |
| 427 | }); |
| 428 | dom.window.close(); |
| 429 | |
| 430 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 431 | if (failed > 0) process.exit(1); |
| 432 |