返回 DeepSeek-Reasonix
pending-prompt-stale-status.test.tsx
根目录 / desktop / frontend / src / __tests__ / pending-prompt-stale-status.test.tsx
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
772 lines Plain Text