返回 DeepSeek-Reasonix
tab-switch-hydration.test.tsx
根目录 / desktop / frontend / src / __tests__ / tab-switch-hydration.test.tsx
1 // Run: tsx src/__tests__/tab-switch-hydration.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { AppBindings } from "../lib/bridge";
7 import { useController } from "../lib/useController";
8 import { verifyDeferredHistoryCloseRace, verifyStaleHistoryFingerprint } from "./deferred-history-close-race";
9 import { historySliceFromMessages } from "./mockHistorySlice";
10 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
11 import { installDesktopHostStub } from "./desktopHostStub";
12 import { verifyExplicitTranscriptRetry } from "./helpers/explicitTranscriptRetry";
13 import { flushPromises, verifyEarlyReadableHistory, verifyDetachedPinRelease } from "./helpers/earlyReadableHistory";
14
15 let passed = 0;
16 let failed = 0;
17
18 function ok(value: boolean, label: string) {
19 if (value) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}\n`);
24 failed += 1;
25 }
26 }
27
28 function eq(actual: unknown, expected: unknown, label: string) {
29 ok(actual === expected, actual === expected ? label : `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
30 }
31
32 function deferred<T>() {
33 let resolve!: (value: T) => void;
34 let reject!: (reason?: unknown) => void;
35 const promise = new Promise<T>((res, rej) => {
36 resolve = res;
37 reject = rej;
38 });
39 return { promise, resolve, reject };
40 }
41
42 async function waitFor(label: string, predicate: () => boolean) {
43 for (let attempt = 0; attempt < 30; attempt += 1) {
44 await act(async () => {
45 await flushPromises();
46 });
47 if (predicate()) return;
48 }
49 throw new Error(`timed out waiting for ${label}`);
50 }
51
52 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
53 const workspaceRoot = `/repo/${id}`;
54 return {
55 id,
56 scope: "project",
57 workspaceRoot,
58 workspaceName: id,
59 workspacePath: workspaceRoot,
60 gitBranch: "main",
61 topicId: `topic-${id}`,
62 topicTitle: id,
63 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
64 label: `model-${id}`,
65 ready: true,
66 running: false,
67 mode: "normal",
68 toolApprovalMode: "ask",
69 tokenMode: "full",
70 active: false,
71 cwd: workspaceRoot,
72 ...overrides,
73 };
74 }
75
76 function metaFor(tab: TabMeta): Meta {
77 return {
78 label: tab.label,
79 ready: tab.ready,
80 startupErr: tab.startupErr,
81 eventChannel: "agent:event",
82 cwd: tab.cwd || tab.workspaceRoot,
83 workspaceRoot: tab.workspaceRoot,
84 workspaceName: tab.workspaceName,
85 workspacePath: tab.workspacePath,
86 sessionPath: tab.sessionPath,
87 sessionRevision: tab.sessionRevision,
88 sessionDigest: tab.sessionDigest,
89 gitBranch: tab.gitBranch,
90 autoApproveTools: false,
91 bypass: false,
92 collaborationMode: tab.collaborationMode ?? "normal",
93 toolApprovalMode: tab.toolApprovalMode ?? "ask",
94 tokenMode: tab.tokenMode ?? "full",
95 goal: "",
96 goalStatus: "stopped",
97 };
98 }
99
100 function userMessage(content: string): HistoryMessage {
101 return { role: "user", content };
102 }
103
104 console.log("\ntab switch hydration");
105
106 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
107 pretendToBeVisual: true,
108 url: "http://localhost/",
109 });
110 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
111 globalThis.window = dom.window as unknown as Window & typeof globalThis;
112 globalThis.document = dom.window.document;
113 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
114 globalThis.Node = dom.window.Node;
115 globalThis.HTMLElement = dom.window.HTMLElement;
116 globalThis.Event = dom.window.Event;
117 globalThis.CustomEvent = dom.window.CustomEvent;
118 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
119 globalThis.MouseEvent = dom.window.MouseEvent;
120 globalThis.localStorage = dom.window.localStorage;
121 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
122 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
123 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
124 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
125 const balance: BalanceInfo = { available: false, display: "" };
126 const jobs: JobView[] = [];
127 const checkpoints: CheckpointMeta[] = [];
128 const tabA = tabMeta("tab-a", { active: true });
129 const tabB = tabMeta("tab-b");
130 const tabC = tabMeta("tab-c");
131 const tabD = tabMeta("tab-d");
132 const tabE = tabMeta("tab-e", { turnStartedAt: Date.now() - 45_000 });
133 const tabF = tabMeta("tab-f");
134 const tabG = tabMeta("tab-g");
135 const tabH = tabMeta("tab-h");
136 const tabI = tabMeta("tab-i", { running: true, pendingPrompt: true, cancellable: true });
137 const tabJ = tabMeta("tab-j");
138 const tabK = tabMeta("tab-k", { sessionRevision: 1, sessionDigest: "digest-k-v1" });
139 const tabL = tabMeta("tab-l", { sessionRevision: 1, sessionDigest: "digest-l-v1" });
140 const tabM = tabMeta("tab-m", { sessionRevision: 2, sessionDigest: "digest-m-v2" });
141 const tabN = tabMeta("tab-n", { sessionRevision: 2, sessionDigest: "digest-n-v2" });
142 const tabO = tabMeta("tab-o");
143 let backendActiveId = "tab-a";
144 const historyB = deferred<HistoryMessage[]>();
145 const historyD = deferred<HistoryMessage[]>();
146 let metaH = deferred<Meta>();
147 let historyH = deferred<HistoryMessage[]>();
148 let historyLOlder = deferred<HistorySlice>();
149 const contextDGate = deferred<ContextInfo>();
150 const setActiveBGate = deferred<void>();
151 const setActiveEGate = deferred<void>();
152 const setActiveFGate = deferred<void>();
153 const staleSwitchFGate = deferred<void>();
154 const staleSwitchReassertGGate = deferred<void>();
155 const submitTabCGate = deferred<void>();
156 let tabCSubmissionId = "";
157 const forkResultGate = deferred<void>();
158 const staleForkResultGate = deferred<void>();
159 const staleForkReassertGGate = deferred<void>();
160 const historyCalls: string[] = [];
161 let historyLCalls = 0;
162 let historyMCalls = 0;
163 const contextMGate = deferred<ContextInfo>();
164 let contextMCalls = 0;
165 let historyNCalls = 0;
166 let startTabNDuringMeta = false;
167 const cancelCalls: string[] = [];
168 let contextDCalls = 0;
169 let metaHCalls = 0;
170 let holdNextContextForD = false;
171 let holdNextMetaForH = false;
172 let holdNextHistoryForH = false;
173 let setActiveCalls = 0;
174 let newSessionCalls = 0;
175 const newSessionTargets: string[] = [];
176 let replayPendingPromptCalls = 0;
177 let failSetActiveFor = "";
178 let holdNextForkResult = false;
179 let forkStarted = false;
180 let holdStaleSwitchF = false;
181 let holdStaleSwitchReassertG = false;
182 let staleSwitchReassertGStarted = false;
183 let holdStaleForkResult = false;
184 let staleForkStarted = false;
185 let holdStaleForkReassertG = false;
186 let staleForkReassertGStarted = false;
187 const runningTabs = new Set<string>();
188 const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN, tabO].map((tab) => [tab.id, tab]));
189
190 function currentTabs(): TabMeta[] {
191 return Array.from(tabsById.values()).map((tab) => {
192 const running = runningTabs.has(tab.id);
193 return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
194 });
195 }
196
197 const appStubTable = ({
198 main: {
199 App: {
200 RegisterNavigationIntent: async () => {},
201 ListTabs: async () => currentTabs(),
202 MetaForTab: async (tabID: string) => {
203 if (tabID === "tab-h" && holdNextMetaForH) {
204 metaHCalls += 1;
205 holdNextMetaForH = false;
206 return metaH.promise;
207 }
208 if (tabID === "tab-n" && startTabNDuringMeta) {
209 startTabNDuringMeta = false;
210 runningTabs.add(tabID);
211 desktopStub.emit("agent:event", { kind: "turn_started", tabId: tabID });
212 }
213 return metaFor(tabsById.get(tabID) ?? tabA);
214 },
215 ContextUsageForTab: async (tabID: string) => {
216 if (tabID === "tab-m" && ++contextMCalls === 1) return contextMGate.promise;
217 if (tabID === "tab-d" && holdNextContextForD) {
218 contextDCalls += 1;
219 holdNextContextForD = false;
220 return contextDGate.promise;
221 }
222 if (tabID === "tab-d") contextDCalls += 1;
223 return context;
224 },
225 EffortForTab: async () => effort,
226 BalanceForTab: async () => balance,
227 JobsForTab: async () => jobs,
228 CheckpointsForTab: async () => checkpoints,
229 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
230 HistoryForTab: async (tabID: string) => {
231 historyCalls.push(tabID);
232 if (tabID === "tab-o") { failSetActiveFor = "tab-a"; throw new Error("history failed at /private/session.jsonl"); }
233 if (tabID === "tab-b") return newSessionTargets.includes(tabID) ? [] : historyB.promise;
234 if (tabID === "tab-d") return historyD.promise;
235 if (tabID === "tab-e") return [userMessage("fork E")];
236 if (tabID === "tab-g") return [userMessage("history G")];
237 if (tabID === "tab-h" && holdNextHistoryForH) {
238 holdNextHistoryForH = false;
239 return historyH.promise;
240 }
241 if (tabID === "tab-h") return [userMessage("history H")];
242 if (tabID === "tab-i") return [userMessage("fork I")];
243 if (tabID === "tab-j") return [userMessage("fork J")];
244 if (tabID === "tab-k") {
245 const revision = tabsById.get("tab-k")?.sessionRevision;
246 return [userMessage(revision === 2 ? "history K v2" : "history K v1")];
247 }
248 return [userMessage("cached A")];
249 },
250 HistoryPageForTab: async (tabID: string) => {
251 const messages = await appStubTable.HistoryForTab(tabID);
252 const tab = tabsById.get(tabID);
253 return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false, revision: tab?.sessionRevision, digest: tab?.sessionDigest };
254 },
255 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
256 if (tabID === "tab-n") {
257 historyNCalls += 1;
258 const revision = historyNCalls === 1 ? 1 : 2;
259 const digest = historyNCalls === 1 ? "digest-n-v1" : "digest-n-v2";
260 return historySliceFromMessages(tabID, [userMessage(historyNCalls === 1 ? "stale N v1" : "history N v2")], req, { revision, digest });
261 }
262 if (tabID === "tab-m") {
263 historyMCalls += 1;
264 const revision = historyMCalls === 1 ? 1 : 2;
265 const digest = historyMCalls === 1 ? "digest-m-v1" : "digest-m-v2";
266 return historySliceFromMessages(tabID, [userMessage(historyMCalls === 1 ? "stale M v1" : "history M v2")], req, { revision, digest });
267 }
268 if (tabID === "tab-l") {
269 historyLCalls += 1;
270 if (req.cursor) return historyLOlder.promise;
271 const latest = historySliceFromMessages(tabID, [userMessage("newest L")], req, { revision: 1, digest: "digest-l-v1" });
272 return {
273 ...latest,
274 entries: latest.entries.map((entry) => ({ ...entry, entryId: "smock-tab-l:r1:m3:o0", order: 3, turn: 4 })),
275 hasOlder: true,
276 totalTurns: 4,
277 startTurn: 4,
278 endTurn: 4,
279 nextCursor: btoa(JSON.stringify({ v: 1, before: 3 })),
280 };
281 }
282 const messages = await appStubTable.HistoryForTab(tabID);
283 const tab = tabsById.get(tabID);
284 return historySliceFromMessages(tabID, messages, req, { revision: tab?.sessionRevision, digest: tab?.sessionDigest });
285 },
286 HistoryCheckpointTurnsForTab: async () => [],
287 OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
288 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
289 backendActiveId = target.id;
290 return { ...target, active: true };
291 },
292 ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
293 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
294 backendActiveId = target.id;
295 return { ...target, active: true };
296 },
297 StartTopicActivation: async (req: TopicActivationRequest) => {
298 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === req.workspaceRoot && tab.topicId === req.topicId) ?? tabG;
299 backendActiveId = target.id;
300 const requestId = req.requestId || "mock-activation";
301 window.setTimeout(() => {
302 desktopStub.emit("topic:activation", { requestId, tabId: target.id, phase: "starting" });
303 desktopStub.emit("topic:activation", { requestId, tabId: target.id, phase: "ready" });
304 }, 0);
305 return { requestId, tabId: target.id, meta: { ...target, active: true } };
306 },
307 NewSession: async () => {
308 newSessionCalls += 1;
309 },
310 NewSessionForTab: async (tabID: string) => {
311 newSessionCalls += 1;
312 newSessionTargets.push(tabID);
313 },
314 Fork: async () => {
315 tabsById.set("tab-e", tabE);
316 backendActiveId = "tab-e";
317 runningTabs.add("tab-e");
318 return { ...tabE, active: true, running: true };
319 },
320 ForkForTab: async () => {
321 const fork = holdNextForkResult || holdStaleForkResult ? tabJ : tabE;
322 tabsById.set(fork.id, fork);
323 backendActiveId = fork.id;
324 runningTabs.add(fork.id);
325 if (holdNextForkResult) {
326 holdNextForkResult = false;
327 forkStarted = true;
328 await forkResultGate.promise;
329 }
330 if (holdStaleForkResult) {
331 holdStaleForkResult = false;
332 staleForkStarted = true;
333 await staleForkResultGate.promise;
334 }
335 return { ...fork, active: true, running: true };
336 },
337 ReplayPendingPrompts: async () => {
338 replayPendingPromptCalls += 1;
339 const active = tabsById.get(backendActiveId);
340 if (!active?.pendingPrompt) return;
341 desktopStub.emit("agent:event", {
342 kind: "approval_request",
343 tabId: backendActiveId,
344 approval: { id: `pending-${backendActiveId}`, tool: "bash", subject: `pending ${backendActiveId}` },
345 });
346 },
347 SetActiveTab: async (tabID: string) => {
348 setActiveCalls += 1;
349 if (tabID === "tab-b") await setActiveBGate.promise;
350 if (tabID === "tab-e") await setActiveEGate.promise;
351 if (tabID === "tab-f") await setActiveFGate.promise;
352 if (tabID === "tab-f" && holdStaleSwitchF) {
353 holdStaleSwitchF = false;
354 await staleSwitchFGate.promise;
355 }
356 if (tabID === "tab-g" && holdStaleSwitchReassertG) {
357 holdStaleSwitchReassertG = false;
358 staleSwitchReassertGStarted = true;
359 await staleSwitchReassertGGate.promise;
360 }
361 if (tabID === "tab-g" && holdStaleForkReassertG) {
362 holdStaleForkReassertG = false;
363 staleForkReassertGStarted = true;
364 await staleForkReassertGGate.promise;
365 }
366 if (tabID === failSetActiveFor) throw new Error("persist failed");
367 backendActiveId = tabID;
368 },
369 CancelTab: async (tabID: string) => {
370 cancelCalls.push(tabID);
371 runningTabs.delete(tabID);
372 },
373 CloseTabWithPolicy: async (tabID: string) => {
374 tabsById.delete(tabID);
375 runningTabs.delete(tabID);
376 if (backendActiveId === tabID) backendActiveId = "tab-a";
377 },
378 SubmitToTab: async (tabID: string) => {
379 runningTabs.add(tabID);
380 if (tabID === "tab-c") await submitTabCGate.promise;
381 },
382 SubmitToTabWithID: async (tabID: string, _input: string, submissionID: string) => {
383 runningTabs.add(tabID);
384 if (tabID === "tab-c") tabCSubmissionId = submissionID;
385 if (tabID === "tab-c") await submitTabCGate.promise;
386 },
387 SubmitDisplayToTab: async (tabID: string) => {
388 runningTabs.add(tabID);
389 },
390 SubmitDisplayToTabWithID: async (tabID: string) => {
391 runningTabs.add(tabID);
392 },
393 } as Partial<AppBindings> as AppBindings,
394 },
395 }).main.App;
396 const desktopStub = installDesktopHostStub(appStubTable);
397
398 type Controller = ReturnType<typeof useController>;
399 let controller: Controller | undefined;
400
401 function Probe() {
402 controller = useController();
403 return null;
404 }
405
406 const rootEl = document.getElementById("root");
407 if (!rootEl) throw new Error("missing root");
408 const root = createRoot(rootEl);
409
410 await act(async () => {
411 root.render(<Probe />);
412 await flushPromises();
413 });
414 await waitFor("initial active tab", () => controller?.activeTabId === "tab-a" && controller.state.items.length === 1);
415
416 await act(async () => {
417 desktopStub.emit("agent:event", { kind: "approval_request", tabId: "tab-b", approval: { id: "stale-tab-b", tool: "bash", subject: "stale tab B" } });
418 await flushPromises();
419 });
420
421 let switchToB: Promise<TabMeta[] | undefined> | undefined;
422 await act(async () => {
423 switchToB = controller?.switchTab("tab-b", tabB);
424 await flushPromises();
425 });
426
427 eq(setActiveCalls, 1, "SetActiveTab is called for the selected tab");
428 eq(controller?.activeTabId, "tab-b", "switchTab updates the active tab before backend activation resolves");
429 eq(controller?.state.meta?.label, "model-tab-b", "switchTab applies optimistic tab metadata immediately");
430 eq(controller?.state.items.length, 0, "uncached target tab does not keep the previous transcript visible");
431 eq(controller?.state.hydrating, true, "target tab shows lightweight hydration state while backend activation is pending");
432 eq(controller?.state.backendActivationPending, true, "target tab gates unscoped actions while backend activation is pending");
433 ok(historyCalls.includes("tab-b"), "HistoryForTab starts before SetActiveTab completes");
434 eq(controller?.state.approval?.id, undefined, "tab activation clears a stale approval already stored on the target tab");
435 eq(controller?.state.running, false, "tab activation clears the stale prompt lifecycle before backend status arrives");
436
437 await verifyEarlyReadableHistory(() => controller, () => historyB.resolve([userMessage("early B")]), flushPromises, waitFor);
438 await act(async () => {
439 desktopStub.emit("agent:event", { kind: "approval_request", approval: { id: "old-backend-approval", tool: "bash", subject: "old backend approval" } });
440 await flushPromises();
441 });
442 eq(controller?.state.approval?.id, undefined, "tab-less events stay with the confirmed backend tab during optimistic activation");
443 eq(controller?.state.running, false, "tab-less old-backend prompts cannot lock the optimistic target tab");
444
445 let newSessionWhileSwitching: Promise<void> | undefined;
446 await act(async () => {
447 newSessionWhileSwitching = controller?.newSession();
448 await flushPromises();
449 });
450 eq(newSessionCalls, 1, "newSession can target the selected tab before backend focus activation settles");
451 eq(newSessionTargets.join(","), "tab-b", "newSession keeps the selected tab as its explicit target");
452
453 await act(async () => {
454 setActiveBGate.resolve();
455 await newSessionWhileSwitching;
456 await flushPromises();
457 });
458 eq(newSessionCalls, 1, "backend focus completion does not duplicate the scoped new-session action");
459 await waitFor("tab-b history request", () => historyCalls.includes("tab-b"));
460
461 const historyCallsBeforeReturnToA = historyCalls.length;
462 await act(async () => {
463 await controller?.switchTab("tab-a", tabA);
464 await flushPromises();
465 });
466 await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
467 eq(historyCalls.length, historyCallsBeforeReturnToA + 2, "unfingerprinted reselect reads a safe baseline before installing the Follow cut");
468
469 await act(async () => {
470 await switchToB;
471 await flushPromises();
472 });
473
474 eq(controller?.activeTabId, "tab-a", "completed activation for another tab does not change the active tab");
475 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "completed activation for another tab does not overwrite the active transcript");
476 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "early B") ?? false), "early history stays scoped to its tab state");
477
478 const historyCallsBeforeFallbackSync = historyCalls.length;
479 await act(async () => {
480 desktopStub.emit("agent:event", { kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
481 await flushPromises();
482 });
483 backendActiveId = "tab-b";
484 await act(async () => {
485 await controller?.syncActiveTab(false);
486 await flushPromises();
487 });
488 eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
489 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "new session does not restore the previous binding's late history");
490 eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
491 eq(controller?.state.approval?.id, "stale-fallback-approval", "metadata cannot remove a backend pending approval");
492 eq(controller?.state.running, true, "pending approval remains active until backend resolution");
493 await act(async () => {
494 await controller?.switchTab("tab-a", tabA);
495 await flushPromises();
496 });
497 await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
498
499 runningTabs.add("tab-e");
500 let switchToE: Promise<TabMeta[] | undefined> | undefined;
501 await act(async () => {
502 switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
503 await flushPromises();
504 });
505 eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
506 eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
507 eq(controller?.state.turnStartAt, tabE.turnStartedAt, "backend-running tab restores the original turn timer before activation settles");
508 eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
509 await act(async () => {
510 controller?.cancel();
511 await flushPromises();
512 });
513 eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
514 await act(async () => {
515 setActiveEGate.resolve();
516 await switchToE;
517 await flushPromises();
518 });
519 eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
520 await act(async () => {
521 await controller?.switchTab("tab-a", tabA);
522 await flushPromises();
523 });
524 await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
525
526 runningTabs.add("tab-i");
527 const replayCallsBeforePendingSwitch = replayPendingPromptCalls;
528 await act(async () => {
529 await controller?.switchTab("tab-i", tabI);
530 await flushPromises();
531 });
532 eq(controller?.activeTabId, "tab-i", "switching to a prompt-blocked tab activates the requested tab");
533 ok(replayPendingPromptCalls > replayCallsBeforePendingSwitch, "pending backend prompts are replayed after tab activation");
534 eq(controller?.state.approval?.id, "pending-tab-i", "a genuine pending approval survives the later hydration start");
535 eq(controller?.state.running, true, "a genuine pending approval keeps the target tab running");
536 tabsById.set("tab-i", { ...tabI, pendingPrompt: false, running: false, cancellable: false });
537 runningTabs.delete("tab-i");
538 await act(async () => {
539 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-i" });
540 await controller?.switchTab("tab-a", tabA);
541 await flushPromises();
542 });
543 await waitFor("tab-a restored after pending-prompt switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
544
545 let switchToF: Promise<TabMeta[] | undefined> | undefined;
546 await act(async () => {
547 switchToF = controller?.switchTab("tab-f", tabF);
548 await flushPromises();
549 });
550 eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
551 let switchToG: Promise<TabMeta[] | undefined> | undefined;
552 await act(async () => {
553 switchToG = controller?.switchTab("tab-g", tabG);
554 await switchToG;
555 await flushPromises();
556 });
557 eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
558 eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
559 await act(async () => {
560 setActiveFGate.resolve();
561 await switchToF;
562 await flushPromises();
563 });
564 eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
565 eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
566 ok(historyCalls.includes("tab-f"), "the first rapid switch may start an early history read");
567 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false, "late completion from the first rapid switch cannot overwrite the winning transcript");
568
569 await act(async () => {
570 await controller?.switchTab("tab-a", tabA);
571 await flushPromises();
572 });
573 await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
574
575 failSetActiveFor = "tab-b";
576 const historyCallsBeforeFailedSwitch = historyCalls.length;
577 await act(async () => {
578 await controller?.switchTab("tab-b", tabB);
579 await flushPromises();
580 });
581 eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
582 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
583 ok(historyCalls.length >= historyCallsBeforeFailedSwitch, "failed backend tab switch may prime only the rejected target's bounded history");
584 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "early B") ?? false), "failed backend tab switch never exposes the rejected target history");
585 failSetActiveFor = "";
586
587 await act(async () => { await controller?.switchTab("tab-o", tabO); await flushPromises(); });
588 await waitFor("failed history and failed source restore settle on target", () => controller?.activeTabId === "tab-o" && Boolean(controller.state.hydrateError));
589 eq(controller?.activeTabId, "tab-o", "failed source rebind does not falsely restore the frontend source");
590 eq(backendActiveId, "tab-o", "failed source rebind keeps frontend and backend on the same target");
591 failSetActiveFor = "";
592 await act(async () => { await controller?.switchTab("tab-a", tabA); await flushPromises(); });
593 await waitFor("tab-a restored after failed source rebind", () => controller?.activeTabId === "tab-a");
594
595 await act(async () => {
596 desktopStub.emit("agent:event", { kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
597 desktopStub.emit("agent:event", { kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
598 await flushPromises();
599 });
600 await waitFor("cached planner transcript", () =>
601 controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false
602 );
603 const historyCallsBeforeReady = historyCalls.length;
604 await act(async () => {
605 desktopStub.emit("agent:ready", );
606 await flushPromises();
607 });
608 await waitFor("ready hydration settled", () => controller?.state.hydrating === false);
609 eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transcript skips executor-only history hydration");
610 ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
611 ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
612
613 let tabCSendResolved = false, tabCSendPromise: Promise<void> | undefined;
614 await act(async () => {
615 tabCSendPromise = controller?.sendToTab("tab-c", "streaming C");
616 tabCSendPromise?.then(() => { tabCSendResolved = true; });
617 await flushPromises();
618 });
619 eq(tabCSendResolved, false, "sendToTab waits for backend admission before resolving");
620 await act(async () => {
621 await controller?.switchTab("tab-c", tabC);
622 await flushPromises();
623 });
624 eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
625 const tabCUser = Object.values(controller?.state.localSubmissions ?? {}).find((submission) => submission.text === "streaming C");
626 ok(Boolean(tabCUser), "cached running tab keeps its optimistic transcript");
627 eq(tabCUser?.submissionId, tabCSubmissionId, "the backend receives the same opaque correlation stored on the local submission");
628 ok(Boolean(tabCSubmissionId) && tabCSubmissionId !== tabCUser?.localId, "opaque submission correlation is distinct from the presentation item id");
629 ok(historyCalls.includes("tab-c"), "a running tab with no history page of its own still hydrates one");
630 await act(async () => {
631 submitTabCGate.resolve();
632 await Promise.all([submitTabCGate.promise, tabCSendPromise]);
633 await flushPromises();
634 });
635 eq(tabCSendResolved, true, "sendToTab resolves after backend admission succeeds");
636
637 holdNextContextForD = true;
638 await act(async () => {
639 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
640 await flushPromises();
641 });
642 eq(controller?.activeTabId, "tab-d", "openProjectTab activates the opened tab");
643 eq(controller?.state.items.length, 0, "open topic keeps the new tab transcript empty while hydrating");
644 eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "cross-session open never reuses the source transcript as a placeholder");
645
646 await act(async () => {
647 historyD.resolve([userMessage("history D")]);
648 await historyD.promise;
649 await flushPromises();
650 });
651 eq(controller?.state.hydrating, false, "topic history clears visible hydration before ancillary phase 2 settles");
652 await waitFor("open topic phase 2 started", () => contextDCalls === 1);
653 const contextCallsBeforeReadyD = contextDCalls;
654 const historyCallsBeforeReadyD = historyCalls.length;
655 await act(async () => {
656 desktopStub.emit("agent:ready", "tab-b");
657 desktopStub.emit("agent:ready", "tab-d");
658 desktopStub.emit("agent:ready", );
659 await flushPromises();
660 });
661 eq(contextDCalls, contextCallsBeforeReadyD, "agent ready reuses in-flight open-topic hydration for the active tab");
662 eq(historyCalls.length, historyCallsBeforeReadyD, "background ready events do not hydrate the active tab");
663 await act(async () => {
664 contextDGate.resolve(context);
665 await contextDGate.promise;
666 await flushPromises();
667 });
668 eq(contextDCalls, 1, "open topic hydration issues one context request");
669 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "topic history replaces the hydration placeholder");
670 eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
671
672 const historyCallsBeforeReopenD = historyCalls.length;
673 await act(async () => {
674 desktopStub.emit("agent:event", { kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
675 await controller?.switchTab("tab-a", tabA);
676 await flushPromises();
677 });
678 await act(async () => {
679 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
680 await flushPromises();
681 });
682 eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
683 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
684 eq(historyCalls.length, historyCallsBeforeReopenD + 3, "unfingerprinted topic navigation reads safe baselines before each Follow cut");
685 eq(controller?.state.approval?.id, "stale-approval", "reopening preserves backend pending approval");
686 eq(controller?.state.running, true, "reopening preserves the pending runtime state");
687
688 await act(async () => {
689 await controller?.rewind(0, "fork");
690 await flushPromises();
691 });
692 eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
693 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
694 eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
695 runningTabs.delete("tab-e");
696
697 const contextCallsBeforeInactiveD = contextDCalls;
698 await act(async () => {
699 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
700 await controller?.switchTab("tab-a", tabA);
701 await flushPromises();
702 });
703 await act(async () => {
704 await flushPromises();
705 await flushPromises();
706 });
707 eq(contextDCalls, contextCallsBeforeInactiveD, "inactive topic skips ancillary hydration after quick tab switch");
708
709 holdNextForkResult = true;
710 let delayedFork: Promise<boolean> | undefined;
711 await act(async () => {
712 delayedFork = controller?.rewind(0, "fork");
713 await flushPromises();
714 });
715 await waitFor("delayed fork result", () => forkStarted && backendActiveId === "tab-j");
716 await act(async () => {
717 await controller?.switchTab("tab-d", tabD);
718 await controller?.switchTab("tab-a", tabA);
719 await flushPromises();
720 });
721 eq(controller?.activeTabId, "tab-a", "later A→D→A navigation returns to the source tab before fork completion");
722 eq(backendActiveId, "tab-a", "later A→D→A navigation owns backend focus before fork completion");
723 await act(async () => {
724 forkResultGate.resolve();
725 await delayedFork;
726 await flushPromises();
727 });
728 eq(controller?.activeTabId, "tab-a", "late fork completion does not override newer ABA navigation");
729 eq(backendActiveId, "tab-a", "late fork completion reasserts the latest backend tab");
730 ok(!historyCalls.includes("tab-j"), "stale fork result is not hydrated as the visible tab");
731 runningTabs.delete("tab-j");
732
733 tabsById.set("tab-d", { ...tabD, sessionPath: `${tabD.workspaceRoot}/sessions/next-tab-d.jsonl` });
734 const historyCallsBeforeReboundD = historyCalls.length;
735 await act(async () => {
736 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
737 await flushPromises();
738 });
739 eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
740
741 metaH = deferred<Meta>();
742 holdNextMetaForH = true;
743 const historyCallsBeforeSlowMeta = historyCalls.length;
744 await act(async () => {
745 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
746 await flushPromises();
747 });
748 await waitFor("slow meta tab hydrates history", () => controller?.activeTabId === "tab-h" && controller.state.hydrating === false
749 && (controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) && metaHCalls === 1);
750 eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
751 eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
752 await act(async () => {
753 metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
754 await metaH.promise;
755 await flushPromises();
756 });
757 await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
758
759 metaH = deferred<Meta>();
760 const metaHCallsBeforeStale = metaHCalls;
761 holdNextMetaForH = true;
762 await act(async () => {
763 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
764 await flushPromises();
765 });
766 await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
767 await act(async () => {
768 await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
769 await flushPromises();
770 });
771 await waitFor("single-surface activation replaces visible tab", () => controller?.activeTabId === "tab-g"
772 && (controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false));
773 await verifyDetachedPinRelease(() => controller, flushPromises);
774 await act(async () => {
775 metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
776 await metaH.promise;
777 await flushPromises();
778 });
779 eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
780 ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
781 historyH = deferred<HistoryMessage[]>();
782 holdNextHistoryForH = true;
783 await act(async () => {
784 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
785 await flushPromises();
786 });
787 await waitFor("reopened tab-h isolates the prior surface while loading", () =>
788 controller?.activeTabId === "tab-h" && controller.state.hydrating === true && controller.state.items.length === 0 &&
789 (controller.state.hydratePlaceholderItems?.length ?? 0) === 0
790 );
791 await act(async () => {
792 historyH.resolve([userMessage("history H after stale meta")]);
793 await historyH.promise;
794 await flushPromises();
795 });
796 await waitFor("reopened tab-h finishes after stale meta discard", () => controller?.state.hydrating === false
797 && (controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false));
798
799 // A third navigation must remain authoritative while stale repair is pending.
800 holdStaleSwitchF = true;
801 holdStaleSwitchReassertG = true;
802 let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
803 await act(async () => {
804 threeWaySwitchToF = controller?.switchTab("tab-f", tabF);
805 await flushPromises();
806 await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
807 staleSwitchFGate.resolve();
808 await flushPromises();
809 });
810 await waitFor("stale switch reassert G starts", () => staleSwitchReassertGStarted);
811 await act(async () => {
812 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
813 staleSwitchReassertGGate.resolve();
814 await threeWaySwitchToF;
815 await flushPromises();
816 });
817 eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale switch repair");
818 eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale switch repair");
819
820 holdStaleForkResult = true;
821 holdStaleForkReassertG = true;
822 let threeWayFork: Promise<boolean> | undefined;
823 await act(async () => {
824 threeWayFork = controller?.rewindForTab("tab-h", 0, "fork");
825 await flushPromises();
826 });
827 await waitFor("stale fork result", () => staleForkStarted && backendActiveId === "tab-j");
828 await act(async () => {
829 await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
830 staleForkResultGate.resolve();
831 await flushPromises();
832 });
833 await waitFor("stale fork reassert G starts", () => staleForkReassertGStarted);
834 await act(async () => {
835 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
836 staleForkReassertGGate.resolve();
837 await threeWayFork;
838 await flushPromises();
839 });
840 eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale fork repair");
841 eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
842 runningTabs.delete("tab-j");
843
844 // A changed durable fingerprint must defeat same-path cache reuse, then become reusable.
845 await act(async () => {
846 await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
847 await flushPromises();
848 });
849 await waitFor("initial fingerprinted tab hydration", () => controller?.activeTabId === "tab-k"
850 && (controller.state.items.some((item) => item.kind === "user" && item.text === "history K v1") ?? false));
851 const historyCallsBeforeFingerprintRefresh = historyCalls.length;
852 tabsById.set("tab-k", { ...tabK, sessionRevision: 2, sessionDigest: "digest-k-v2" });
853 await act(async () => {
854 await controller?.openProjectTab(tabA.workspaceRoot, tabA.topicId || "");
855 await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
856 await flushPromises();
857 });
858 await waitFor("fingerprint change reloads tab history", () => controller?.activeTabId === "tab-k"
859 && (controller.state.items.some((item) => item.kind === "user" && item.text === "history K v2") ?? false));
860 eq(historyCalls.length, historyCallsBeforeFingerprintRefresh + 2, "changed session fingerprint reloads history instead of reusing same-path cache");
861 const historyCallsAfterFingerprintRefresh = historyCalls.length;
862 await act(async () => {
863 await controller?.openProjectTab(tabA.workspaceRoot, tabA.topicId || "");
864 await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
865 await flushPromises();
866 });
867 await waitFor("matching fingerprint reuses tab history", () => controller?.activeTabId === "tab-k");
868 eq(historyCalls.length, historyCallsAfterFingerprintRefresh + 1, "navigation uses Follow rather than an unrelated metadata fingerprint");
869
870 // An older response with a stale fingerprint must not prepend into the newer page.
871 await act(async () => {
872 await controller?.openProjectTab(tabL.workspaceRoot, tabL.topicId || "");
873 await flushPromises();
874 });
875 await waitFor("fingerprinted tab-l initial page", () => controller?.activeTabId === "tab-l"
876 && (controller.state.items.some((item) => item.kind === "user" && item.text === "newest L") ?? false) && controller.state.historyHasOlder);
877 tabsById.set("tab-l", { ...tabL, sessionRevision: 2, sessionDigest: "digest-l-v2" });
878 await act(async () => {
879 await controller?.refreshMeta();
880 await flushPromises();
881 });
882 await waitFor("tab-l metadata advances", () => controller?.state.meta?.sessionRevision === 2);
883 historyLOlder = deferred<HistorySlice>();
884 await verifyStaleHistoryFingerprint({
885 olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
886 historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq,
887 getState: () => controller?.state,
888 });
889
890 historyLOlder = deferred<HistorySlice>();
891 await verifyDeferredHistoryCloseRace({
892 olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
893 closeTab: async () => Boolean(await controller?.closeTab("tab-l", "stop_and_close")),
894 historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
895 });
896
897 // An explicit retry needs a new cut even while the old cut's ancillary read is pending.
898 await act(async () => {
899 await controller?.openProjectTab(tabM.workspaceRoot, tabM.topicId || "");
900 await flushPromises();
901 });
902 await verifyExplicitTranscriptRetry({
903 controller: () => controller, historyCalls: () => historyMCalls, ancillaryCalls: () => contextMCalls,
904 releaseAncillary: () => contextMGate.resolve({ ...context, used: 99999 }), waitFor, flush: flushPromises, ok,
905 });
906
907 // A live turn that starts between durable page and metadata reads owns the transcript.
908 startTabNDuringMeta = true;
909 await act(async () => {
910 await controller?.openProjectTab(tabN.workspaceRoot, tabN.topicId || "");
911 await flushPromises();
912 });
913 await waitFor("live turn blocks durable history reconciliation", () => controller?.activeTabId === "tab-n" && controller.state.running);
914 eq(historyNCalls, 1, "a foreground turn prevents mismatched durable history from being reloaded");
915 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history N v2") ?? false), "durable reconciliation does not replace a live transcript");
916 runningTabs.delete("tab-n");
917
918 await act(async () => {
919 root.unmount();
920 });
921 dom.window.close();
922
923 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
924 if (failed > 0) process.exit(1);
925
925 lines Plain Text