返回 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 type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
9
10 let passed = 0;
11 let failed = 0;
12
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22
23 function eq(actual: unknown, expected: unknown, label: string) {
24 if (actual === expected) {
25 ok(true, label);
26 } else {
27 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
28 }
29 }
30
31 function flushPromises(): Promise<void> {
32 return new Promise((resolve) => setTimeout(resolve, 0));
33 }
34
35 function deferred<T>() {
36 let resolve!: (value: T) => void;
37 let reject!: (reason?: unknown) => void;
38 const promise = new Promise<T>((res, rej) => {
39 resolve = res;
40 reject = rej;
41 });
42 return { promise, resolve, reject };
43 }
44
45 async function waitFor(label: string, predicate: () => boolean) {
46 for (let attempt = 0; attempt < 30; attempt += 1) {
47 await act(async () => {
48 await flushPromises();
49 });
50 if (predicate()) return;
51 }
52 throw new Error(`timed out waiting for ${label}`);
53 }
54
55 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
56 const workspaceRoot = `/repo/${id}`;
57 return {
58 id,
59 scope: "project",
60 workspaceRoot,
61 workspaceName: id,
62 workspacePath: workspaceRoot,
63 gitBranch: "main",
64 topicId: `topic-${id}`,
65 topicTitle: id,
66 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
67 label: `model-${id}`,
68 ready: true,
69 running: false,
70 mode: "normal",
71 toolApprovalMode: "ask",
72 tokenMode: "full",
73 active: false,
74 cwd: workspaceRoot,
75 ...overrides,
76 };
77 }
78
79 function metaFor(tab: TabMeta): Meta {
80 return {
81 label: tab.label,
82 ready: tab.ready,
83 startupErr: tab.startupErr,
84 eventChannel: "agent:event",
85 cwd: tab.cwd || tab.workspaceRoot,
86 workspaceRoot: tab.workspaceRoot,
87 workspaceName: tab.workspaceName,
88 workspacePath: tab.workspacePath,
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
124 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
125 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
126 const balance: BalanceInfo = { available: false, display: "" };
127 const jobs: JobView[] = [];
128 const checkpoints: CheckpointMeta[] = [];
129 const tabA = tabMeta("tab-a", { active: true });
130 const tabB = tabMeta("tab-b");
131 const tabC = tabMeta("tab-c");
132 const tabD = tabMeta("tab-d");
133 const tabE = tabMeta("tab-e");
134 const tabF = tabMeta("tab-f");
135 const tabG = tabMeta("tab-g");
136 const tabH = tabMeta("tab-h");
137 const tabI = tabMeta("tab-i", { running: true, pendingPrompt: true, cancellable: true });
138 const tabJ = tabMeta("tab-j");
139 let backendActiveId = "tab-a";
140 const historyB = deferred<HistoryMessage[]>();
141 const historyD = deferred<HistoryMessage[]>();
142 let metaH = deferred<Meta>();
143 let historyH = deferred<HistoryMessage[]>();
144 const contextDGate = deferred<ContextInfo>();
145 const setActiveBGate = deferred<void>();
146 const setActiveEGate = deferred<void>();
147 const setActiveFGate = deferred<void>();
148 const staleSwitchFGate = deferred<void>();
149 const staleSwitchReassertGGate = deferred<void>();
150 const submitTabCGate = deferred<void>();
151 const forkResultGate = deferred<void>();
152 const staleForkResultGate = deferred<void>();
153 const staleForkReassertGGate = deferred<void>();
154 const historyCalls: string[] = [];
155 const cancelCalls: string[] = [];
156 let contextDCalls = 0;
157 let metaHCalls = 0;
158 let holdNextContextForD = false;
159 let holdNextMetaForH = false;
160 let holdNextHistoryForH = false;
161 let setActiveCalls = 0;
162 let newSessionCalls = 0;
163 const newSessionTargets: string[] = [];
164 let replayPendingPromptCalls = 0;
165 let failSetActiveFor = "";
166 let holdNextForkResult = false;
167 let forkStarted = false;
168 let holdStaleSwitchF = false;
169 let holdStaleSwitchReassertG = false;
170 let staleSwitchReassertGStarted = false;
171 let holdStaleForkResult = false;
172 let staleForkStarted = false;
173 let holdStaleForkReassertG = false;
174 let staleForkReassertGStarted = false;
175 const runningTabs = new Set<string>();
176 const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI].map((tab) => [tab.id, tab]));
177 const eventHandlers: Array<(e: WireEvent) => void> = [];
178 const readyHandlers: Array<(tabId?: string) => void> = [];
179
180 function currentTabs(): TabMeta[] {
181 return Array.from(tabsById.values()).map((tab) => {
182 const running = runningTabs.has(tab.id);
183 return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
184 });
185 }
186
187 window.runtime = {
188 EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
189 if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
190 if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
191 return () => {};
192 },
193 BrowserOpenURL: () => {},
194 };
195 window.go = {
196 main: {
197 App: {
198 ListTabs: async () => currentTabs(),
199 MetaForTab: async (tabID: string) => {
200 if (tabID === "tab-h" && holdNextMetaForH) {
201 metaHCalls += 1;
202 holdNextMetaForH = false;
203 return metaH.promise;
204 }
205 return metaFor(tabsById.get(tabID) ?? tabA);
206 },
207 ContextUsageForTab: async (tabID: string) => {
208 if (tabID === "tab-d" && holdNextContextForD) {
209 contextDCalls += 1;
210 holdNextContextForD = false;
211 return contextDGate.promise;
212 }
213 if (tabID === "tab-d") contextDCalls += 1;
214 return context;
215 },
216 EffortForTab: async () => effort,
217 BalanceForTab: async () => balance,
218 JobsForTab: async () => jobs,
219 CheckpointsForTab: async () => checkpoints,
220 HistoryForTab: async (tabID: string) => {
221 historyCalls.push(tabID);
222 if (tabID === "tab-b") return historyB.promise;
223 if (tabID === "tab-d") return historyD.promise;
224 if (tabID === "tab-e") return [userMessage("fork E")];
225 if (tabID === "tab-g") return [userMessage("history G")];
226 if (tabID === "tab-h" && holdNextHistoryForH) {
227 holdNextHistoryForH = false;
228 return historyH.promise;
229 }
230 if (tabID === "tab-h") return [userMessage("history H")];
231 if (tabID === "tab-i") return [userMessage("fork I")];
232 if (tabID === "tab-j") return [userMessage("fork J")];
233 return [userMessage("cached A")];
234 },
235 HistoryPageForTab: async (tabID: string) => {
236 const messages = await window.go.main.App.HistoryForTab(tabID);
237 return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
238 },
239 HistoryCheckpointTurnsForTab: async () => [],
240 OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
241 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
242 backendActiveId = target.id;
243 return { ...target, active: true };
244 },
245 ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
246 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
247 backendActiveId = target.id;
248 return { ...target, active: true };
249 },
250 NewSession: async () => {
251 newSessionCalls += 1;
252 },
253 NewSessionForTab: async (tabID: string) => {
254 newSessionCalls += 1;
255 newSessionTargets.push(tabID);
256 },
257 Fork: async () => {
258 tabsById.set("tab-e", tabE);
259 backendActiveId = "tab-e";
260 runningTabs.add("tab-e");
261 return { ...tabE, active: true, running: true };
262 },
263 ForkForTab: async () => {
264 const fork = holdNextForkResult || holdStaleForkResult ? tabJ : tabE;
265 tabsById.set(fork.id, fork);
266 backendActiveId = fork.id;
267 runningTabs.add(fork.id);
268 if (holdNextForkResult) {
269 holdNextForkResult = false;
270 forkStarted = true;
271 await forkResultGate.promise;
272 }
273 if (holdStaleForkResult) {
274 holdStaleForkResult = false;
275 staleForkStarted = true;
276 await staleForkResultGate.promise;
277 }
278 return { ...fork, active: true, running: true };
279 },
280 ReplayPendingPrompts: async () => {
281 replayPendingPromptCalls += 1;
282 const active = tabsById.get(backendActiveId);
283 if (!active?.pendingPrompt) return;
284 for (const handler of eventHandlers) {
285 handler({
286 kind: "approval_request",
287 tabId: backendActiveId,
288 approval: { id: `pending-${backendActiveId}`, tool: "bash", subject: `pending ${backendActiveId}` },
289 });
290 }
291 },
292 SetActiveTab: async (tabID: string) => {
293 setActiveCalls += 1;
294 if (tabID === "tab-b") await setActiveBGate.promise;
295 if (tabID === "tab-e") await setActiveEGate.promise;
296 if (tabID === "tab-f") await setActiveFGate.promise;
297 if (tabID === "tab-f" && holdStaleSwitchF) {
298 holdStaleSwitchF = false;
299 await staleSwitchFGate.promise;
300 }
301 if (tabID === "tab-g" && holdStaleSwitchReassertG) {
302 holdStaleSwitchReassertG = false;
303 staleSwitchReassertGStarted = true;
304 await staleSwitchReassertGGate.promise;
305 }
306 if (tabID === "tab-g" && holdStaleForkReassertG) {
307 holdStaleForkReassertG = false;
308 staleForkReassertGStarted = true;
309 await staleForkReassertGGate.promise;
310 }
311 if (tabID === failSetActiveFor) throw new Error("persist failed");
312 backendActiveId = tabID;
313 },
314 CancelTab: async (tabID: string) => {
315 cancelCalls.push(tabID);
316 runningTabs.delete(tabID);
317 },
318 SubmitToTab: async (tabID: string) => {
319 runningTabs.add(tabID);
320 if (tabID === "tab-c") await submitTabCGate.promise;
321 },
322 SubmitDisplayToTab: async (tabID: string) => {
323 runningTabs.add(tabID);
324 },
325 } as Partial<AppBindings> as AppBindings,
326 },
327 };
328
329 type Controller = ReturnType<typeof useController>;
330 let controller: Controller | undefined;
331
332 function Probe() {
333 controller = useController();
334 return null;
335 }
336
337 const rootEl = document.getElementById("root");
338 if (!rootEl) throw new Error("missing root");
339 const root = createRoot(rootEl);
340
341 await act(async () => {
342 root.render(<Probe />);
343 await flushPromises();
344 });
345 await waitFor("initial active tab", () => controller?.activeTabId === "tab-a" && controller.state.items.length === 1);
346
347 await act(async () => {
348 for (const handler of eventHandlers) {
349 handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-tab-b", tool: "bash", subject: "stale tab B" } });
350 }
351 await flushPromises();
352 });
353
354 let switchToB: Promise<TabMeta[] | undefined> | undefined;
355 await act(async () => {
356 switchToB = controller?.switchTab("tab-b", tabB);
357 await flushPromises();
358 });
359
360 eq(setActiveCalls, 1, "SetActiveTab is called for the selected tab");
361 eq(controller?.activeTabId, "tab-b", "switchTab updates the active tab before backend activation resolves");
362 eq(controller?.state.meta?.label, "model-tab-b", "switchTab applies optimistic tab metadata immediately");
363 eq(controller?.state.items.length, 0, "uncached target tab does not keep the previous transcript visible");
364 eq(controller?.state.hydrating, true, "target tab shows lightweight hydration state while backend activation is pending");
365 eq(controller?.state.backendActivationPending, true, "target tab gates unscoped actions while backend activation is pending");
366 ok(!historyCalls.includes("tab-b"), "HistoryForTab is not requested before SetActiveTab completes");
367 eq(controller?.state.approval?.id, undefined, "tab activation clears a stale approval already stored on the target tab");
368 eq(controller?.state.running, false, "tab activation clears the stale prompt lifecycle before backend status arrives");
369
370 await act(async () => {
371 for (const handler of eventHandlers) {
372 handler({ kind: "approval_request", approval: { id: "old-backend-approval", tool: "bash", subject: "old backend approval" } });
373 }
374 await flushPromises();
375 });
376 eq(controller?.state.approval?.id, undefined, "tab-less events stay with the confirmed backend tab during optimistic activation");
377 eq(controller?.state.running, false, "tab-less old-backend prompts cannot lock the optimistic target tab");
378
379 let newSessionWhileSwitching: Promise<void> | undefined;
380 await act(async () => {
381 newSessionWhileSwitching = controller?.newSession();
382 await flushPromises();
383 });
384 eq(newSessionCalls, 1, "newSession can target the selected tab before backend focus activation settles");
385 eq(newSessionTargets.join(","), "tab-b", "newSession keeps the selected tab as its explicit target");
386
387 await act(async () => {
388 setActiveBGate.resolve();
389 await switchToB;
390 await newSessionWhileSwitching;
391 await flushPromises();
392 });
393 eq(newSessionCalls, 1, "backend focus completion does not duplicate the scoped new-session action");
394 await waitFor("tab-b history request", () => historyCalls.includes("tab-b"));
395
396 const historyCallsBeforeReturnToA = historyCalls.length;
397 await act(async () => {
398 await controller?.switchTab("tab-a", tabA);
399 await flushPromises();
400 });
401 await waitFor("tab-a restored", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
402 eq(historyCalls.length, historyCallsBeforeReturnToA, "cached idle tab skips history hydration when reselected");
403
404 await act(async () => {
405 historyB.resolve([userMessage("late B")]);
406 await historyB.promise;
407 await flushPromises();
408 });
409
410 eq(controller?.activeTabId, "tab-a", "late history for another tab does not change the active tab");
411 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
412 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
413
414 const historyCallsBeforeFallbackSync = historyCalls.length;
415 await act(async () => {
416 for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
417 await flushPromises();
418 });
419 backendActiveId = "tab-b";
420 await act(async () => {
421 await controller?.syncActiveTab(false);
422 await flushPromises();
423 });
424 eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
425 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
426 eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
427 eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
428 eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
429 await act(async () => {
430 await controller?.switchTab("tab-a", tabA);
431 await flushPromises();
432 });
433 await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
434
435 runningTabs.add("tab-e");
436 let switchToE: Promise<TabMeta[] | undefined> | undefined;
437 await act(async () => {
438 switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
439 await flushPromises();
440 });
441 eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
442 eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
443 eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
444 await act(async () => {
445 controller?.cancel();
446 await flushPromises();
447 });
448 eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
449 await act(async () => {
450 setActiveEGate.resolve();
451 await switchToE;
452 await flushPromises();
453 });
454 eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
455 await act(async () => {
456 await controller?.switchTab("tab-a", tabA);
457 await flushPromises();
458 });
459 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"));
460
461 runningTabs.add("tab-i");
462 const replayCallsBeforePendingSwitch = replayPendingPromptCalls;
463 await act(async () => {
464 await controller?.switchTab("tab-i", tabI);
465 await flushPromises();
466 });
467 eq(controller?.activeTabId, "tab-i", "switching to a prompt-blocked tab activates the requested tab");
468 ok(replayPendingPromptCalls > replayCallsBeforePendingSwitch, "pending backend prompts are replayed after tab activation");
469 eq(controller?.state.approval?.id, "pending-tab-i", "a genuine pending approval survives the later hydration start");
470 eq(controller?.state.running, true, "a genuine pending approval keeps the target tab running");
471 tabsById.set("tab-i", { ...tabI, pendingPrompt: false, running: false, cancellable: false });
472 runningTabs.delete("tab-i");
473 await act(async () => {
474 for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-i" });
475 await controller?.switchTab("tab-a", tabA);
476 await flushPromises();
477 });
478 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"));
479
480 let switchToF: Promise<TabMeta[] | undefined> | undefined;
481 await act(async () => {
482 switchToF = controller?.switchTab("tab-f", tabF);
483 await flushPromises();
484 });
485 eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
486 let switchToG: Promise<TabMeta[] | undefined> | undefined;
487 await act(async () => {
488 switchToG = controller?.switchTab("tab-g", tabG);
489 await switchToG;
490 await flushPromises();
491 });
492 eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
493 eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
494 await act(async () => {
495 setActiveFGate.resolve();
496 await switchToF;
497 await flushPromises();
498 });
499 eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
500 eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
501 ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
502
503 await act(async () => {
504 await controller?.switchTab("tab-a", tabA);
505 await flushPromises();
506 });
507 await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
508
509 failSetActiveFor = "tab-b";
510 const historyCallsBeforeFailedSwitch = historyCalls.length;
511 await act(async () => {
512 await controller?.switchTab("tab-b", tabB);
513 await flushPromises();
514 });
515 eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
516 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
517 eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
518 failSetActiveFor = "";
519
520 await act(async () => {
521 for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
522 for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
523 await flushPromises();
524 });
525 await waitFor("cached planner transcript", () =>
526 controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false
527 );
528 const historyCallsBeforeReady = historyCalls.length;
529 await act(async () => {
530 for (const handler of readyHandlers) handler();
531 await flushPromises();
532 });
533 await waitFor("ready hydration settled", () => controller?.state.hydrating === false);
534 eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transcript skips executor-only history hydration");
535 ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
536 ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
537
538 let tabCSendResolved = false;
539 await act(async () => {
540 const sendPromise = controller?.sendToTab("tab-c", "streaming C");
541 sendPromise?.then(() => {
542 tabCSendResolved = true;
543 });
544 await flushPromises();
545 });
546 eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
547 await act(async () => {
548 await controller?.switchTab("tab-c", tabC);
549 await flushPromises();
550 });
551 eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
552 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
553 ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
554 await act(async () => {
555 submitTabCGate.resolve();
556 await submitTabCGate.promise;
557 await flushPromises();
558 });
559
560 holdNextContextForD = true;
561 await act(async () => {
562 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
563 await flushPromises();
564 });
565 eq(controller?.activeTabId, "tab-d", "openProjectTab activates the opened tab");
566 eq(controller?.state.items.length, 0, "open topic keeps the new tab transcript empty while hydrating");
567 ok(controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "open topic stores previous transcript only as a hydration placeholder");
568
569 await act(async () => {
570 historyD.resolve([userMessage("history D")]);
571 await historyD.promise;
572 await flushPromises();
573 });
574 eq(controller?.state.hydrating, false, "topic history clears visible hydration before ancillary phase 2 settles");
575 await waitFor("open topic phase 2 started", () => contextDCalls === 1);
576 const contextCallsBeforeReadyD = contextDCalls;
577 const historyCallsBeforeReadyD = historyCalls.length;
578 await act(async () => {
579 for (const handler of readyHandlers) {
580 handler("tab-b");
581 handler("tab-d");
582 handler();
583 }
584 await flushPromises();
585 });
586 eq(contextDCalls, contextCallsBeforeReadyD, "agent ready reuses in-flight open-topic hydration for the active tab");
587 eq(historyCalls.length, historyCallsBeforeReadyD, "background ready events do not hydrate the active tab");
588 await act(async () => {
589 contextDGate.resolve(context);
590 await contextDGate.promise;
591 await flushPromises();
592 });
593 eq(contextDCalls, 1, "open topic hydration issues one context request");
594 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "topic history replaces the hydration placeholder");
595 eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
596
597 const historyCallsBeforeReopenD = historyCalls.length;
598 await act(async () => {
599 for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
600 await controller?.switchTab("tab-a", tabA);
601 await flushPromises();
602 });
603 await act(async () => {
604 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
605 await flushPromises();
606 });
607 eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
608 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
609 eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
610 eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
611 eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
612
613 await act(async () => {
614 await controller?.rewind(0, "fork");
615 await flushPromises();
616 });
617 eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
618 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
619 eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
620 runningTabs.delete("tab-e");
621
622 const contextCallsBeforeInactiveD = contextDCalls;
623 await act(async () => {
624 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
625 await controller?.switchTab("tab-a", tabA);
626 await flushPromises();
627 });
628 await act(async () => {
629 await flushPromises();
630 await flushPromises();
631 });
632 eq(contextDCalls, contextCallsBeforeInactiveD, "inactive topic skips ancillary hydration after quick tab switch");
633
634 holdNextForkResult = true;
635 let delayedFork: Promise<boolean> | undefined;
636 await act(async () => {
637 delayedFork = controller?.rewind(0, "fork");
638 await flushPromises();
639 });
640 await waitFor("delayed fork result", () => forkStarted && backendActiveId === "tab-j");
641 await act(async () => {
642 await controller?.switchTab("tab-d", tabD);
643 await controller?.switchTab("tab-a", tabA);
644 await flushPromises();
645 });
646 eq(controller?.activeTabId, "tab-a", "later A→D→A navigation returns to the source tab before fork completion");
647 eq(backendActiveId, "tab-a", "later A→D→A navigation owns backend focus before fork completion");
648 await act(async () => {
649 forkResultGate.resolve();
650 await delayedFork;
651 await flushPromises();
652 });
653 eq(controller?.activeTabId, "tab-a", "late fork completion does not override newer ABA navigation");
654 eq(backendActiveId, "tab-a", "late fork completion reasserts the latest backend tab");
655 ok(!historyCalls.includes("tab-j"), "stale fork result is not hydrated as the visible tab");
656 runningTabs.delete("tab-j");
657
658 tabsById.set("tab-d", { ...tabD, sessionPath: `${tabD.workspaceRoot}/sessions/next-tab-d.jsonl` });
659 const historyCallsBeforeReboundD = historyCalls.length;
660 await act(async () => {
661 await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
662 await flushPromises();
663 });
664 eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
665
666 metaH = deferred<Meta>();
667 holdNextMetaForH = true;
668 const historyCallsBeforeSlowMeta = historyCalls.length;
669 await act(async () => {
670 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
671 await flushPromises();
672 });
673 await waitFor("slow meta tab hydrates history", () =>
674 controller?.activeTabId === "tab-h" &&
675 controller.state.hydrating === false &&
676 (controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
677 metaHCalls === 1
678 );
679 eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
680 eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
681 await act(async () => {
682 metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
683 await metaH.promise;
684 await flushPromises();
685 });
686 await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
687
688 metaH = deferred<Meta>();
689 const metaHCallsBeforeStale = metaHCalls;
690 holdNextMetaForH = true;
691 await act(async () => {
692 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
693 await flushPromises();
694 });
695 await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
696 await act(async () => {
697 await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
698 await flushPromises();
699 });
700 await waitFor("single-surface activation replaces visible tab", () =>
701 controller?.activeTabId === "tab-g" &&
702 (controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
703 );
704 await act(async () => {
705 metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
706 await metaH.promise;
707 await flushPromises();
708 });
709 eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
710 ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
711 historyH = deferred<HistoryMessage[]>();
712 holdNextHistoryForH = true;
713 await act(async () => {
714 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
715 await flushPromises();
716 });
717 await waitFor("reopened tab-h treats stale late meta as discarded", () =>
718 controller?.activeTabId === "tab-h" &&
719 controller.state.hydrating === true &&
720 (controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
721 );
722 await act(async () => {
723 historyH.resolve([userMessage("history H after stale meta")]);
724 await historyH.promise;
725 await flushPromises();
726 });
727 await waitFor("reopened tab-h finishes after stale meta discard", () =>
728 controller?.state.hydrating === false &&
729 (controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
730 );
731
732 // A stale repair is itself asynchronous. Force a third navigation to complete
733 // while that repair is pending and verify the repair follows the newest tab.
734 holdStaleSwitchF = true;
735 holdStaleSwitchReassertG = true;
736 let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
737 await act(async () => {
738 threeWaySwitchToF = controller?.switchTab("tab-f", tabF);
739 await flushPromises();
740 await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
741 staleSwitchFGate.resolve();
742 await flushPromises();
743 });
744 await waitFor("stale switch reassert G starts", () => staleSwitchReassertGStarted);
745 await act(async () => {
746 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
747 staleSwitchReassertGGate.resolve();
748 await threeWaySwitchToF;
749 await flushPromises();
750 });
751 eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale switch repair");
752 eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale switch repair");
753
754 holdStaleForkResult = true;
755 holdStaleForkReassertG = true;
756 let threeWayFork: Promise<boolean> | undefined;
757 await act(async () => {
758 threeWayFork = controller?.rewindForTab("tab-h", 0, "fork");
759 await flushPromises();
760 });
761 await waitFor("stale fork result", () => staleForkStarted && backendActiveId === "tab-j");
762 await act(async () => {
763 await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
764 staleForkResultGate.resolve();
765 await flushPromises();
766 });
767 await waitFor("stale fork reassert G starts", () => staleForkReassertGStarted);
768 await act(async () => {
769 await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
770 staleForkReassertGGate.resolve();
771 await threeWayFork;
772 await flushPromises();
773 });
774 eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale fork repair");
775 eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
776 runningTabs.delete("tab-j");
777
778 await act(async () => {
779 root.unmount();
780 });
781 dom.window.close();
782
783 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
784 if (failed > 0) process.exit(1);
785
785 lines Plain Text