| 1 | // Run: tsx src/__tests__/pending-prompt-stale-status.test.tsx |
| 2 | // |
| 3 | // Regression for #6429 (also #5561/#5481): switching to a session whose plan |
| 4 | // approval / ask is pending flashed the prompt and then lost it. The backend |
| 5 | // replays the prompt event when the detached runtime re-attaches, but a |
| 6 | // runtime snapshot fetched BEFORE that event (pre-attach ListTabs, activation |
| 7 | // metas) could be dispatched AFTER it — reporting the tab idle, clearing the |
| 8 | // prompt, and skipping the compensating replay because its pendingPrompt was |
| 9 | // false. Snapshots that predate the live prompt event must be ignored. |
| 10 | |
| 11 | import { readFileSync } from "node:fs"; |
| 12 | import { dirname, resolve } from "node:path"; |
| 13 | import { fileURLToPath } from "node:url"; |
| 14 | import { JSDOM } from "jsdom"; |
| 15 | import React, { act } from "react"; |
| 16 | import { createRoot } from "react-dom/client"; |
| 17 | import { |
| 18 | initialState, |
| 19 | promptEventClock, |
| 20 | reducer, |
| 21 | runtimeSnapshotPredatesPrompt, |
| 22 | useController, |
| 23 | } from "../lib/useController"; |
| 24 | import type { AppBindings } from "../lib/bridge"; |
| 25 | import type { ContextInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 26 | |
| 27 | let passed = 0; |
| 28 | let failed = 0; |
| 29 | |
| 30 | function ok(value: boolean, label: string) { |
| 31 | if (value) { |
| 32 | process.stdout.write(` PASS ${label}\n`); |
| 33 | passed += 1; |
| 34 | } else { |
| 35 | process.stdout.write(` FAIL ${label}\n`); |
| 36 | failed += 1; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | function eq(actual: unknown, expected: unknown, label: string) { |
| 41 | ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`); |
| 42 | } |
| 43 | |
| 44 | console.log("\npending prompt vs stale runtime snapshots"); |
| 45 | |
| 46 | // ---- reducer invariants ---- |
| 47 | |
| 48 | const planApprovalEvent = { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent; |
| 49 | const askEvent = { kind: "ask_request", ask: { id: "ask-1", question: "Which option?" } } as WireEvent; |
| 50 | const idleStatus = { type: "backend_status", running: false, pendingPrompt: false, backgroundJobs: 0, cancelRequested: false, cancellable: false } as const; |
| 51 | |
| 52 | const beforePrompt = promptEventClock(); |
| 53 | const withApproval = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 54 | const afterPrompt = promptEventClock(); |
| 55 | |
| 56 | eq(withApproval.approval?.id, "plan-1", "approval event arms the prompt"); |
| 57 | ok(typeof withApproval.promptArrivedAt === "number", "approval event records its arrival time"); |
| 58 | |
| 59 | const staleIdle = reducer(withApproval, { ...idleStatus, snapshotAt: beforePrompt }); |
| 60 | eq(staleIdle, withApproval, "idle snapshot fetched before the prompt event is ignored"); |
| 61 | eq(staleIdle.approval?.id, "plan-1", "stale idle snapshot keeps the approval visible"); |
| 62 | eq(staleIdle.pendingPrompt, true, "stale idle snapshot keeps the prompt gate"); |
| 63 | eq(staleIdle.running, true, "stale idle snapshot keeps the tab blocked on the user"); |
| 64 | |
| 65 | const tieIdle = reducer(withApproval, { ...idleStatus, snapshotAt: withApproval.promptArrivedAt }); |
| 66 | eq(tieIdle, withApproval, "snapshot tied with the prompt arrival counts as stale"); |
| 67 | |
| 68 | const staleRunning = reducer(withApproval, { type: "backend_status", running: true, pendingPrompt: false, backgroundJobs: 0, cancelRequested: false, cancellable: true, snapshotAt: beforePrompt }); |
| 69 | eq(staleRunning, withApproval, "stale running snapshot cannot drop the prompt gate either"); |
| 70 | |
| 71 | const freshIdle = reducer(withApproval, { ...idleStatus, snapshotAt: afterPrompt }); |
| 72 | eq(freshIdle.approval, undefined, "idle snapshot fetched after the prompt event still reconciles a dead prompt"); |
| 73 | eq(freshIdle.running, false, "fresh idle snapshot ends the turn"); |
| 74 | |
| 75 | const legacyIdle = reducer(withApproval, { ...idleStatus }); |
| 76 | eq(legacyIdle.approval, undefined, "snapshot without freshness metadata keeps the legacy clearing behavior"); |
| 77 | |
| 78 | const withAsk = reducer({ ...initialState }, { type: "event", e: askEvent }); |
| 79 | const staleAskIdle = reducer(withAsk, { ...idleStatus, snapshotAt: beforePrompt }); |
| 80 | eq(staleAskIdle.ask?.id, "ask-1", "stale idle snapshot keeps the ask card visible"); |
| 81 | const freshAskIdle = reducer(withAsk, { ...idleStatus, snapshotAt: promptEventClock() }); |
| 82 | eq(freshAskIdle.ask, undefined, "fresh idle snapshot still reconciles a dead ask"); |
| 83 | |
| 84 | // A replay of the SAME prompt id keeps the original arrival time — it must not |
| 85 | // advance the anchor, or an authoritative post-answer idle snapshot would look |
| 86 | // stale (#6432 reverse race). |
| 87 | const replayed = reducer(withApproval, { type: "event", e: planApprovalEvent }); |
| 88 | eq(replayed.promptArrivedAt, withApproval.promptArrivedAt, "same-id replay keeps the original arrival time"); |
| 89 | eq(replayed.promptArrivedId, "plan-1", "same-id replay keeps the anchor id"); |
| 90 | |
| 91 | // #6432 reverse race: user answers, a delayed replay of the SAME answered |
| 92 | // prompt id must not re-arm it at all — no downstream snapshot or turn_done |
| 93 | // is guaranteed to ever get a chance to disprove it (round 2 review: an idle |
| 94 | // snapshot dispatched before the replay has nothing to reject, and a fresh |
| 95 | // running=true/pendingPrompt=false snapshot never touches approval/ask). |
| 96 | { |
| 97 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 98 | const originalArrival = armed.promptArrivedAt!; |
| 99 | const answeredEarly = reducer(armed, { type: "clearApproval" }); |
| 100 | eq(answeredEarly.resolvedPromptId, "plan-1", "answering records the resolved prompt id"); |
| 101 | const replayed = reducer(answeredEarly, { type: "event", e: planApprovalEvent }); |
| 102 | eq(replayed.approval, undefined, "a same-id replay of an answered prompt is ignored, not re-armed"); |
| 103 | eq(replayed.running, answeredEarly.running, "an ignored replay leaves running/turnActive exactly as the answer left them"); |
| 104 | eq(replayed.promptArrivedAt, originalArrival, "an ignored replay leaves the original anchor untouched"); |
| 105 | const afterTurnDone = reducer(replayed, { type: "event", e: { kind: "turn_done" } as WireEvent }); |
| 106 | eq(afterTurnDone.approval, undefined, "turn_done cannot resurrect a replay that was never re-armed"); |
| 107 | |
| 108 | // Round 2, sequence 1: an idle snapshot dispatched between the answer and |
| 109 | // the delayed replay has nothing to reject (no live approval to compare |
| 110 | // against) — the replay must still be suppressed when it lands after. |
| 111 | const idleBetween = reducer(answeredEarly, { ...idleStatus, snapshotAt: promptEventClock() }); |
| 112 | const replayAfterIdle = reducer(idleBetween, { type: "event", e: planApprovalEvent }); |
| 113 | eq(replayAfterIdle.approval, undefined, "a replay landing after an already-applied idle snapshot is still ignored"); |
| 114 | const afterTurnDone2 = reducer(replayAfterIdle, { type: "event", e: { kind: "turn_done" } as WireEvent }); |
| 115 | eq(afterTurnDone2.approval, undefined, "turn_done stays clear after the idle-then-replay ordering"); |
| 116 | |
| 117 | // Round 2, sequence 2: a fresh running=true/pendingPrompt=false snapshot |
| 118 | // (backend genuinely executing the approved plan, no prompt pending) must |
| 119 | // not be able to inherit a zombie approval, because there is none to inherit. |
| 120 | const busySnapshot = reducer(answeredEarly, { |
| 121 | type: "backend_status", |
| 122 | running: true, |
| 123 | pendingPrompt: false, |
| 124 | backgroundJobs: 0, |
| 125 | cancelRequested: false, |
| 126 | cancellable: true, |
| 127 | snapshotAt: promptEventClock(), |
| 128 | }); |
| 129 | const replayDuringBusy = reducer(busySnapshot, { type: "event", e: planApprovalEvent }); |
| 130 | eq(replayDuringBusy.approval, undefined, "a replay during a genuinely busy, non-pending turn is still ignored"); |
| 131 | } |
| 132 | |
| 133 | // #6432 round 3, finding 1 (P1): a controller rebuild (model/effort/token-mode |
| 134 | // switch) reissues approval/ask ids from "1" (per-controller counters, see |
| 135 | // sound.ts). Without resetting the id-anchored bookkeeping, a genuinely new |
| 136 | // prompt from the rebuilt controller reusing an old id would be misread as a |
| 137 | // stale replay of one the OLD controller already resolved, and silently |
| 138 | // swallowed forever. |
| 139 | { |
| 140 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 141 | const answeredEarly = reducer(armed, { type: "clearApproval" }); |
| 142 | eq(answeredEarly.resolvedPromptId, "plan-1", "answering records the resolved id before the rebuild"); |
| 143 | const rebuilt = reducer(answeredEarly, { type: "controller_rebuilt" }); |
| 144 | eq(rebuilt.resolvedPromptId, undefined, "a controller rebuild drops the resolved-id bookkeeping"); |
| 145 | eq(rebuilt.promptArrivedId, undefined, "a controller rebuild drops the prompt arrival anchor id"); |
| 146 | eq(rebuilt.promptArrivedAt, undefined, "a controller rebuild drops the prompt arrival anchor time"); |
| 147 | // The new controller's own first prompt happens to reuse id "plan-1". |
| 148 | const freshPromptSameId = reducer(rebuilt, { type: "event", e: planApprovalEvent }); |
| 149 | eq(freshPromptSameId.approval?.id, "plan-1", "a genuinely new prompt reusing an old id after rebuild is armed, not swallowed"); |
| 150 | eq(freshPromptSameId.pendingPrompt, true, "the rebuilt controller's new prompt blocks the tab as expected"); |
| 151 | } |
| 152 | |
| 153 | // #6432 round 3, finding 2 (P2): the optimistic clearApproval/clearAsk |
| 154 | // tombstone must not be permanent when the backend call it anticipated |
| 155 | // actually fails — the prompt is still genuinely pending server-side, and a |
| 156 | // later replay must be able to recover it instead of being swallowed forever. |
| 157 | { |
| 158 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 159 | const answeredOptimistically = reducer(armed, { type: "clearApproval" }); |
| 160 | eq(answeredOptimistically.resolvedPromptId, "plan-1", "the optimistic answer records a tombstone before the backend call resolves"); |
| 161 | const submitFailed = reducer(answeredOptimistically, { type: "submit_prompt_failed", id: "plan-1", epoch: answeredOptimistically.promptEpoch }); |
| 162 | eq(submitFailed.resolvedPromptId, undefined, "a failed submit undoes the tombstone for that id"); |
| 163 | const recovered = reducer(submitFailed, { type: "event", e: planApprovalEvent }); |
| 164 | eq(recovered.approval?.id, "plan-1", "a replay after a failed submit can recover the still-pending prompt"); |
| 165 | |
| 166 | // A failure report for an id that is no longer the current tombstone (e.g. |
| 167 | // a stale/duplicate failure callback) must not clobber a newer one. |
| 168 | const armed2 = reducer({ ...initialState }, { type: "event", e: { kind: "approval_request", approval: { id: "plan-2", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent }); |
| 169 | const answered2 = reducer(armed2, { type: "clearApproval" }); |
| 170 | eq(answered2.resolvedPromptId, "plan-2", "answering the second prompt records its own tombstone"); |
| 171 | const staleFailure = reducer(answered2, { type: "submit_prompt_failed", id: "plan-1", epoch: answered2.promptEpoch }); |
| 172 | eq(staleFailure.resolvedPromptId, "plan-2", "a stale failure for an older id does not clobber the current tombstone"); |
| 173 | } |
| 174 | |
| 175 | // #6432 round 4, finding 1 (P1): a tool-approval posture switch (auto/yolo) |
| 176 | // only auto-allows a SUBSET of pending approvals backend-side (drainLocked |
| 177 | // keeps fresh plan/memory/sandbox-escape decisions pending, and auto keeps |
| 178 | // approvals an allow policy would not cover). The frontend must dismiss + |
| 179 | // tombstone only the prompt ids the backend reports as drained — blanket- |
| 180 | // tombstoning the visible prompt would filter every future replay of a |
| 181 | // prompt the backend still holds, stranding the turn with no card to answer. |
| 182 | { |
| 183 | // Backend kept the fresh plan approval: not in the drained set → stays. |
| 184 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 185 | const notDrained = reducer(armed, { type: "approval_drained", ids: ["7"], epoch: armed.promptEpoch }); |
| 186 | eq(notDrained, armed, "a drain report not covering the visible approval leaves the state untouched"); |
| 187 | eq(notDrained.approval?.id, "plan-1", "the still-pending plan approval card survives the yolo switch"); |
| 188 | eq(notDrained.resolvedPromptId, undefined, "no tombstone is written for a prompt the backend still holds"); |
| 189 | const replayed = reducer(notDrained, { type: "event", e: planApprovalEvent }); |
| 190 | eq(replayed.approval?.id, "plan-1", "a later replay of the still-pending prompt re-arms it"); |
| 191 | |
| 192 | const emptyDrain = reducer(armed, { type: "approval_drained", ids: [], epoch: armed.promptEpoch }); |
| 193 | eq(emptyDrain, armed, "an empty drain report is a no-op"); |
| 194 | |
| 195 | // Backend drained the ordinary tool approval: dismissed + tombstoned so a |
| 196 | // delayed re-delivery cannot resurrect it (round 2 contract preserved). |
| 197 | const bashEvent = { kind: "approval_request", approval: { id: "bash-3", tool: "bash", subject: "rm -rf build" } } as WireEvent; |
| 198 | const armedBash = reducer({ ...initialState }, { type: "event", e: bashEvent }); |
| 199 | const drained = reducer(armedBash, { type: "approval_drained", ids: ["bash-3"], epoch: armedBash.promptEpoch }); |
| 200 | eq(drained.approval, undefined, "a drained approval is dismissed"); |
| 201 | eq(drained.resolvedPromptId, "bash-3", "a drained approval is tombstoned like an answered one"); |
| 202 | const zombieReplay = reducer(drained, { type: "event", e: bashEvent }); |
| 203 | eq(zombieReplay.approval, undefined, "a delayed re-delivery of the drained prompt stays suppressed"); |
| 204 | } |
| 205 | |
| 206 | // #6432 round 4, finding 2 (P2): a late submit failure from BEFORE a |
| 207 | // controller rebuild must not undo the tombstone the NEW controller's answer |
| 208 | // wrote for the same numeric id — approval ids restart from "1" per |
| 209 | // controller, so the old failure names a different prompt. |
| 210 | { |
| 211 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 212 | const epochA = armed.promptEpoch; |
| 213 | const answeredA = reducer(armed, { type: "clearApproval" }); |
| 214 | // Controller rebuild lands while the epoch-A RPC is still in flight. |
| 215 | const rebuilt = reducer(answeredA, { type: "controller_rebuilt" }); |
| 216 | eq(rebuilt.promptEpoch, epochA + 1, "a controller rebuild advances the prompt epoch"); |
| 217 | // The rebuilt controller reissues id "plan-1"; the user answers it too. |
| 218 | const armedB = reducer(rebuilt, { type: "event", e: planApprovalEvent }); |
| 219 | const answeredB = reducer(armedB, { type: "clearApproval" }); |
| 220 | eq(answeredB.resolvedPromptId, "plan-1", "the new controller's answer records its own tombstone"); |
| 221 | // The old controller's RPC failure finally lands, carrying the old epoch. |
| 222 | const staleEpochFailure = reducer(answeredB, { type: "submit_prompt_failed", id: "plan-1", epoch: epochA }); |
| 223 | eq(staleEpochFailure.resolvedPromptId, "plan-1", "a failure from a pre-rebuild epoch cannot erase the new controller's tombstone"); |
| 224 | const zombie = reducer(staleEpochFailure, { type: "event", e: planApprovalEvent }); |
| 225 | eq(zombie.approval, undefined, "the answered prompt's delayed replay stays suppressed after the stale failure"); |
| 226 | // Same-epoch failures still recover the genuinely-unresolved prompt. |
| 227 | const currentEpochFailure = reducer(answeredB, { type: "submit_prompt_failed", id: "plan-1", epoch: answeredB.promptEpoch }); |
| 228 | eq(currentEpochFailure.resolvedPromptId, undefined, "a same-epoch failure still undoes the tombstone"); |
| 229 | // reset() starts a new session (new controller, ids restart) — the epoch |
| 230 | // advances there too so pre-reset failures cannot touch post-reset state. |
| 231 | const resetState = reducer(answeredB, { type: "reset" }); |
| 232 | eq(resetState.promptEpoch, answeredB.promptEpoch + 1, "a session reset advances the prompt epoch too"); |
| 233 | } |
| 234 | |
| 235 | // #6432 round 5 (P2): a mode-switch drain result belongs to the controller |
| 236 | // epoch where its RPC started. If that controller is rebuilt before the RPC |
| 237 | // resolves, the replacement controller may reuse the same approval id; the |
| 238 | // old result must not dismiss or tombstone the replacement's prompt. |
| 239 | { |
| 240 | const approval = { kind: "approval_request", approval: { id: "1", tool: "bash", subject: "old controller" } } as WireEvent; |
| 241 | const armedA = reducer({ ...initialState }, { type: "event", e: approval }); |
| 242 | const epochA = armedA.promptEpoch; |
| 243 | const rebuilt = reducer(armedA, { type: "controller_rebuilt" }); |
| 244 | const freshApproval = { kind: "approval_request", approval: { id: "1", tool: "bash", subject: "new controller" } } as WireEvent; |
| 245 | const armedB = reducer(rebuilt, { type: "event", e: freshApproval }); |
| 246 | |
| 247 | const staleDrain = reducer(armedB, { type: "approval_drained", ids: ["1"], epoch: epochA }); |
| 248 | eq(staleDrain.approval?.subject, "new controller", "a pre-rebuild drain result cannot dismiss the new controller's same-id approval"); |
| 249 | eq(staleDrain.resolvedPromptId, undefined, "a stale drain result cannot tombstone the new controller's prompt id"); |
| 250 | |
| 251 | const currentDrain = reducer(armedB, { type: "approval_drained", ids: ["1"], epoch: armedB.promptEpoch }); |
| 252 | eq(currentDrain.approval, undefined, "a same-epoch drain still dismisses the backend-drained approval"); |
| 253 | eq(currentDrain.resolvedPromptId, "1", "a same-epoch drain still tombstones the drained approval"); |
| 254 | } |
| 255 | |
| 256 | // A genuinely new prompt (different id) after an answer re-anchors, so its own |
| 257 | // stale pre-arrival snapshot is still rejected (#6429 preserved). |
| 258 | { |
| 259 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 260 | const answeredEarly = reducer(armed, { type: "clearApproval" }); |
| 261 | const betweenPrompts = promptEventClock(); |
| 262 | const nextPrompt = reducer(answeredEarly, { type: "event", e: { kind: "approval_request", approval: { id: "plan-2", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent }); |
| 263 | ok((nextPrompt.promptArrivedAt ?? 0) > betweenPrompts, "a new prompt id re-anchors the arrival time"); |
| 264 | const staleForNext = reducer(nextPrompt, { ...idleStatus, snapshotAt: betweenPrompts }); |
| 265 | eq(staleForNext.approval?.id, "plan-2", "a stale snapshot predating the new prompt is still rejected"); |
| 266 | } |
| 267 | |
| 268 | // backend_activation_start drops the anchor so a post-activation replay |
| 269 | // re-anchors against the activation (#6429 tab-switch path). |
| 270 | { |
| 271 | const stale = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 272 | const activated = reducer(stale, { type: "backend_activation_start" }); |
| 273 | eq(activated.promptArrivedId, undefined, "activation drops the prompt anchor"); |
| 274 | eq(activated.promptArrivedAt, undefined, "activation drops the prompt arrival time"); |
| 275 | } |
| 276 | |
| 277 | // A new user turn drops the anchor so the next turn's prompts re-anchor fresh. |
| 278 | { |
| 279 | const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent }); |
| 280 | const answeredEarly = reducer(armed, { type: "clearApproval" }); |
| 281 | const nextTurn = reducer(answeredEarly, { type: "user", text: "continue", seq: 0 }); |
| 282 | eq(nextTurn.promptArrivedId, undefined, "a new user message drops the prompt anchor id"); |
| 283 | eq(nextTurn.promptArrivedAt, undefined, "a new user message drops the prompt arrival time"); |
| 284 | } |
| 285 | |
| 286 | const answered = reducer(withApproval, { type: "clearApproval" }); |
| 287 | eq(answered.approval, undefined, "explicit answer clears the prompt"); |
| 288 | const idleAfterAnswer = reducer(answered, { ...idleStatus, snapshotAt: beforePrompt }); |
| 289 | eq(idleAfterAnswer.running, false, "without a live prompt, even old snapshots reconcile normally"); |
| 290 | |
| 291 | eq(runtimeSnapshotPredatesPrompt(withApproval, beforePrompt), true, "predates: snapshot older than the prompt"); |
| 292 | eq(runtimeSnapshotPredatesPrompt(withApproval, afterPrompt), false, "predates: snapshot newer than the prompt"); |
| 293 | eq(runtimeSnapshotPredatesPrompt(withApproval, undefined), false, "predates: unknown snapshot freshness is not stale"); |
| 294 | eq(runtimeSnapshotPredatesPrompt({ ...initialState }, beforePrompt), false, "predates: no live prompt means nothing to protect"); |
| 295 | eq(runtimeSnapshotPredatesPrompt(undefined, beforePrompt), false, "predates: missing state is not stale"); |
| 296 | |
| 297 | // Every runtime-status dispatch must carry the fetch time of its snapshot; a |
| 298 | // two-argument call reintroduces the unguarded clearing path. |
| 299 | const here = dirname(fileURLToPath(import.meta.url)); |
| 300 | const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8"); |
| 301 | const twoArgStatusCalls = controllerSource.match(/dispatchRuntimeStatusForTab\(\s*[^(),]+,\s*[^(),]+\s*\)/g) ?? []; |
| 302 | eq(twoArgStatusCalls.length, 0, "every dispatchRuntimeStatusForTab call passes its snapshot fetch time"); |
| 303 | |
| 304 | // ---- hook-level race: replayed approval vs in-flight stale ListTabs ---- |
| 305 | |
| 306 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 307 | pretendToBeVisual: true, |
| 308 | url: "http://localhost/", |
| 309 | }); |
| 310 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 311 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 312 | globalThis.document = dom.window.document; |
| 313 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 314 | globalThis.Node = dom.window.Node; |
| 315 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 316 | globalThis.Event = dom.window.Event; |
| 317 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 318 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 319 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 320 | globalThis.localStorage = dom.window.localStorage; |
| 321 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 322 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 323 | |
| 324 | function flushPromises(): Promise<void> { |
| 325 | return new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); |
| 326 | } |
| 327 | |
| 328 | function deferred<T>() { |
| 329 | let resolvePromise!: (value: T) => void; |
| 330 | const promise = new Promise<T>((res) => { |
| 331 | resolvePromise = res; |
| 332 | }); |
| 333 | return { promise, resolve: resolvePromise }; |
| 334 | } |
| 335 | |
| 336 | async function waitFor(label: string, predicate: () => boolean) { |
| 337 | for (let attempt = 0; attempt < 50; attempt += 1) { |
| 338 | await act(async () => { |
| 339 | await flushPromises(); |
| 340 | }); |
| 341 | if (predicate()) return; |
| 342 | } |
| 343 | throw new Error(`timed out waiting for ${label}`); |
| 344 | } |
| 345 | |
| 346 | let projectedRuntimeEpoch = "runtime-local"; |
| 347 | |
| 348 | function tabMeta(): TabMeta { |
| 349 | return { |
| 350 | id: "tab-a", |
| 351 | scope: "project", |
| 352 | workspaceRoot: "/repo", |
| 353 | workspaceName: "repo", |
| 354 | workspacePath: "/repo", |
| 355 | topicId: "topic-a", |
| 356 | topicTitle: "General", |
| 357 | sessionPath: "/repo/sessions/tab-a.jsonl", |
| 358 | label: "model", |
| 359 | ready: true, |
| 360 | runtime: { phase: "ready", epoch: projectedRuntimeEpoch }, |
| 361 | running: false, |
| 362 | cancellable: false, |
| 363 | mode: "normal", |
| 364 | toolApprovalMode: "ask", |
| 365 | tokenMode: "full", |
| 366 | active: true, |
| 367 | cwd: "/repo", |
| 368 | }; |
| 369 | } |
| 370 | |
| 371 | function metaForTab(): Meta { |
| 372 | return { |
| 373 | label: "model", |
| 374 | ready: true, |
| 375 | runtime: { phase: "ready", epoch: projectedRuntimeEpoch }, |
| 376 | eventChannel: "agent:event", |
| 377 | cwd: "/repo", |
| 378 | workspaceRoot: "/repo", |
| 379 | workspaceName: "repo", |
| 380 | workspacePath: "/repo", |
| 381 | autoApproveTools: false, |
| 382 | bypass: false, |
| 383 | collaborationMode: "normal", |
| 384 | toolApprovalMode: "ask", |
| 385 | tokenMode: "full", |
| 386 | goal: "", |
| 387 | goalStatus: "stopped", |
| 388 | }; |
| 389 | } |
| 390 | |
| 391 | const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 }; |
| 392 | const effortInfo: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] }; |
| 393 | const eventHandlers: Array<(e: WireEvent) => void> = []; |
| 394 | const rebuiltHandlers: Array<(tabId?: string, runtimeEpoch?: string) => void> = []; |
| 395 | let holdNextListTabs: Promise<void> | undefined; |
| 396 | let modeDrain: ReturnType<typeof deferred<string[]>> | undefined; |
| 397 | let toolApprovalModeDrain: ReturnType<typeof deferred<string[]>> | undefined; |
| 398 | let composerProfileDrain: ReturnType<typeof deferred<string[]>> | undefined; |
| 399 | let composerProfileCalls = 0; |
| 400 | let rejectNextComposerProfile = false; |
| 401 | |
| 402 | window.runtime = { |
| 403 | EventsOn: (name: string, cb: (payload: unknown) => void) => { |
| 404 | if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void); |
| 405 | if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string, runtimeEpoch?: string) => void); |
| 406 | return () => {}; |
| 407 | }, |
| 408 | BrowserOpenURL: () => {}, |
| 409 | }; |
| 410 | window.go = { |
| 411 | main: { |
| 412 | App: { |
| 413 | ListTabs: async () => { |
| 414 | if (holdNextListTabs) { |
| 415 | const gatePromise = holdNextListTabs; |
| 416 | holdNextListTabs = undefined; |
| 417 | await gatePromise; |
| 418 | } |
| 419 | return [tabMeta()]; |
| 420 | }, |
| 421 | MetaForTab: async () => metaForTab(), |
| 422 | ContextUsageForTab: async () => context, |
| 423 | EffortForTab: async () => effortInfo, |
| 424 | BalanceForTab: async () => ({ available: false, display: "" }), |
| 425 | JobsForTab: async () => [], |
| 426 | CheckpointsForTab: async () => [], |
| 427 | HistoryForTab: async () => [], |
| 428 | HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), |
| 429 | HistoryCheckpointTurnsForTab: async () => [], |
| 430 | ReplayPendingPrompts: async () => {}, |
| 431 | SetActiveTab: async () => {}, |
| 432 | SetModeForTab: async () => modeDrain?.promise ?? [], |
| 433 | SetToolApprovalModeForTab: async () => toolApprovalModeDrain?.promise ?? [], |
| 434 | SetComposerProfileForTab: async () => { |
| 435 | composerProfileCalls += 1; |
| 436 | if (rejectNextComposerProfile) { |
| 437 | rejectNextComposerProfile = false; |
| 438 | throw new Error("profile transaction failed"); |
| 439 | } |
| 440 | return composerProfileDrain?.promise ?? []; |
| 441 | }, |
| 442 | } as Partial<AppBindings> as AppBindings, |
| 443 | }, |
| 444 | }; |
| 445 | |
| 446 | type Controller = ReturnType<typeof useController>; |
| 447 | let controller: Controller | undefined; |
| 448 | |
| 449 | function Probe() { |
| 450 | controller = useController(); |
| 451 | return null; |
| 452 | } |
| 453 | |
| 454 | const rootEl = document.getElementById("root"); |
| 455 | if (!rootEl) throw new Error("missing root"); |
| 456 | const root = createRoot(rootEl); |
| 457 | |
| 458 | await act(async () => { |
| 459 | root.render(<Probe />); |
| 460 | await flushPromises(); |
| 461 | }); |
| 462 | await waitFor("active tab", () => controller?.activeTabId === "tab-a"); |
| 463 | await act(async () => { |
| 464 | await flushPromises(); |
| 465 | await flushPromises(); |
| 466 | }); |
| 467 | |
| 468 | // A Local tab already has an accepted epoch before Workbench projects a |
| 469 | // Remote runtime onto the same surface. The Remote transition must publish its |
| 470 | // authority before tagged Host events arrive, or the frontend rejects them all |
| 471 | // as stale Local traffic. |
| 472 | const remoteEpochApproval = { |
| 473 | kind: "approval_request", |
| 474 | tabId: "tab-a", |
| 475 | runtimeEpoch: "runtime-remote", |
| 476 | approval: { id: "remote-epoch-1", tool: "bash", subject: "Remote epoch prompt" }, |
| 477 | } as WireEvent; |
| 478 | await act(async () => { |
| 479 | for (const handler of eventHandlers) handler(remoteEpochApproval); |
| 480 | await flushPromises(); |
| 481 | }); |
| 482 | eq(controller?.state.approval, undefined, "a Remote event is fenced while the Local epoch is still authoritative"); |
| 483 | await act(async () => { |
| 484 | projectedRuntimeEpoch = "runtime-remote"; |
| 485 | for (const handler of rebuiltHandlers) handler("tab-a", "runtime-remote"); |
| 486 | for (const handler of eventHandlers) handler(remoteEpochApproval); |
| 487 | await flushPromises(); |
| 488 | }); |
| 489 | eq(controller?.state.approval?.id, "remote-epoch-1", "the projected Remote epoch admits tagged Host events"); |
| 490 | await act(async () => { |
| 491 | for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a", runtimeEpoch: "runtime-remote" } as WireEvent); |
| 492 | await flushPromises(); |
| 493 | }); |
| 494 | eq(controller?.state.approval, undefined, "the Remote epoch regression fixture resets cleanly"); |
| 495 | |
| 496 | // A reconciliation fetch starts (its snapshot time is captured now), then the |
| 497 | // backend attach replays the pending plan approval before the fetch resolves. |
| 498 | const gate = deferred<void>(); |
| 499 | holdNextListTabs = gate.promise; |
| 500 | let syncPromise: Promise<string | undefined> | undefined; |
| 501 | await act(async () => { |
| 502 | syncPromise = controller?.syncActiveTab(false); |
| 503 | await flushPromises(); |
| 504 | }); |
| 505 | await act(async () => { |
| 506 | for (const handler of eventHandlers) { |
| 507 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: "plan-live", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent); |
| 508 | } |
| 509 | await flushPromises(); |
| 510 | }); |
| 511 | eq(controller?.state.approval?.id, "plan-live", "replayed plan approval renders while a snapshot fetch is in flight"); |
| 512 | |
| 513 | await act(async () => { |
| 514 | gate.resolve(); |
| 515 | await syncPromise; |
| 516 | await flushPromises(); |
| 517 | }); |
| 518 | eq(controller?.state.approval?.id, "plan-live", "a snapshot fetched before the prompt event cannot clear the approval"); |
| 519 | eq(controller?.state.pendingPrompt, true, "the prompt gate survives the stale reconciliation"); |
| 520 | eq(controller?.state.running, true, "the tab stays blocked on the user after the stale reconciliation"); |
| 521 | |
| 522 | // A snapshot fetched after the event still reconciles: if the backend truly |
| 523 | // has no pending prompt anymore, the zombie prompt is cleared. |
| 524 | await act(async () => { |
| 525 | await controller?.syncActiveTab(false); |
| 526 | await flushPromises(); |
| 527 | }); |
| 528 | eq(controller?.state.approval?.id, undefined, "a snapshot fetched after the prompt event still reconciles a dead prompt"); |
| 529 | eq(controller?.state.running, false, "fresh idle snapshot releases the blocked state"); |
| 530 | |
| 531 | // #6432 backstop (reviewer round 2): after navigation drops the prompt anchor |
| 532 | // (backend_activation_start on a rapid A→B→A, or single-surface state wipe), a |
| 533 | // delayed replay of an already-answered prompt re-anchors it, so the |
| 534 | // authoritative post-answer idle snapshot looks stale and is rejected — leaving |
| 535 | // a zombie the frontend heuristic cannot disprove. The rejection must schedule a |
| 536 | // fresh reconcile that refetches backend truth and clears the resolved prompt. |
| 537 | { |
| 538 | // A snapshot fetch starts (its time is captured), then a prompt event arrives, |
| 539 | // so the snapshot is stale relative to the prompt when it finally dispatches. |
| 540 | const staleGate = deferred<void>(); |
| 541 | holdNextListTabs = staleGate.promise; |
| 542 | let staleSync: Promise<string | undefined> | undefined; |
| 543 | await act(async () => { |
| 544 | staleSync = controller?.syncActiveTab(false); |
| 545 | await flushPromises(); |
| 546 | }); |
| 547 | await act(async () => { |
| 548 | for (const handler of eventHandlers) { |
| 549 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: "plan-zombie", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent); |
| 550 | } |
| 551 | await flushPromises(); |
| 552 | }); |
| 553 | eq(controller?.state.approval?.id, "plan-zombie", "zombie approval is armed after the snapshot fetch started"); |
| 554 | await act(async () => { |
| 555 | staleGate.resolve(); |
| 556 | await staleSync; |
| 557 | await flushPromises(); |
| 558 | }); |
| 559 | eq(controller?.state.approval?.id, "plan-zombie", "the stale idle snapshot is rejected, the prompt survives for now"); |
| 560 | // The backend reports idle (the prompt was resolved); the scheduled fresh |
| 561 | // reconcile refetches that truth and clears the zombie, unlocking input. |
| 562 | await act(async () => { |
| 563 | await new Promise((resolvePromise) => setTimeout(resolvePromise, 300)); |
| 564 | await flushPromises(); |
| 565 | }); |
| 566 | eq(controller?.state.approval?.id, undefined, "the scheduled fresh reconcile clears the zombie the stale rejection preserved"); |
| 567 | eq(controller?.state.running, false, "the fresh reconcile unlocks the input after clearing the zombie"); |
| 568 | } |
| 569 | |
| 570 | // Both mode-switch entry points capture the prompt epoch before starting their |
| 571 | // backend RPC. A rebuild and same-id prompt can arrive while either call is in |
| 572 | // flight; its old drain result must then be ignored. |
| 573 | { |
| 574 | const approvalID = "mode-drain-1"; |
| 575 | await act(async () => { |
| 576 | for (const handler of eventHandlers) { |
| 577 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: approvalID, tool: "bash", subject: "old controller mode prompt" } } as WireEvent); |
| 578 | } |
| 579 | await flushPromises(); |
| 580 | }); |
| 581 | modeDrain = deferred<string[]>(); |
| 582 | let switchPromise: Promise<void> | undefined; |
| 583 | await act(async () => { |
| 584 | switchPromise = controller?.setControllerMode("plan"); |
| 585 | await flushPromises(); |
| 586 | }); |
| 587 | await act(async () => { |
| 588 | for (const handler of rebuiltHandlers) handler("tab-a"); |
| 589 | for (const handler of eventHandlers) { |
| 590 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: approvalID, tool: "bash", subject: "new controller mode prompt" } } as WireEvent); |
| 591 | } |
| 592 | await flushPromises(); |
| 593 | }); |
| 594 | await act(async () => { |
| 595 | modeDrain?.resolve([approvalID]); |
| 596 | await switchPromise; |
| 597 | await flushPromises(); |
| 598 | }); |
| 599 | eq(controller?.state.approval?.subject, "new controller mode prompt", "a late SetModeForTab drain cannot dismiss a new same-id prompt"); |
| 600 | |
| 601 | const toolApprovalID = "tool-mode-drain-1"; |
| 602 | await act(async () => { |
| 603 | for (const handler of eventHandlers) { |
| 604 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: toolApprovalID, tool: "bash", subject: "old tool-approval prompt" } } as WireEvent); |
| 605 | } |
| 606 | await flushPromises(); |
| 607 | }); |
| 608 | toolApprovalModeDrain = deferred<string[]>(); |
| 609 | let toolSwitchPromise: Promise<void> | undefined; |
| 610 | await act(async () => { |
| 611 | toolSwitchPromise = controller?.setToolApprovalModeForTab("tab-a", "auto"); |
| 612 | await flushPromises(); |
| 613 | }); |
| 614 | await act(async () => { |
| 615 | for (const handler of rebuiltHandlers) handler("tab-a"); |
| 616 | for (const handler of eventHandlers) { |
| 617 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: toolApprovalID, tool: "bash", subject: "new tool-approval prompt" } } as WireEvent); |
| 618 | } |
| 619 | await flushPromises(); |
| 620 | }); |
| 621 | await act(async () => { |
| 622 | toolApprovalModeDrain?.resolve([toolApprovalID]); |
| 623 | await toolSwitchPromise; |
| 624 | await flushPromises(); |
| 625 | }); |
| 626 | eq(controller?.state.approval?.subject, "new tool-approval prompt", "a late SetToolApprovalModeForTab drain cannot dismiss a new same-id prompt"); |
| 627 | |
| 628 | const profileApprovalID = "profile-drain-1"; |
| 629 | await act(async () => { |
| 630 | for (const handler of eventHandlers) { |
| 631 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "old composer-profile prompt" } } as WireEvent); |
| 632 | } |
| 633 | await flushPromises(); |
| 634 | }); |
| 635 | composerProfileDrain = deferred<string[]>(); |
| 636 | const profileCallsBefore = composerProfileCalls; |
| 637 | let profilePromise: Promise<boolean> | undefined; |
| 638 | await act(async () => { |
| 639 | profilePromise = controller?.setComposerProfileForTab("tab-a", "plan", "auto", ""); |
| 640 | await flushPromises(); |
| 641 | }); |
| 642 | eq(composerProfileCalls, profileCallsBefore + 1, "one composer-profile sync uses one atomic backend call"); |
| 643 | await act(async () => { |
| 644 | for (const handler of rebuiltHandlers) handler("tab-a"); |
| 645 | for (const handler of eventHandlers) { |
| 646 | handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "new composer-profile prompt" } } as WireEvent); |
| 647 | } |
| 648 | await flushPromises(); |
| 649 | }); |
| 650 | await act(async () => { |
| 651 | composerProfileDrain?.resolve([profileApprovalID]); |
| 652 | await profilePromise; |
| 653 | await flushPromises(); |
| 654 | }); |
| 655 | eq(controller?.state.approval?.subject, "new composer-profile prompt", "a late atomic profile drain cannot dismiss a new same-id prompt"); |
| 656 | |
| 657 | composerProfileDrain = undefined; |
| 658 | const falseRebuildProfileCallsBefore = composerProfileCalls; |
| 659 | await act(async () => { |
| 660 | await controller?.setComposerProfileForTab("tab-a", "plan", "auto", ""); |
| 661 | await controller?.setComposerProfileForTab("tab-a", "plan", "auto", ""); |
| 662 | await flushPromises(); |
| 663 | }); |
| 664 | eq(composerProfileCalls, falseRebuildProfileCallsBefore, "a rebuild notice without a new runtime identity does not replay the same profile"); |
| 665 | |
| 666 | const rebuiltProfileCallsBefore = composerProfileCalls; |
| 667 | await act(async () => { |
| 668 | projectedRuntimeEpoch = "runtime-next"; |
| 669 | for (const handler of rebuiltHandlers) handler("tab-a", "runtime-next"); |
| 670 | await controller?.setComposerProfileForTab("tab-a", "plan", "auto", ""); |
| 671 | await controller?.setComposerProfileForTab("tab-a", "plan", "auto", ""); |
| 672 | await flushPromises(); |
| 673 | }); |
| 674 | eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per actual runtime generation"); |
| 675 | |
| 676 | const concurrentProfileDrain = deferred<string[]>(); |
| 677 | composerProfileDrain = concurrentProfileDrain; |
| 678 | const changedProfileCallsBefore = composerProfileCalls; |
| 679 | let concurrentProfileA: Promise<boolean> | undefined; |
| 680 | let concurrentProfileB: Promise<boolean> | undefined; |
| 681 | await act(async () => { |
| 682 | concurrentProfileA = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal"); |
| 683 | concurrentProfileB = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal"); |
| 684 | await flushPromises(); |
| 685 | }); |
| 686 | eq(composerProfileCalls, changedProfileCallsBefore + 1, "concurrent identical profile replays share one backend call"); |
| 687 | await act(async () => { |
| 688 | concurrentProfileDrain.resolve([]); |
| 689 | await Promise.all([concurrentProfileA, concurrentProfileB]); |
| 690 | await flushPromises(); |
| 691 | }); |
| 692 | |
| 693 | const olderProfileDrain = deferred<string[]>(); |
| 694 | const latestProfileDrain = deferred<string[]>(); |
| 695 | composerProfileDrain = olderProfileDrain; |
| 696 | const orderedProfileCallsBefore = composerProfileCalls; |
| 697 | let olderProfile: Promise<boolean> | undefined; |
| 698 | let latestProfile: Promise<boolean> | undefined; |
| 699 | await act(async () => { |
| 700 | olderProfile = controller?.setComposerProfileForTab("tab-a", "plan", "yolo", "older intent"); |
| 701 | latestProfile = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "latest intent"); |
| 702 | await flushPromises(); |
| 703 | }); |
| 704 | eq(composerProfileCalls, orderedProfileCallsBefore + 1, "different composer profiles are serialized per tab"); |
| 705 | composerProfileDrain = latestProfileDrain; |
| 706 | await act(async () => { |
| 707 | olderProfileDrain.resolve([]); |
| 708 | await olderProfile; |
| 709 | await flushPromises(); |
| 710 | }); |
| 711 | eq(composerProfileCalls, orderedProfileCallsBefore + 2, "latest composer profile starts after the older transaction"); |
| 712 | await act(async () => { |
| 713 | latestProfileDrain.resolve([]); |
| 714 | await latestProfile; |
| 715 | await flushPromises(); |
| 716 | }); |
| 717 | |
| 718 | composerProfileDrain = undefined; |
| 719 | rejectNextComposerProfile = true; |
| 720 | const retryCallsBefore = composerProfileCalls; |
| 721 | let failedProfile = true; |
| 722 | let retriedProfile = false; |
| 723 | await act(async () => { |
| 724 | failedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal"); |
| 725 | retriedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal"); |
| 726 | await flushPromises(); |
| 727 | }); |
| 728 | eq(failedProfile, false, "failed composer profile transaction blocks the caller"); |
| 729 | eq(retriedProfile, true, "failed composer profile transaction remains retryable"); |
| 730 | eq(composerProfileCalls, retryCallsBefore + 2, "failed profile key is not cached as applied"); |
| 731 | } |
| 732 | |
| 733 | await act(async () => { |
| 734 | root.unmount(); |
| 735 | }); |
| 736 | dom.window.close(); |
| 737 | |
| 738 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 739 | if (failed > 0) process.exit(1); |
| 740 |