返回 DeepSeek-Reasonix
new-session-load-race.test.tsx
根目录 / desktop / frontend / src / __tests__ / new-session-load-race.test.tsx
1 // Run: tsx src/__tests__/new-session-load-race.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { initialState, reducer, useController, type Item } from "../lib/useController";
7 import type { AppBindings } from "../lib/bridge";
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 < 20; 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(overrides: Partial<TabMeta> = {}): TabMeta {
56 return {
57 id: "tab-a",
58 scope: "project",
59 workspaceRoot: "/repo",
60 workspaceName: "repo",
61 workspacePath: "/repo",
62 gitBranch: "main",
63 topicId: "topic-a",
64 topicTitle: "General",
65 label: "model",
66 ready: true,
67 running: false,
68 mode: "normal",
69 toolApprovalMode: "ask",
70 tokenMode: "full",
71 active: true,
72 cwd: "/repo",
73 ...overrides,
74 };
75 }
76
77 function meta(overrides: Partial<Meta> = {}): Meta {
78 return {
79 label: "model",
80 ready: true,
81 eventChannel: "agent:event",
82 cwd: "/repo",
83 workspaceRoot: "/repo",
84 workspaceName: "repo",
85 workspacePath: "/repo",
86 gitBranch: "main",
87 autoApproveTools: false,
88 bypass: false,
89 collaborationMode: "normal",
90 toolApprovalMode: "ask",
91 tokenMode: "full",
92 goal: "",
93 goalStatus: "stopped",
94 ...overrides,
95 };
96 }
97
98 console.log("\nnew session load race");
99
100 const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }];
101 const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }];
102 const resetState = reducer(
103 {
104 ...initialState,
105 items: resetSourceItems,
106 hydrating: true,
107 hydrateReason: "open-topic",
108 hydratePlaceholderItems: resetPlaceholderItems,
109 },
110 { type: "reset" },
111 );
112 eq(resetState.items.length, 0, "reset clears real transcript items");
113 eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately");
114
115 const emptyHistoryState = reducer(resetState, { type: "history", messages: [] });
116 eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty");
117 eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded");
118 eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items");
119
120 const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" });
121 eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker");
122
123 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
124 pretendToBeVisual: true,
125 url: "http://localhost/",
126 });
127 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
128 globalThis.window = dom.window as unknown as Window & typeof globalThis;
129 globalThis.document = dom.window.document;
130 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
131 globalThis.Node = dom.window.Node;
132 globalThis.HTMLElement = dom.window.HTMLElement;
133 globalThis.Event = dom.window.Event;
134 globalThis.CustomEvent = dom.window.CustomEvent;
135 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
136 globalThis.MouseEvent = dom.window.MouseEvent;
137 globalThis.localStorage = dom.window.localStorage;
138 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
139 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
140
141 const staleHistory = deferred<HistoryMessage[]>();
142 const staleSessionMeta = deferred<Meta>();
143 let newSessionCalls = 0;
144 let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
145 let holdNextMeta = false;
146 let staleMetaStarted = false;
147 const eventHandlers: Array<(event: WireEvent) => void> = [];
148 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
149 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
150 const balance: BalanceInfo = { available: false, display: "" };
151 const jobs: JobView[] = [];
152 const checkpoints: CheckpointMeta[] = [];
153
154 window.runtime = {
155 EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
156 if (name === "agent:event") eventHandlers.push(cb as (event: WireEvent) => void);
157 return () => {};
158 },
159 BrowserOpenURL: () => {},
160 };
161 window.go = {
162 main: {
163 App: {
164 ListTabs: async () => [tabMeta()],
165 MetaForTab: async () => {
166 if (holdNextMeta) {
167 holdNextMeta = false;
168 staleMetaStarted = true;
169 return staleSessionMeta.promise;
170 }
171 return meta({ canonicalTodos: backendCanonicalTodos });
172 },
173 ContextUsageForTab: async () => context,
174 EffortForTab: async () => effort,
175 BalanceForTab: async () => balance,
176 JobsForTab: async () => jobs,
177 CheckpointsForTab: async () => checkpoints,
178 HistoryForTab: async () => staleHistory.promise,
179 HistoryPageForTab: async () => {
180 const messages = await staleHistory.promise;
181 return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
182 },
183 HistoryCheckpointTurnsForTab: async () => [],
184 ReplayPendingPrompts: async () => {},
185 NewSession: async () => {
186 newSessionCalls += 1;
187 backendCanonicalTodos = [];
188 },
189 NewSessionForTab: async (tabID: string) => {
190 if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`);
191 newSessionCalls += 1;
192 backendCanonicalTodos = [];
193 },
194 ResumeSessionPageForTab: async () => {
195 backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
196 return {
197 messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }],
198 startTurn: 0,
199 endTurn: 1,
200 totalTurns: 1,
201 hasOlder: false,
202 };
203 },
204 } as Partial<AppBindings> as AppBindings,
205 },
206 };
207
208 type Controller = ReturnType<typeof useController>;
209 let controller: Controller | undefined;
210
211 function Probe() {
212 controller = useController();
213 return null;
214 }
215
216 const rootEl = document.getElementById("root");
217 if (!rootEl) throw new Error("missing root");
218 const root = createRoot(rootEl);
219
220 await act(async () => {
221 root.render(<Probe />);
222 await flushPromises();
223 });
224 await waitFor("active tab", () => controller?.activeTabId === "tab-a");
225
226 await act(async () => {
227 await controller?.refreshMeta();
228 await flushPromises();
229 });
230 eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Old task", "pre-reset metadata exposes the current session todo");
231
232 holdNextMeta = true;
233 await act(async () => {
234 for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a" });
235 await flushPromises();
236 });
237 await waitFor("stale metadata request", () => staleMetaStarted);
238
239 await act(async () => {
240 await controller?.newSession();
241 await flushPromises();
242 });
243 eq(newSessionCalls, 1, "tab-scoped NewSession is called once");
244 eq(controller?.state.items.length, 0, "new session clears the visible transcript");
245 eq(controller?.state.meta?.canonicalTodos?.length, 0, "new session refresh replaces the previous session todo with an authoritative empty list");
246
247 await act(async () => {
248 staleSessionMeta.resolve(meta({ canonicalTodos: [{ content: "Old task", status: "in_progress" }] }));
249 await staleSessionMeta.promise;
250 await flushPromises();
251 });
252 eq(controller?.state.meta?.canonicalTodos?.length, 0, "metadata started before a session transition cannot restore the previous todo");
253
254 await act(async () => {
255 staleHistory.resolve([{ role: "user", content: "old prompt" }]);
256 await staleHistory.promise;
257 await flushPromises();
258 });
259
260 eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
261
262 await act(async () => {
263 await controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
264 await flushPromises();
265 });
266 eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
267
268 await act(async () => {
269 root.unmount();
270 });
271
272 // Reusing a blank tab must invalidate the old hydration request. The backend
273 // may return the same tab id, so the request sequence (not the tab id) is the
274 // session boundary that prevents orphaned tool cards from coming back.
275 const reusedOldHistory = deferred<{
276 messages: HistoryMessage[];
277 startTurn: number;
278 endTurn: number;
279 totalTurns: number;
280 hasOlder: boolean;
281 }>();
282 const reusedHistoryCalls: string[] = [];
283 const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" });
284 const reusedTabPage = {
285 messages: [
286 { role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] },
287 { role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" },
288 ] as HistoryMessage[],
289 startTurn: 0,
290 endTurn: 0,
291 totalTurns: 0,
292 hasOlder: false,
293 };
294 const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false };
295 window.go.main.App = {
296 ListTabs: async () => [reusedTab],
297 MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }),
298 ContextUsageForTab: async () => context,
299 EffortForTab: async () => effort,
300 BalanceForTab: async () => balance,
301 JobsForTab: async () => jobs,
302 CheckpointsForTab: async () => checkpoints,
303 HistoryPageForTab: async () => {
304 reusedHistoryCalls.push("history");
305 return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage;
306 },
307 HistoryCheckpointTurnsForTab: async () => [],
308 ReplayPendingPrompts: async () => {},
309 EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }),
310 } as Partial<AppBindings> as AppBindings;
311
312 controller = undefined;
313 const reuseRoot = createRoot(rootEl);
314 await act(async () => {
315 reuseRoot.render(<Probe />);
316 await flushPromises();
317 });
318 await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1);
319
320 await act(async () => {
321 await controller?.ensureBlankTab("project", "/repo");
322 await flushPromises();
323 });
324 eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request");
325 eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card");
326
327 await act(async () => {
328 reusedOldHistory.resolve(reusedTabPage);
329 await reusedOldHistory.promise;
330 await flushPromises();
331 });
332 eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card");
333
334 await act(async () => {
335 reuseRoot.unmount();
336 });
337
338 // A tab-bar click can overtake EnsureBlankTab while its backend call is still
339 // in flight. Its intent must invalidate the older completion immediately, and
340 // the stale backend activation must be repaired after it eventually returns.
341 const queuedBlank = deferred<TabMeta>();
342 const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" });
343 const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" });
344 const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" });
345 let raceBackendActiveId = raceTabA.id;
346 const raceHistoryCalls: string[] = [];
347 const raceSetActiveCalls: string[] = [];
348 window.go.main.App = {
349 ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })),
350 MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }),
351 ContextUsageForTab: async () => context,
352 EffortForTab: async () => effort,
353 BalanceForTab: async () => balance,
354 JobsForTab: async () => jobs,
355 CheckpointsForTab: async () => checkpoints,
356 HistoryPageForTab: async (tabID: string) => {
357 raceHistoryCalls.push(tabID);
358 return reusedEmptyPage;
359 },
360 HistoryCheckpointTurnsForTab: async () => [],
361 ReplayPendingPrompts: async () => {},
362 EnsureBlankTab: async () => {
363 const tab = await queuedBlank.promise;
364 raceBackendActiveId = tab.id;
365 return tab;
366 },
367 SetActiveTab: async (tabID: string) => {
368 raceSetActiveCalls.push(tabID);
369 raceBackendActiveId = tabID;
370 },
371 } as Partial<AppBindings> as AppBindings;
372
373 controller = undefined;
374 const queuedRaceRoot = createRoot(rootEl);
375 await act(async () => {
376 queuedRaceRoot.render(<Probe />);
377 await flushPromises();
378 });
379 await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id);
380
381 let pendingBlank: Promise<TabMeta> | undefined;
382 await act(async () => {
383 pendingBlank = controller?.ensureBlankTab("project", "/repo");
384 await flushPromises();
385 });
386 const tabClickIntent = controller?.noteNavigationIntent();
387 if (tabClickIntent === undefined) throw new Error("missing queued tab intent");
388
389 let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined;
390 await act(async () => {
391 pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent);
392 await pendingTabSwitch;
393 await flushPromises();
394 });
395 eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion");
396 eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion");
397
398 await act(async () => {
399 queuedBlank.resolve({ ...raceBlank, active: true });
400 await pendingBlank;
401 await flushPromises();
402 });
403 eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab");
404 eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab");
405 eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab");
406 eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once");
407
408 await act(async () => {
409 queuedRaceRoot.unmount();
410 });
411
412 const guardedStartupTabs = deferred<TabMeta[]>();
413 const staleProjectA = "/repo/project-a";
414 const targetProjectB = "/repo/project-b";
415 const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
416 window.go.main.App = {
417 ListTabs: async () => guardedStartupTabs.promise,
418 MetaForTab: async (tabID: string) => tabID === "tab-new"
419 ? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
420 : meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
421 ContextUsageForTab: async () => context,
422 EffortForTab: async () => effort,
423 BalanceForTab: async () => balance,
424 JobsForTab: async () => jobs,
425 CheckpointsForTab: async () => checkpoints,
426 HistoryForTab: async () => [],
427 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
428 HistoryCheckpointTurnsForTab: async () => [],
429 ReplayPendingPrompts: async () => {},
430 EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
431 ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
432 return tabMeta({
433 id: "tab-new",
434 topicId: "topic-new",
435 topicTitle: "New session",
436 workspaceRoot: targetProjectB,
437 workspaceName: "project-b",
438 workspacePath: targetProjectB,
439 cwd: targetProjectB,
440 });
441 },
442 } as Partial<AppBindings> as AppBindings;
443
444 controller = undefined;
445 const guardRoot = createRoot(rootEl);
446
447 await act(async () => {
448 guardRoot.render(<Probe />);
449 await flushPromises();
450 });
451
452 await act(async () => {
453 await controller?.ensureBlankSurface("project", targetProjectB);
454 await flushPromises();
455 });
456
457 eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
458 eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
459 eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
460 eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
461
462 await act(async () => {
463 guardedStartupTabs.resolve([tabMeta({
464 id: "tab-old",
465 topicId: "topic-old",
466 topicTitle: "Old session",
467 workspaceRoot: staleProjectA,
468 workspaceName: "project-a",
469 workspacePath: staleProjectA,
470 cwd: staleProjectA,
471 })]);
472 await guardedStartupTabs.promise;
473 await flushPromises();
474 });
475
476 eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
477 eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
478
479 await act(async () => {
480 guardRoot.unmount();
481 });
482 dom.window.close();
483
484 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
485 if (failed > 0) process.exit(1);
486
486 lines Plain Text