| 1 | // Run: tsx src/__tests__/use-controller-stale-turn-watchdog.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import type { AppBindings } from "../lib/bridge"; |
| 7 | import type { HistoryMessage, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 8 | import { useController } from "../lib/useController"; |
| 9 | import { historySliceFromMessages } from "./mockHistorySlice"; |
| 10 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 11 | |
| 12 | let passed = 0; |
| 13 | let failed = 0; |
| 14 | function ok(value: boolean, label: string) { |
| 15 | if (value) { |
| 16 | process.stdout.write(` PASS ${label}\n`); |
| 17 | passed += 1; |
| 18 | } else { |
| 19 | process.stdout.write(` FAIL ${label}\n`); |
| 20 | failed += 1; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | function flushPromises(): Promise<void> { |
| 25 | return new Promise((resolve) => globalThis.setTimeout(resolve, 0)); |
| 26 | } |
| 27 | |
| 28 | async function waitFor(label: string, predicate: () => boolean) { |
| 29 | for (let attempt = 0; attempt < 30; attempt += 1) { |
| 30 | await act(async () => { await flushPromises(); }); |
| 31 | if (predicate()) return; |
| 32 | } |
| 33 | throw new Error(`timed out waiting for ${label}`); |
| 34 | } |
| 35 | |
| 36 | console.log("\nstale turn watchdog"); |
| 37 | |
| 38 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 39 | pretendToBeVisual: true, |
| 40 | url: "http://localhost/", |
| 41 | }); |
| 42 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 43 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 44 | globalThis.document = dom.window.document; |
| 45 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 46 | globalThis.Node = dom.window.Node; |
| 47 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 48 | globalThis.Event = dom.window.Event; |
| 49 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 50 | globalThis.localStorage = dom.window.localStorage; |
| 51 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 52 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 53 | |
| 54 | const originalNow = Date.now; |
| 55 | let fakeNow = 1_000; |
| 56 | Date.now = () => fakeNow; |
| 57 | let nextTimerID = 1; |
| 58 | const watchdogTimers = new Map<number, () => void>(); |
| 59 | window.setTimeout = ((handler: TimerHandler, delay?: number) => { |
| 60 | const id = nextTimerID++; |
| 61 | if ((delay ?? 0) >= 29_000 && typeof handler === "function") watchdogTimers.set(id, handler); |
| 62 | else globalThis.setTimeout(handler, delay); |
| 63 | return id; |
| 64 | }) as typeof window.setTimeout; |
| 65 | window.clearTimeout = ((id?: number) => { |
| 66 | if (id !== undefined) watchdogTimers.delete(id); |
| 67 | }) as typeof window.clearTimeout; |
| 68 | |
| 69 | const tabID = "tab-watchdog"; |
| 70 | const sessionPath = "/repo/sessions/watchdog.jsonl"; |
| 71 | let backendRunning = false; |
| 72 | let revision = 1; |
| 73 | let history: HistoryMessage[] = []; |
| 74 | let listTabsCalls = 0; |
| 75 | let historyCalls = 0; |
| 76 | |
| 77 | function tabMeta(): TabMeta { |
| 78 | return { |
| 79 | id: tabID, |
| 80 | scope: "project", |
| 81 | workspaceRoot: "/repo", |
| 82 | workspaceName: "repo", |
| 83 | workspacePath: "/repo", |
| 84 | topicId: "topic-watchdog", |
| 85 | topicTitle: "Watchdog", |
| 86 | sessionPath, |
| 87 | sessionRevision: revision, |
| 88 | sessionDigest: `digest-${revision}`, |
| 89 | label: "model", |
| 90 | ready: true, |
| 91 | running: backendRunning, |
| 92 | cancellable: backendRunning, |
| 93 | mode: "normal", |
| 94 | toolApprovalMode: "ask", |
| 95 | tokenMode: "full", |
| 96 | active: true, |
| 97 | cwd: "/repo", |
| 98 | }; |
| 99 | } |
| 100 | |
| 101 | function meta(): Meta { |
| 102 | return { |
| 103 | label: "model", |
| 104 | ready: true, |
| 105 | eventChannel: "agent:event", |
| 106 | sessionPath, |
| 107 | sessionRevision: revision, |
| 108 | sessionDigest: `digest-${revision}`, |
| 109 | cwd: "/repo", |
| 110 | workspaceRoot: "/repo", |
| 111 | workspaceName: "repo", |
| 112 | workspacePath: "/repo", |
| 113 | autoApproveTools: false, |
| 114 | bypass: false, |
| 115 | collaborationMode: "normal", |
| 116 | toolApprovalMode: "ask", |
| 117 | tokenMode: "full", |
| 118 | goal: "", |
| 119 | goalStatus: "stopped", |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | const desktopStub = installDesktopHostStub(({ |
| 124 | main: { |
| 125 | App: { |
| 126 | ListTabs: async () => { |
| 127 | listTabsCalls += 1; |
| 128 | return [tabMeta()]; |
| 129 | }, |
| 130 | MetaForTab: async () => meta(), |
| 131 | ContextUsageForTab: async () => ({ used: 0, window: 1000, sessionTokens: 0 }), |
| 132 | EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }), |
| 133 | BalanceForTab: async () => ({ available: false, display: "" }), |
| 134 | JobsForTab: async () => [], |
| 135 | CheckpointsForTab: async () => [], |
| 136 | ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 137 | HistorySliceForTab: async (_id, request) => { |
| 138 | historyCalls += 1; |
| 139 | return historySliceFromMessages(tabID, history, request, { revision, digest: `digest-${revision}` }); |
| 140 | }, |
| 141 | HistoryCheckpointTurnsForTab: async () => [], |
| 142 | ReplayPendingPrompts: async () => {}, |
| 143 | SubmitToTabWithID: async () => { |
| 144 | backendRunning = true; |
| 145 | }, |
| 146 | } as Partial<AppBindings> as AppBindings, |
| 147 | }, |
| 148 | }).main.App); |
| 149 | |
| 150 | type Controller = ReturnType<typeof useController>; |
| 151 | let controller: Controller | undefined; |
| 152 | function Probe() { |
| 153 | controller = useController(); |
| 154 | return null; |
| 155 | } |
| 156 | |
| 157 | const rootElement = document.getElementById("root"); |
| 158 | if (!rootElement) throw new Error("missing root"); |
| 159 | const root = createRoot(rootElement); |
| 160 | await act(async () => { |
| 161 | root.render(<Probe />); |
| 162 | await flushPromises(); |
| 163 | }); |
| 164 | await waitFor("initial session", () => controller?.activeTabId === tabID && historyCalls > 0); |
| 165 | |
| 166 | await act(async () => { |
| 167 | desktopStub.emit("agent:event", { kind: "turn_started", tabId: tabID }); |
| 168 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: tabID }); |
| 169 | await flushPromises(); |
| 170 | }); |
| 171 | fakeNow += 60_000; |
| 172 | await act(async () => { |
| 173 | await controller?.send("hello"); |
| 174 | await flushPromises(); |
| 175 | }); |
| 176 | ok(controller?.state.running ?? false, "optimistic submit enters running state without any agent events"); |
| 177 | ok(controller?.state.turnActive === false, "missing turn_started leaves no live turn evidence"); |
| 178 | ok(watchdogTimers.size === 0, "v2 never arms a timer that guesses task completion"); |
| 179 | |
| 180 | const firstTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined; |
| 181 | if (firstTimer) { |
| 182 | watchdogTimers.delete(firstTimer[0]); |
| 183 | fakeNow += 30_000; |
| 184 | await act(async () => { |
| 185 | firstTimer[1](); |
| 186 | await flushPromises(); |
| 187 | }); |
| 188 | } |
| 189 | ok(listTabsCalls >= 1, "initial tab discovery completed"); |
| 190 | ok(controller?.state.running ?? false, "a genuinely running backend remains running"); |
| 191 | ok(watchdogTimers.size === 0, "network silence does not restart a watchdog"); |
| 192 | |
| 193 | backendRunning = false; |
| 194 | revision = 2; |
| 195 | history = [ |
| 196 | { role: "user", content: "hello" }, |
| 197 | { role: "assistant", content: "recovered answer" }, |
| 198 | ]; |
| 199 | const secondTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined; |
| 200 | if (secondTimer) { |
| 201 | watchdogTimers.delete(secondTimer[0]); |
| 202 | fakeNow += 30_000; |
| 203 | await act(async () => { |
| 204 | secondTimer[1](); |
| 205 | await flushPromises(); |
| 206 | }); |
| 207 | } |
| 208 | ok(controller?.state.running === true, "idle metadata alone cannot end the turn"); |
| 209 | await act(async () => { |
| 210 | desktopStub.emit("agent:event", { kind: "message", tabId: tabID, messageId: "final", text: "recovered answer" }); |
| 211 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: tabID }); |
| 212 | await flushPromises(); |
| 213 | }); |
| 214 | ok(controller?.state.running === false, "ordered backend completion settles the spinner"); |
| 215 | ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "completion updates the same visible transcript"); |
| 216 | ok(watchdogTimers.size === 0, "settled turn leaves no watchdog timer behind"); |
| 217 | |
| 218 | await act(async () => { root.unmount(); }); |
| 219 | Date.now = originalNow; |
| 220 | dom.window.close(); |
| 221 | |
| 222 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 223 | if (failed > 0) process.exit(1); |
| 224 |