返回 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 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
740 lines Plain Text