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