返回 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, runtimeReadyForSubmit, useController, type Item } from "../lib/useController";
7 import type { NavigationResult } from "../lib/navigationSurfaceTransition";
8 import { historySliceFromMessages } from "./mockHistorySlice";
9 import type { AppBindings } from "../lib/bridge";
10 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
11 import { installDesktopHostStub } from "./desktopHostStub";
12 import { meta, tabMeta } from "./helpers/sessionSwitchFixtures";
13 import { resetSessionDiagnostics, sessionPipelineDiagnostics } from "../lib/sessionDiagnostics";
14 import { runTodoSessionSwitchScenario } from "../test-support/todoSessionSwitchScenario";
15
16 let passed = 0, 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 if (actual === expected) {
30 ok(true, label);
31 } else {
32 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
33 }
34 }
35
36 function flushPromises(): Promise<void> { return new Promise((resolve) => setTimeout(resolve, 0)); }
37
38 function deferred<T>() {
39 let resolve!: (value: T) => void;
40 let reject!: (reason?: unknown) => void;
41 const promise = new Promise<T>((res, rej) => {
42 resolve = res;
43 reject = rej;
44 });
45 return { promise, resolve, reject };
46 }
47
48 async function waitFor(label: string, predicate: () => boolean) {
49 for (let attempt = 0; attempt < 20; attempt += 1) {
50 await act(async () => {
51 await flushPromises();
52 });
53 if (predicate()) return;
54 }
55 throw new Error(`timed out waiting for ${label}`);
56 }
57
58
59 console.log("\nnew session load race");
60
61 const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }];
62 const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }];
63 const resetState = reducer(
64 {
65 ...initialState,
66 items: resetSourceItems,
67 hydrating: true,
68 hydrateReason: "open-topic",
69 hydratePlaceholderItems: resetPlaceholderItems,
70 },
71 { type: "reset" },
72 );
73 eq(resetState.items.length, 0, "reset clears real transcript items");
74 eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately");
75
76 const emptyHistoryState = reducer(resetState, { type: "history", messages: [] });
77 eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty");
78 eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded");
79 eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items");
80
81 const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" });
82 eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker");
83
84 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
85 pretendToBeVisual: true,
86 url: "http://localhost/",
87 });
88 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
89 globalThis.window = dom.window as unknown as Window & typeof globalThis;
90 globalThis.document = dom.window.document;
91 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
92 globalThis.Node = dom.window.Node;
93 globalThis.HTMLElement = dom.window.HTMLElement;
94 globalThis.Event = dom.window.Event;
95 globalThis.CustomEvent = dom.window.CustomEvent;
96 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
97 globalThis.MouseEvent = dom.window.MouseEvent;
98 globalThis.localStorage = dom.window.localStorage;
99 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
100 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
101
102 const staleHistory = deferred<HistoryMessage[]>();
103 const staleSessionMeta = deferred<Meta>();
104 let newSessionCalls = 0;
105 let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }];
106 let backendHistory: HistoryMessage[] | undefined;
107 let holdNextMeta = false;
108 let staleMetaStarted = false;
109 let backendRuntimeEpoch = "runtime-old";
110 let backendPendingPrompt = false;
111 let promptReplayCalls = 0;
112 const resumeRPCGate = deferred<void>();
113 const channelRPCGate = deferred<void>();
114 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
115 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
116 const balance: BalanceInfo = { available: false, display: "" };
117 const jobs: JobView[] = [];
118 const checkpoints: CheckpointMeta[] = [];
119
120 const appStubTable = {
121 RegisterNavigationIntent: async () => {},
122 ListTabs: async () => {
123 return [tabMeta({
124 runtime: { phase: "ready", epoch: backendRuntimeEpoch },
125 running: backendPendingPrompt,
126 pendingPrompt: backendPendingPrompt,
127 cancellable: backendPendingPrompt,
128 })];
129 },
130 MetaForTab: async () => {
131 if (holdNextMeta) {
132 holdNextMeta = false;
133 staleMetaStarted = true;
134 return staleSessionMeta.promise;
135 }
136 return meta({ canonicalTodos: backendCanonicalTodos, runtime: { phase: "ready", epoch: backendRuntimeEpoch } });
137 },
138 ContextUsageForTab: async () => context,
139 EffortForTab: async () => effort,
140 BalanceForTab: async () => balance,
141 JobsForTab: async () => jobs,
142 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
143 HistoryForTab: async () => staleHistory.promise,
144 HistoryPageForTab: async () => {
145 const messages = await staleHistory.promise;
146 return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
147 },
148 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
149 historySliceFromMessages(tabID, backendHistory ?? await staleHistory.promise, req),
150 HistoryCheckpointTurnsForTab: async () => [],
151 ReplayPendingPrompts: async () => {},
152 ReplayPendingPromptsForTab: async (tabID: string) => {
153 promptReplayCalls += 1;
154 if (tabID !== "tab-a" || !backendPendingPrompt) return;
155 desktopStub.emit("agent:event", {
156 kind: "ask_request",
157 tabId: tabID,
158 runtimeEpoch: backendRuntimeEpoch,
159 ask: { id: `replayed-${backendRuntimeEpoch}`, questions: [{ id: "choice", prompt: "Recovered after hydration", options: [] }] },
160 });
161 },
162 NewSession: async () => {
163 newSessionCalls += 1;
164 backendCanonicalTodos = [];
165 },
166 NewSessionForTab: async (tabID: string) => {
167 if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`);
168 newSessionCalls += 1;
169 backendCanonicalTodos = [];
170 backendHistory = [];
171 },
172 ResumeTranscriptSessionForTab: async () => {
173 backendHistory = [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }];
174 backendCanonicalTodos = [{ content: "Restored task", status: "completed" }];
175 const oldEpoch = backendRuntimeEpoch;
176 backendRuntimeEpoch = "runtime-resumed";
177 backendPendingPrompt = true;
178 desktopStub.emit("runtime:rebuilt", "tab-a", backendRuntimeEpoch);
179 desktopStub.emit("agent:event", {
180 kind: "ask_request",
181 tabId: "tab-a",
182 runtimeEpoch: oldEpoch,
183 ask: { id: "stale-old-epoch", questions: [{ id: "choice", prompt: "Stale", options: [] }] },
184 });
185 desktopStub.emit("agent:event", {
186 kind: "ask_request",
187 tabId: "tab-a",
188 runtimeEpoch: backendRuntimeEpoch,
189 ask: { id: "pre-response-resume", questions: [{ id: "choice", prompt: "Before Resume RPC returns", options: [] }] },
190 });
191 await resumeRPCGate.promise;
192 return {
193 messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }],
194 startTurn: 0,
195 endTurn: 1,
196 totalTurns: 1,
197 hasOlder: false,
198 };
199 },
200 OpenChannelTranscriptSessionForTab: async () => {
201 backendHistory = [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }];
202 const oldEpoch = backendRuntimeEpoch;
203 backendRuntimeEpoch = "runtime-channel";
204 backendPendingPrompt = true;
205 desktopStub.emit("runtime:rebuilt", "tab-a", backendRuntimeEpoch);
206 desktopStub.emit("agent:event", {
207 kind: "ask_request",
208 tabId: "tab-a",
209 runtimeEpoch: oldEpoch,
210 ask: { id: "stale-channel-old-epoch", questions: [{ id: "choice", prompt: "Stale channel", options: [] }] },
211 });
212 desktopStub.emit("agent:event", {
213 kind: "ask_request",
214 tabId: "tab-a",
215 runtimeEpoch: backendRuntimeEpoch,
216 ask: { id: "pre-response-channel", questions: [{ id: "choice", prompt: "Before channel RPC returns", options: [] }] },
217 });
218 await channelRPCGate.promise;
219 return {
220 messages: [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }],
221 startTurn: 0,
222 endTurn: 1,
223 totalTurns: 1,
224 hasOlder: false,
225 };
226 },
227 } as Partial<AppBindings> as AppBindings;
228 const desktopStub = installDesktopHostStub(appStubTable);
229
230 type Controller = ReturnType<typeof useController>;
231 let controller: Controller | undefined;
232
233 function Probe() {
234 controller = useController();
235 return null;
236 }
237
238 const rootEl = document.getElementById("root");
239 if (!rootEl) throw new Error("missing root");
240 const root = createRoot(rootEl);
241
242 await act(async () => {
243 root.render(<Probe />);
244 await flushPromises();
245 });
246 await waitFor("active tab", () => controller?.activeTabId === "tab-a");
247
248 await act(async () => {
249 await controller?.refreshMeta();
250 await flushPromises();
251 });
252 eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Old task", "pre-reset metadata exposes the current session todo");
253
254 holdNextMeta = true;
255 await act(async () => {
256 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a" });
257 await flushPromises();
258 });
259 await waitFor("stale metadata request", () => staleMetaStarted);
260
261 await act(async () => {
262 await controller?.newSession();
263 await flushPromises();
264 });
265 eq(newSessionCalls, 1, "tab-scoped NewSession is called once");
266 eq(controller?.state.items.length, 0, "new session clears the visible transcript");
267 eq(controller?.state.meta?.canonicalTodos?.length, 0, "new session refresh replaces the previous session todo with an authoritative empty list");
268
269 await act(async () => {
270 staleSessionMeta.resolve(meta({ canonicalTodos: [{ content: "Old task", status: "in_progress" }] }));
271 await staleSessionMeta.promise;
272 await flushPromises();
273 });
274 eq(controller?.state.meta?.canonicalTodos?.length, 0, "metadata started before a session transition cannot restore the previous todo");
275
276 await act(async () => {
277 staleHistory.resolve([{ role: "user", content: "old prompt" }]);
278 await staleHistory.promise;
279 await flushPromises();
280 });
281
282 eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session");
283
284 let resumeNavigation: NavigationResult<void> | undefined;
285 let resumeSurfaceSettled = false;
286 await act(async () => {
287 resumeNavigation = controller?.resumeSession("/sessions/restored.jsonl", "tab-a");
288 void resumeNavigation?.surfaceReady.then(() => { resumeSurfaceSettled = true; });
289 await flushPromises();
290 });
291 eq(resumeSurfaceSettled, false, "Resume releases navigation acquisition before target history settles");
292 eq(controller?.state.ask?.id, undefined, "Resume waits for a consistent Follow snapshot before presenting prompts");
293 await act(async () => {
294 resumeRPCGate.resolve();
295 await resumeNavigation?.surfaceReady;
296 await flushPromises();
297 });
298 eq(resumeSurfaceSettled, true, "Resume surfaceReady resolves after authoritative history and reconciliation");
299 eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state");
300 eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration");
301 ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay");
302
303 backendPendingPrompt = false;
304 await act(async () => {
305 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", runtimeEpoch: backendRuntimeEpoch });
306 await flushPromises();
307 });
308 const replayCallsBeforeChannelOpen = promptReplayCalls;
309 let channelNavigation: NavigationResult<void> | undefined;
310 await act(async () => {
311 channelNavigation = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a");
312 await flushPromises();
313 });
314 eq(controller?.state.ask?.id, undefined, "channel-open waits for a consistent Follow snapshot before presenting prompts");
315 await act(async () => {
316 channelRPCGate.resolve();
317 await channelNavigation?.surfaceReady;
318 await flushPromises();
319 });
320 eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration");
321 ok(promptReplayCalls > replayCallsBeforeChannelOpen, "channel-open completion performs a tab-scoped pending-prompt replay");
322
323 await act(async () => {
324 root.unmount();
325 });
326
327 // Reusing a blank tab must invalidate the old hydration request. The backend
328 // may return the same tab id, so the request sequence (not the tab id) is the
329 // session boundary that prevents orphaned tool cards from coming back.
330 const reusedOldHistory = deferred<{
331 messages: HistoryMessage[];
332 startTurn: number;
333 endTurn: number;
334 totalTurns: number;
335 hasOlder: boolean;
336 }>();
337 const reusedHistoryCalls: string[] = [];
338 const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" });
339 const reusedTabPage = {
340 messages: [
341 { role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] },
342 { role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" },
343 ] as HistoryMessage[],
344 startTurn: 0,
345 endTurn: 0,
346 totalTurns: 0,
347 hasOlder: false,
348 };
349 const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false };
350 desktopStub.replaceCommands({
351 RegisterNavigationIntent: async () => {},
352 ListTabs: async () => [reusedTab],
353 MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }),
354 ContextUsageForTab: async () => context,
355 EffortForTab: async () => effort,
356 BalanceForTab: async () => balance,
357 JobsForTab: async () => jobs,
358 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
359 HistoryPageForTab: async () => {
360 reusedHistoryCalls.push("history");
361 return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage;
362 },
363 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
364 reusedHistoryCalls.push("history");
365 const page = reusedHistoryCalls.length === 1 ? await reusedOldHistory.promise : reusedEmptyPage;
366 return historySliceFromMessages(tabID, page.messages, req);
367 },
368 HistoryCheckpointTurnsForTab: async () => [],
369 ReplayPendingPrompts: async () => {},
370 EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }),
371 } as Partial<AppBindings> as AppBindings);
372
373 controller = undefined;
374 const reuseRoot = createRoot(rootEl);
375 await act(async () => {
376 reuseRoot.render(<Probe />);
377 await flushPromises();
378 });
379 await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1);
380
381 await act(async () => {
382 await controller?.ensureBlankTab("project", "/repo");
383 await flushPromises();
384 });
385 eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request");
386 eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card");
387
388 await act(async () => {
389 reusedOldHistory.resolve(reusedTabPage);
390 await reusedOldHistory.promise;
391 await flushPromises();
392 });
393 eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card");
394
395 await act(async () => {
396 reuseRoot.unmount();
397 });
398
399 // A tab-bar click can overtake EnsureBlankTab while its backend call is still
400 // in flight. Its intent must invalidate the older completion immediately, and
401 // the stale backend activation must be repaired after it eventually returns.
402 const queuedBlank = deferred<TabMeta>();
403 const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" });
404 const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" });
405 const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" });
406 let raceBackendActiveId = raceTabA.id;
407 const raceHistoryCalls: string[] = [];
408 const raceSetActiveCalls: string[] = [];
409 desktopStub.replaceCommands({
410 RegisterNavigationIntent: async () => {},
411 ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })),
412 MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }),
413 ContextUsageForTab: async () => context,
414 EffortForTab: async () => effort,
415 BalanceForTab: async () => balance,
416 JobsForTab: async () => jobs,
417 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
418 HistoryPageForTab: async (tabID: string) => {
419 raceHistoryCalls.push(tabID);
420 return reusedEmptyPage;
421 },
422 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
423 raceHistoryCalls.push(tabID);
424 return historySliceFromMessages(tabID, reusedEmptyPage.messages, req);
425 },
426 HistoryCheckpointTurnsForTab: async () => [],
427 ReplayPendingPrompts: async () => {},
428 EnsureBlankTab: async () => {
429 const tab = await queuedBlank.promise;
430 raceBackendActiveId = tab.id;
431 return tab;
432 },
433 SetActiveTab: async (tabID: string) => {
434 raceSetActiveCalls.push(tabID);
435 raceBackendActiveId = tabID;
436 },
437 } as Partial<AppBindings> as AppBindings);
438
439 controller = undefined;
440 const queuedRaceRoot = createRoot(rootEl);
441 await act(async () => {
442 queuedRaceRoot.render(<Probe />);
443 await flushPromises();
444 });
445 await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id);
446
447 let pendingBlank: Promise<TabMeta> | undefined;
448 await act(async () => {
449 pendingBlank = controller?.ensureBlankTab("project", "/repo");
450 await flushPromises();
451 });
452 const tabClickIntent = controller?.noteNavigationIntent();
453 if (tabClickIntent === undefined) throw new Error("missing queued tab intent");
454
455 let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined;
456 await act(async () => {
457 pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent);
458 await pendingTabSwitch;
459 await flushPromises();
460 });
461 eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion");
462 eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion");
463
464 await act(async () => {
465 queuedBlank.resolve({ ...raceBlank, active: true });
466 await pendingBlank;
467 await flushPromises();
468 });
469 eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab");
470 eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab");
471 eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab");
472 eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once");
473
474 await act(async () => {
475 queuedRaceRoot.unmount();
476 });
477
478 const guardedStartupTabs = deferred<TabMeta[]>();
479 const staleProjectA = "/repo/project-a";
480 const targetProjectB = "/repo/project-b";
481 const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
482 desktopStub.replaceCommands({
483 RegisterNavigationIntent: async () => {},
484 ListTabs: async () => guardedStartupTabs.promise,
485 MetaForTab: async (tabID: string) => tabID === "tab-new"
486 ? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
487 : meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
488 ContextUsageForTab: async () => context,
489 EffortForTab: async () => effort,
490 BalanceForTab: async () => balance,
491 JobsForTab: async () => jobs,
492 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
493 HistoryForTab: async () => [],
494 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
495 HistoryCheckpointTurnsForTab: async () => [],
496 ReplayPendingPrompts: async () => {},
497 EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
498 ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
499 return tabMeta({
500 id: "tab-new",
501 topicId: "topic-new",
502 topicTitle: "New session",
503 workspaceRoot: targetProjectB,
504 workspaceName: "project-b",
505 workspacePath: targetProjectB,
506 cwd: targetProjectB,
507 });
508 },
509 } as Partial<AppBindings> as AppBindings);
510
511 controller = undefined;
512 const guardRoot = createRoot(rootEl);
513
514 await act(async () => {
515 guardRoot.render(<Probe />);
516 await flushPromises();
517 });
518
519 await act(async () => {
520 await controller?.ensureBlankSurface("project", targetProjectB);
521 await flushPromises();
522 });
523
524 eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
525 eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
526 eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
527 eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
528
529 await act(async () => {
530 guardedStartupTabs.resolve([tabMeta({
531 id: "tab-old",
532 topicId: "topic-old",
533 topicTitle: "Old session",
534 workspaceRoot: staleProjectA,
535 workspaceName: "project-a",
536 workspacePath: staleProjectA,
537 cwd: staleProjectA,
538 })]);
539 await guardedStartupTabs.promise;
540 await flushPromises();
541 });
542
543 eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
544 eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
545
546 await act(async () => {
547 guardRoot.unmount();
548 });
549
550 // Exercise the production hook's modern entry points: none may install an
551 // independently fetched legacy prefix or leave the new runtime paused forever.
552 let modernEpoch = "modern-start";
553 let modernPath = "/sessions/modern-start.jsonl";
554 let modernSnapshots = 0;
555 let legacyReads = 0;
556 let modernAdoptions = 0;
557 const slowModernGate = deferred<void>();
558 const modernPhases = { resolveMs: 1, loadMs: 2, rebindMs: 3, historyMs: 0, totalMs: 6, loadedMessages: 2, loadedBytes: 100, historyEntries: 0, durableReads: 1, outcome: "ok" };
559 const modernReplace = (name: string) => {
560 modernEpoch = name;
561 modernPath = `/sessions/${name}.jsonl`;
562 desktopStub.emit("runtime:rebuilt", "tab-a", modernEpoch);
563 };
564 const legacyRead = async () => { legacyReads++; throw new Error("modern hydration read legacy history"); };
565 desktopStub.replaceCommands({
566 RegisterNavigationIntent: async () => {},
567 ListTabs: async () => [tabMeta({ runtime: { phase: "ready", epoch: modernEpoch } })],
568 MetaForTab: async () => meta({ sessionPath: modernPath, runtime: { phase: "ready", epoch: modernEpoch } }),
569 ContextUsageForTab: async () => context,
570 EffortForTab: async () => effort,
571 BalanceForTab: async () => balance,
572 JobsForTab: async () => jobs,
573 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
574 HistoryCheckpointTurnsForTab: async () => [],
575 HistoryForTab: legacyRead, HistoryPageForTab: legacyRead, HistorySliceForTab: legacyRead,
576 ResumeSessionPageForTab: legacyRead, OpenChannelSessionPageForTab: legacyRead,
577 ReplayPendingPrompts: async () => {},
578 ReplayPendingPromptsForTab: async () => {},
579 SessionHistoryWindowForTab: async () => {
580 modernSnapshots++;
581 return { status: "ready", snapshotSequence: 0, coverageSequence: 0, generation: modernEpoch, messages: [], totalTurns: 0, hasOlder: false, hasNewer: false };
582 },
583 NewSessionForTab: async () => modernReplace("modern-new"),
584 ClearSessionForTab: async () => { modernReplace("modern-clear"); return { sessionPath: modernPath, sessionGeneration: 2 }; },
585 ResumeTranscriptSessionForTab: async (_tab: string, path: string) => {
586 modernAdoptions++; modernReplace(path.includes("slow") ? "modern-slow" : "modern-resume");
587 if (path.includes("slow")) await slowModernGate.promise;
588 return { ...modernPhases, totalMs: path.includes("slow") ? 500 : modernPhases.totalMs };
589 },
590 OpenChannelTranscriptSessionForTab: async () => { modernAdoptions++; modernReplace("modern-channel"); return modernPhases; },
591 } as Partial<AppBindings>);
592 controller = undefined;
593 const modernRoot = createRoot(rootEl);
594 await act(async () => { modernRoot.render(<Probe />); await flushPromises(); });
595 await waitFor("modern startup snapshot", () => controller?.state.transcriptProtocol === 2);
596 const verifyModernSuffix = async (label: string) => {
597 await act(async () => {
598 desktopStub.emit("agent:event", { kind: "user_message", tabId: "tab-a", runtimeEpoch: modernEpoch,
599 sessionId: modernEpoch, seq: 1, messageId: `${modernEpoch}-user`, text: label });
600 desktopStub.emit("agent:event", { kind: "text", tabId: "tab-a", runtimeEpoch: modernEpoch,
601 sessionId: modernEpoch, seq: 2, messageId: `${modernEpoch}-assistant`, text: "suffix" });
602 await flushPromises();
603 });
604 eq(controller?.state.items.filter(item => item.kind === "user").length + (controller?.state.localSubmissionOrder.length ?? 0), 0, `${label} backend identity event cannot fabricate a durable row or local echo`);
605 eq(controller?.state.live?.text, "suffix", `${label} accepts the ordered live suffix`);
606 };
607 await verifyModernSuffix("startup");
608 await act(async () => { await controller?.newSession(); await flushPromises(); });
609 eq(controller?.state.items.length, 0, "modern new session installs its empty cut");
610 await verifyModernSuffix("new");
611 await act(async () => { await controller?.clearSession(); await flushPromises(); });
612 eq(controller?.state.items.length, 0, "modern clear installs its empty cut");
613 await verifyModernSuffix("clear");
614 await act(async () => { await controller?.resumeSession("/sessions/modern-resume.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
615 eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern resume records snapshot installation");
616 eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "modern resume receives backend load evidence");
617 ok(typeof sessionPipelineDiagnostics().resumeSnapshotMs === "number", "modern resume measures snapshot time separately");
618 await verifyModernSuffix("resume");
619 await act(async () => { await controller?.openChannelSession("/sessions/modern-channel.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
620 eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern channel records snapshot installation");
621 await verifyModernSuffix("channel");
622 eq(modernAdoptions, 2, "resume and channel use adoption without a legacy history payload");
623 eq(modernSnapshots, 5, "each modern entry point obtains one authoritative cut");
624 eq(legacyReads, 0, "modern entry points never read legacy history");
625 let staleModern: NavigationResult<void> | undefined;
626 await act(async () => {
627 staleModern = controller?.resumeSession("/sessions/slow.jsonl", "tab-a");
628 await flushPromises();
629 });
630 eq(sessionPipelineDiagnostics().duplicateLoadCount, null, "pending switch does not reuse previous evidence");
631 await act(async () => { await controller?.resumeSession("/sessions/fast.jsonl", "tab-a")?.surfaceReady; await flushPromises(); });
632 await act(async () => { slowModernGate.resolve(); await staleModern?.surfaceReady; await flushPromises(); });
633 eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, modernPhases.totalMs, "stale modern adoption cannot overwrite committed diagnostics");
634 eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern race retains authoritative snapshot evidence");
635
636 desktopStub.commands.TranscriptFollowForTab = async () => { throw new Error("configured model is unavailable before controller startup"); };
637 desktopStub.commands.HistorySliceForTab = async (tabID: string, req: HistorySliceRequest) => { legacyReads++; return historySliceFromMessages(tabID, [{ role: "user", content: "recovered without controller" }], req); };
638 await act(async () => {
639 await controller?.retrySessionHistory("tab-a");
640 await flushPromises();
641 });
642 ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "recovered without controller") ?? false),
643 "failed Follow never falls back to a different protocol");
644 eq(legacyReads, 0, "snapshot failure performs no compatibility history read");
645 ok(Boolean(controller?.state.hydrateError), "failed synchronization remains visible");
646 await act(async () => { modernRoot.unmount(); });
647 // ── session switch: one history commit, composer bound to the new runtime ────
648 // The switch shows the restored transcript as soon as its page lands, but the
649 // tab is only submittable once the runtime reconcile confirms which session the
650 // controller now owns. A superseded switch must not paint over the newer one.
651 const switchMetaGate = deferred<Meta>();
652 const slowSwitchGate = deferred<void>();
653 let switchMetaHeld = false;
654 let switchMetaPath = "/sessions/one.jsonl";
655 let switchResumeCalls = 0;
656 let switchHistoryPageCalls = 0;
657 const switchTab = tabMeta({ id: "tab-switch", sessionPath: "/sessions/one.jsonl" });
658 const switchPage = (text: string, durableReads = 1) => ({
659 messages: [{ role: "user", content: text } as HistoryMessage],
660 startTurn: 0,
661 endTurn: 1,
662 totalTurns: 1,
663 hasOlder: false,
664 switch: {
665 resolveMs: 0, loadMs: 1, rebindMs: 2, historyMs: 1, totalMs: 4,
666 loadedMessages: 1, loadedBytes: 64, historyEntries: 1, durableReads, outcome: "ok",
667 },
668 });
669 desktopStub.replaceCommands({
670 RegisterNavigationIntent: async () => {},
671 ListTabs: async () => [switchTab],
672 SessionOpenForTab: undefined, // Exercise the older host's resume-page contract.
673 MetaForTab: async () => {
674 if (switchMetaHeld) return switchMetaGate.promise;
675 return meta({ sessionPath: switchMetaPath });
676 },
677 ContextUsageForTab: async () => context,
678 EffortForTab: async () => effort,
679 BalanceForTab: async () => balance,
680 JobsForTab: async () => jobs,
681 CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
682 HistoryPageForTab: async () => {
683 switchHistoryPageCalls += 1;
684 return switchPage("full-history-refetch");
685 },
686 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, switchMetaPath ? [{ role: "user", content: switchMetaPath }] : [], req),
687 HistoryCheckpointTurnsForTab: async () => [],
688 ReplayPendingPrompts: async () => {},
689 ReplayPendingPromptsForTab: async () => {},
690 ResumeTranscriptSessionForTab: async (_tabID: string, path: string) => {
691 switchResumeCalls += 1;
692 if (path.includes("slow")) await slowSwitchGate.promise;
693 switchMetaPath = path;
694 const page = switchPage(path);
695 page.switch.totalMs = path.includes("slow") ? 500 : 4;
696 return page.switch;
697 },
698 });
699
700 resetSessionDiagnostics();
701 const switchRoot = createRoot(document.createElement("div"));
702 await act(async () => {
703 switchRoot.render(<Probe />);
704 await flushPromises();
705 });
706 await waitFor("switch tab active", () => controller?.activeTabId === "tab-switch");
707
708 switchMetaHeld = true;
709 let switchNav: NavigationResult<void> | undefined;
710 await act(async () => {
711 switchNav = controller?.resumeSession("/sessions/two.jsonl", "tab-switch");
712 await flushPromises();
713 });
714 await waitFor("switched transcript", () => (controller?.state.items.length ?? 0) > 0);
715 eq(controller?.state.items[0]?.text, "/sessions/two.jsonl", "switch commits the restored transcript before ancillary work finishes");
716 eq(runtimeReadyForSubmit(controller?.state.meta), false, "composer stays disabled until the switched runtime is reconciled");
717
718 await act(async () => {
719 switchMetaHeld = false;
720 switchMetaPath = "/sessions/two.jsonl";
721 switchMetaGate.resolve(meta({ sessionPath: switchMetaPath }));
722 await switchNav?.surfaceReady;
723 await flushPromises();
724 });
725 eq(runtimeReadyForSubmit(controller?.state.meta), true, "composer re-enables once the switched runtime is reconciled");
726 eq(switchResumeCalls, 1, "a switch issues exactly one resume page request");
727 eq(switchHistoryPageCalls, 0, "a switch does not refetch the full history page from the frontend");
728 eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "switch reports no duplicate durable load");
729 eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "the switch's first screen is attributed to Follow");
730
731 let slowNav: NavigationResult<void> | undefined;
732 let fastNav: NavigationResult<void> | undefined;
733 await act(async () => {
734 slowNav = controller?.resumeSession("/sessions/slow.jsonl", "tab-switch");
735 await flushPromises();
736 });
737 await act(async () => {
738 fastNav = controller?.resumeSession("/sessions/fast.jsonl", "tab-switch");
739 await fastNav?.surfaceReady;
740 await flushPromises();
741 });
742 await act(async () => {
743 slowSwitchGate.resolve();
744 await slowNav?.surfaceReady;
745 await flushPromises();
746 });
747 eq(controller?.state.items[0]?.text, "/sessions/fast.jsonl", "a superseded switch cannot paint over the newer transcript");
748 eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, 4, "superseded response cannot overwrite current switch diagnostics");
749
750 await runTodoSessionSwitchScenario({ controller: () => controller, desktopStub, currentSessionPath: () => switchMetaPath, meta, equal: eq });
751
752 await act(async () => {
753 switchRoot.unmount();
754 });
755 // Navigation admission: an old MetaForTab completion races the new ready=false.
756 {
757 const oldMeta = deferred<Meta>();
758 const navigationGate = deferred<void>();
759 let holdMeta = false;
760 let holdNavigation = false;
761 let path = "/sessions/source.jsonl";
762 const submissions: string[] = [];
763 desktopStub.replaceCommands({
764 RegisterNavigationIntent: async () => { if (holdNavigation) await navigationGate.promise; },
765 ListTabs: async () => [tabMeta({ id: "meta-race", sessionPath: path })],
766 SessionOpenForTab: undefined,
767 MetaForTab: async () => holdMeta ? oldMeta.promise : meta({ sessionPath: path }),
768 ContextUsageForTab: async () => context, EffortForTab: async () => effort,
769 BalanceForTab: async () => balance, JobsForTab: async () => jobs,
770 CheckpointsForTab: async () => checkpoints, HistoryCheckpointTurnsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
771 HistorySliceForTab: async (id: string, req: HistorySliceRequest) => historySliceFromMessages(id, [], req),
772 ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
773 ResumeTranscriptSessionForTab: async (_id: string, target: string) => { path = target; return switchPage(target).switch; },
774 StartTurnForTab: async () => { submissions.push(path); return { turnId: "wrong-source" }; },
775 });
776 const raceRoot = createRoot(document.createElement("div"));
777 await act(async () => { raceRoot.render(<Probe />); await flushPromises(); });
778 await waitFor("meta race active", () => controller?.activeTabId === "meta-race" && runtimeReadyForSubmit(controller?.state.meta));
779 holdMeta = true;
780 let oldRefresh: Promise<void> | undefined;
781 await act(async () => { oldRefresh = controller?.refreshMeta(); await flushPromises(); });
782 holdNavigation = true;
783 let navigation: NavigationResult<void> | undefined;
784 await act(async () => { navigation = controller?.resumeSession("/sessions/target.jsonl", "meta-race"); await flushPromises(); });
785 eq(runtimeReadyForSubmit(controller?.state.meta), false, "switch initially closes admission");
786 await act(async () => {
787 holdMeta = false;
788 oldMeta.resolve(meta({ sessionPath: "/sessions/source.jsonl" }));
789 await oldRefresh;
790 await flushPromises();
791 });
792 eq(runtimeReadyForSubmit(controller?.state.meta), false, "old metadata must not reopen admission during navigation registration");
793 await act(async () => { await controller?.sendToTab("meta-race", "raced submission").catch(() => {}); await flushPromises(); });
794 eq(submissions.length, 0, "pending switch must not send into its source controller");
795 await act(async () => { holdNavigation = false; navigationGate.resolve(); await navigation?.surfaceReady; await flushPromises(); raceRoot.unmount(); });
796 }
797 dom.window.close();
798
799 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
800 if (failed > 0) process.exit(1);
801
801 lines Plain Text