返回 DeepSeek-Reasonix
tab-switch-session-rebind.test.tsx
根目录 / desktop / frontend / src / __tests__ / tab-switch-session-rebind.test.tsx
1 // Run: tsx src/__tests__/tab-switch-session-rebind.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 { historySliceFromMessages } from "./mockHistorySlice";
9 import type {
10 BalanceInfo,
11 CheckpointMeta,
12 ContextInfo,
13 EffortInfo,
14 HistoryMessage,
15 HistorySliceRequest,
16 JobView,
17 Meta,
18 TabMeta,
19 } from "../lib/types";
20 import { installDesktopHostStub } from "./desktopHostStub";
21
22 let passed = 0;
23 let failed = 0;
24
25 function ok(value: boolean, label: string) {
26 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
27 if (value) passed += 1;
28 else failed += 1;
29 }
30
31 function eq(actual: unknown, expected: unknown, label: string) {
32 ok(actual === expected, actual === expected ? label : `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
33 }
34
35 function flushPromises(): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, 0));
37 }
38
39 function deferred<T>() {
40 let resolve!: (value: T) => void;
41 let reject!: (reason?: unknown) => void;
42 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
43 return { promise, resolve, reject };
44 }
45
46 async function waitFor(label: string, predicate: () => boolean) {
47 for (let attempt = 0; attempt < 30; attempt += 1) {
48 await act(async () => { await flushPromises(); });
49 if (predicate()) return;
50 }
51 throw new Error(`timed out waiting for ${label}`);
52 }
53
54 function tabMeta(id: string, sessionGeneration: number, active = false): TabMeta {
55 const workspaceRoot = `/repo/${id}`;
56 return {
57 id,
58 scope: "project",
59 workspaceRoot,
60 workspaceName: id,
61 workspacePath: workspaceRoot,
62 gitBranch: "main",
63 topicId: `topic-${id}`,
64 topicTitle: id,
65 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
66 sessionGeneration,
67 label: `model-${id}`,
68 ready: true,
69 running: false,
70 mode: "normal",
71 toolApprovalMode: "ask",
72 tokenMode: "full",
73 active,
74 cwd: workspaceRoot,
75 };
76 }
77
78 function metaFor(tab: TabMeta): Meta {
79 return {
80 label: tab.label,
81 ready: tab.ready,
82 eventChannel: "agent:event",
83 cwd: tab.cwd || tab.workspaceRoot,
84 workspaceRoot: tab.workspaceRoot,
85 workspaceName: tab.workspaceName,
86 workspacePath: tab.workspacePath,
87 sessionPath: tab.sessionPath,
88 sessionGeneration: tab.sessionGeneration,
89 gitBranch: tab.gitBranch,
90 autoApproveTools: false,
91 bypass: false,
92 collaborationMode: "normal",
93 toolApprovalMode: "ask",
94 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 session rebind");
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: 0, window: 100, sessionTokens: 0 };
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", 1, true);
130 const tabO = tabMeta("tab-o", 1);
131 const tabsById = new Map([tabA, tabO].map((tab) => [tab.id, tab]));
132 let backendActiveId = "tab-a";
133 let generationTwoHistory = deferred<HistoryMessage[]>();
134 let heldTabOHistory: Promise<HistoryMessage[]> | null = null;
135 let heldListTabs: Promise<TabMeta[]> | null = null;
136 let heldListTabsStarted = false;
137
138 function currentTabs(): TabMeta[] {
139 return Array.from(tabsById.values()).map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
140 }
141
142 const appStubTable = ({
143 main: {
144 App: {
145 RegisterNavigationIntent: async () => {},
146 ListTabs: async () => {
147 if (heldListTabs) {
148 const promise = heldListTabs;
149 heldListTabs = null;
150 heldListTabsStarted = true;
151 return promise;
152 }
153 return currentTabs();
154 },
155 MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
156 ContextUsageForTab: async () => context,
157 EffortForTab: async () => effort,
158 BalanceForTab: async () => balance,
159 JobsForTab: async () => jobs,
160 CheckpointsForTab: async () => checkpoints,
161 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
162 HistoryForTab: async (tabID: string) => {
163 if (tabID === "tab-o" && heldTabOHistory) {
164 // Every reader of this binding observes the same unavailable cut,
165 // including the early baseline and the later authoritative Follow.
166 return heldTabOHistory;
167 }
168 const generation = tabsById.get(tabID)?.sessionGeneration ?? 0;
169 return [userMessage(tabID === "tab-o" ? `history O generation ${generation}` : "history A")];
170 },
171 HistorySliceForTab: async (tabID: string, request: HistorySliceRequest) => {
172 const messages = await appStubTable.HistoryForTab(tabID);
173 return historySliceFromMessages(tabID, messages, request);
174 },
175 HistoryCheckpointTurnsForTab: async () => [],
176 ReplayPendingPrompts: async () => {},
177 SetActiveTab: async (tabID: string) => { backendActiveId = tabID; },
178 OpenProjectTab: async (workspaceRoot: string) => {
179 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot) ?? tabA;
180 backendActiveId = target.id;
181 return { ...target, active: true };
182 },
183 } as Partial<AppBindings> as AppBindings,
184 },
185 }).main.App;
186 const desktopStub = installDesktopHostStub(appStubTable);
187
188 type Controller = ReturnType<typeof useController>;
189 let controller: Controller | undefined;
190 function Probe() { controller = useController(); return null; }
191
192 const rootElement = document.getElementById("root");
193 if (!rootElement) throw new Error("missing root");
194 const root = createRoot(rootElement);
195
196 await act(async () => { root.render(<Probe />); await flushPromises(); });
197 await waitFor("initial session", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history A") ?? false);
198 await act(async () => { await controller?.openProjectTab(tabO.workspaceRoot, tabO.topicId || ""); await flushPromises(); });
199 await waitFor("generation one", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false);
200 await act(async () => { await controller?.openProjectTab(tabA.workspaceRoot, tabA.topicId || ""); await flushPromises(); });
201 await waitFor("source restored", () => controller?.activeTabId === "tab-a");
202
203 const reboundTabO = { ...tabO, sessionGeneration: 2 };
204 tabsById.set("tab-o", reboundTabO);
205 generationTwoHistory = deferred<HistoryMessage[]>();
206 heldTabOHistory = generationTwoHistory.promise;
207 let generationTwoSwitch: Promise<TabMeta[] | undefined> | undefined;
208 await act(async () => {
209 generationTwoSwitch = controller?.switchTab("tab-o", reboundTabO);
210 await flushPromises();
211 });
212
213 eq(controller?.activeTabId, "tab-o", "generation-rebound tab becomes the selected target");
214 eq(controller?.state.items.length, 0, "generation-rebound tab clears its prior session before history settles");
215 eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "generation-rebound tab never exposes prior-session placeholders");
216 eq(controller?.state.hydrating, true, "generation-rebound tab remains in target hydration after the App mask hands off");
217
218 await act(async () => {
219 generationTwoHistory.reject(new Error("generation 2 history failed"));
220 await Promise.all([generationTwoHistory.promise.catch(() => undefined), generationTwoSwitch]);
221 await flushPromises();
222 });
223 await waitFor("source restored after target history failure", () => controller?.activeTabId === "tab-a");
224 eq(backendActiveId, "tab-a", "target history failure rebinds backend focus to the retained source session");
225 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history A") ?? false, "target history failure restores the retained source transcript");
226 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 1") ?? false), "target history failure never restores the prior target generation");
227 heldTabOHistory = null;
228
229 await act(async () => {
230 await controller?.openProjectTab(reboundTabO.workspaceRoot, reboundTabO.topicId || "");
231 await flushPromises();
232 });
233 await waitFor("generation two retry", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 2") ?? false);
234
235 // A mount/ready sync can start before a same-tab session rebind and resolve
236 // afterwards. Its tab id still matches, so the navigation generation — not the
237 // id — must fence the stale snapshot before it can rewrite optimistic meta.
238 const staleGenerationTwoTabs = currentTabs();
239 const staleListTabs = deferred<TabMeta[]>();
240 heldListTabs = staleListTabs.promise;
241 heldListTabsStarted = false;
242 let staleSync: Promise<string | undefined> | undefined;
243 await act(async () => {
244 staleSync = controller?.syncActiveTab(false);
245 await flushPromises();
246 });
247 ok(heldListTabsStarted, "backend sync is held before the newer same-tab navigation");
248
249 const reboundTabOGenerationThree = { ...tabO, sessionGeneration: 3, active: true };
250 tabsById.set("tab-o", reboundTabOGenerationThree);
251 backendActiveId = "tab-o";
252 const generationThreeHistory = deferred<HistoryMessage[]>();
253 heldTabOHistory = generationThreeHistory.promise;
254 let generationThreeNavigation: Promise<TabMeta[] | undefined> | undefined;
255 await act(async () => {
256 generationThreeNavigation = controller?.openProjectTab(reboundTabOGenerationThree.workspaceRoot, reboundTabOGenerationThree.topicId || "");
257 await flushPromises();
258 });
259 eq(controller?.state.meta?.sessionGeneration, 3, "newer same-tab navigation installs generation three identity");
260 eq(controller?.state.hydrating, true, "generation three history remains pending");
261
262 await act(async () => {
263 staleListTabs.resolve(staleGenerationTwoTabs);
264 await flushPromises();
265 });
266 eq(controller?.state.meta?.sessionGeneration, 3, "stale same-tab sync cannot restore generation two metadata");
267 eq(controller?.state.hydrating, true, "stale same-tab sync cannot cancel generation three hydration");
268
269 await act(async () => {
270 generationThreeHistory.resolve([userMessage("history O generation 3")]);
271 await Promise.all([generationThreeHistory.promise, generationThreeNavigation, staleSync]);
272 await flushPromises();
273 });
274 await waitFor("generation three history", () => controller?.state.items.some((item) => item.kind === "user" && item.text === "history O generation 3") ?? false);
275 eq(controller?.state.meta?.sessionGeneration, 3, "generation three remains the settled session identity");
276
277 await act(async () => { root.unmount(); });
278 dom.window.close();
279
280 console.log(`\n${passed} passed, ${failed} failed`);
281 if (failed > 0) process.exit(1);
282
282 lines Plain Text