返回 DeepSeek-Reasonix
topic-activation.test.tsx
根目录 / desktop / frontend / src / __tests__ / topic-activation.test.tsx
1 // Run: tsx src/__tests__/topic-activation.test.tsx
2 //
3 // Ticketed topic activation (StartTopicActivation + "topic:activation"
4 // events): rapid A→B→C navigation with out-of-order lifecycle events hydrates
5 // only the last click; runtime failure leaves independent history readable; a
6 // terminal event that beats the ticket resolution is stashed and replayed;
7 // the legacy agent:ready flow and the tab:meta refresh push still work.
8
9 import { JSDOM } from "jsdom";
10 import React, { act } from "react";
11 import { createRoot } from "react-dom/client";
12 import type { AppBindings } from "../lib/bridge";
13 import { useController } from "../lib/useController";
14 import { getTranscriptStore } from "../lib/transcriptStore";
15 import { historySliceFromMessages } from "./mockHistorySlice";
16 import type {
17 BalanceInfo,
18 CheckpointMeta,
19 ContextInfo,
20 EffortInfo,
21 HistoryMessage,
22 HistorySliceRequest,
23 JobView,
24 Meta,
25 TabMeta,
26 TabMetaRefreshEvent,
27 TopicActivationEvent,
28 TopicActivationRequest,
29 WireEvent,
30 } from "../lib/types";
31 import { installDesktopHostStub } from "./desktopHostStub";
32 import { makeMockSessionReaderBindings } from "../lib/sessionReaderBridge";
33
34 let passed = 0;
35 let failed = 0;
36
37 function ok(value: boolean, label: string) {
38 if (value) {
39 process.stdout.write(` PASS ${label}\n`);
40 passed += 1;
41 } else {
42 process.stdout.write(` FAIL ${label}\n`);
43 failed += 1;
44 }
45 }
46
47 function eq(actual: unknown, expected: unknown, label: string) {
48 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
49 }
50
51 function flushPromises(): Promise<void> {
52 return new Promise((resolve) => setTimeout(resolve, 0));
53 }
54
55 async function waitFor(label: string, predicate: () => boolean) {
56 for (let attempt = 0; attempt < 30; attempt += 1) {
57 await act(async () => {
58 await flushPromises();
59 });
60 if (predicate()) return;
61 }
62 throw new Error(`timed out waiting for ${label}`);
63 }
64
65 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
66 const workspaceRoot = `/repo/${id}`;
67 return {
68 id,
69 scope: "project",
70 workspaceRoot,
71 workspaceName: id,
72 workspacePath: workspaceRoot,
73 gitBranch: "main",
74 topicId: `topic-${id}`,
75 topicTitle: id,
76 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
77 label: `model-${id}`,
78 ready: true,
79 running: false,
80 mode: "normal",
81 toolApprovalMode: "ask",
82 tokenMode: "full",
83 active: false,
84 cwd: workspaceRoot,
85 ...overrides,
86 };
87 }
88
89 function metaFor(tab: TabMeta, overrides: Partial<Meta> = {}): Meta {
90 return {
91 label: tab.label,
92 ready: tab.ready,
93 startupErr: tab.startupErr,
94 eventChannel: "agent:event",
95 cwd: tab.cwd || tab.workspaceRoot,
96 workspaceRoot: tab.workspaceRoot,
97 workspaceName: tab.workspaceName,
98 workspacePath: tab.workspacePath,
99 sessionPath: tab.sessionPath,
100 session: tab.session,
101 sessionGeneration: tab.sessionGeneration,
102 gitBranch: tab.gitBranch,
103 autoApproveTools: false,
104 bypass: false,
105 collaborationMode: tab.collaborationMode ?? "normal",
106 toolApprovalMode: tab.toolApprovalMode ?? "ask",
107 tokenMode: tab.tokenMode ?? "full",
108 goal: "",
109 goalStatus: "stopped",
110 ...overrides,
111 };
112 }
113
114 console.log("\ntopic activation (ticketed)");
115
116 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
117 pretendToBeVisual: true,
118 url: "http://localhost/",
119 });
120 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
121 globalThis.window = dom.window as unknown as Window & typeof globalThis;
122 globalThis.document = dom.window.document;
123 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
124 globalThis.Node = dom.window.Node;
125 globalThis.HTMLElement = dom.window.HTMLElement;
126 globalThis.Event = dom.window.Event;
127 globalThis.CustomEvent = dom.window.CustomEvent;
128 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
129 globalThis.MouseEvent = dom.window.MouseEvent;
130 globalThis.localStorage = dom.window.localStorage;
131 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
132 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
133
134 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
135 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
136 const balance: BalanceInfo = { available: false, display: "" };
137 const jobs: JobView[] = [];
138 const checkpoints: CheckpointMeta[] = [];
139 const tabA = tabMeta("tab-a", { active: true, ready: false });
140 const tabB = tabMeta("tab-b");
141 const tabC = tabMeta("tab-c");
142 const tabR = tabMeta("tab-r", { running: true, cancellable: true });
143 const tabAsk = tabMeta("tab-ask", { workspaceRoot: "/work/ask", topicId: "topic-ask", sessionPath: "/sessions/ask.jsonl" });
144 let backendActiveId = "tab-a";
145 const tabsById = new Map([tabA, tabB, tabC, tabR, tabAsk].map((tab) => [tab.id, tab]));
146 // requestId the controller issued per tab, recorded by the mock.
147 const requestIdByTab = new Map<string, string>();
148 // When true, the mock emits starting+ready synchronously BEFORE returning the
149 // ticket (exercises the terminal-event stash path).
150 let eagerActivationEvents = false;
151 let beforeActivationReturn: (() => Promise<void>) | undefined;
152 let failedHistoryTabId = "";
153 let historyRequests = 0;
154 let historyGate: { tabId: string; promise: Promise<void> } | undefined;
155 const transientHistoryFailures = new Map<string, { remaining: number; beforeThrow?: () => Promise<void> }>();
156 let failSetActiveTabId = "";
157 let restoredTabSeq = 0;
158 let followStarts = 0;
159 const followTabs: string[] = [];
160 const readerBindings = makeMockSessionReaderBindings();
161
162 function emitActivation(event: TopicActivationEvent): void {
163 desktopStub.emit("topic:activation", event);
164 }
165
166 function historyFor(tabID: string): HistoryMessage[] {
167 if (tabID === "tab-r") return [{ role: "user", content: "帮我查询,现在我链接的 deep" }];
168 if (tabID.startsWith("tab-c-restored-")) return [{ role: "user", content: "history tab-c" }];
169 return [{ role: "user", content: `history ${tabID}` }];
170 }
171
172 function hasHistory(tabID: string): boolean {
173 return controller?.state.items.some((item) => item.kind === "user" && item.text === `history ${tabID}`) ?? false;
174 }
175
176 const desktopStub = installDesktopHostStub(({
177 main: {
178 App: {
179 async TranscriptFollowForTab(tabID, request) {
180 if (!request.subscription && !request.close) {
181 followStarts++;
182 followTabs.push(tabID);
183 if (tabsById.get(tabID)?.startupErr) throw new Error("runtime lease blocked");
184 }
185 return readerBindings.TranscriptFollowForTab.call(this, tabID, request);
186 },
187 RegisterNavigationIntent: async () => {},
188 ListTabs: async () => Array.from(tabsById.values()).map((tab) => ({ ...tab, active: tab.id === backendActiveId })),
189 MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
190 ContextUsageForTab: async () => context,
191 EffortForTab: async () => effort,
192 BalanceForTab: async () => balance,
193 JobsForTab: async () => jobs,
194 CheckpointsForTab: async () => checkpoints,
195 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
196 HistoryForTab: async (tabID: string) => historyFor(tabID),
197 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
198 historyRequests++;
199 if (historyGate?.tabId === tabID) await historyGate.promise;
200 if (tabID === failedHistoryTabId) throw new Error(`/private/${tabID}/history.jsonl could not be read`);
201 const transientFailure = transientHistoryFailures.get(tabID);
202 if (transientFailure && transientFailure.remaining > 0) {
203 transientFailure.remaining -= 1;
204 await transientFailure.beforeThrow?.();
205 throw new Error("session runtime is still publishing its history");
206 }
207 return historySliceFromMessages(tabID, historyFor(tabID), req,
208 tabsById.get(tabID)?.startupErr ? { digest: `native-digest-${tabID}` } : {});
209 },
210 HistoryCheckpointTurnsForTab: async () => [],
211 StartTopicActivation: async (req: TopicActivationRequest) => {
212 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === req.workspaceRoot && tab.topicId === req.topicId) ?? tabA;
213 backendActiveId = target.id;
214 const requestId = req.requestId || `mock-activation-${target.id}`;
215 requestIdByTab.set(target.id, requestId);
216 await beforeActivationReturn?.();
217 if (eagerActivationEvents) {
218 emitActivation({ requestId, tabId: target.id, phase: "starting" });
219 emitActivation({ requestId, tabId: target.id, phase: "ready" });
220 }
221 return { requestId, tabId: target.id, meta: { ...target, active: true } };
222 },
223 SetActiveTab: async (tabID: string) => {
224 if (tabID === failSetActiveTabId) throw new Error("source tab was already pruned");
225 backendActiveId = tabID;
226 },
227 OpenTopicSession: async (_scope: string, _workspaceRoot: string, _topicID: string, sessionPath: string) => {
228 const source = Array.from(tabsById.values()).find((tab) => tab.sessionPath === sessionPath) ?? tabA;
229 const restored = tabMeta(`${source.id}-restored-${++restoredTabSeq}`, {
230 ...source,
231 id: `${source.id}-restored-${restoredTabSeq}`,
232 active: true,
233 });
234 tabsById.set(restored.id, restored);
235 backendActiveId = restored.id;
236 return restored;
237 },
238 ReplayPendingPrompts: async () => {},
239 } as Partial<AppBindings> as AppBindings,
240 },
241 }).main.App);
242
243 type Controller = ReturnType<typeof useController>;
244 let controller: Controller | undefined;
245
246 function Probe() {
247 controller = useController();
248 return null;
249 }
250
251 const rootEl = document.getElementById("root");
252 if (!rootEl) throw new Error("missing root");
253 const root = createRoot(rootEl);
254
255 await act(async () => {
256 root.render(<Probe />);
257 await flushPromises();
258 });
259 await waitFor("initial tab", () => controller?.activeTabId === "tab-a" && hasHistory("tab-a"));
260 eq(controller?.state.meta?.ready, false, "startup history is readable while execution is still recovering");
261 eq(controller?.state.hydrating, false, "cold startup settles the history window independently");
262 ok(controller?.state.transcriptProtocol !== 2, "cold startup does not invent a live Follow subscription");
263 tabsById.set(tabA.id, { ...tabA, ready: true });
264 await act(async () => { desktopStub.emit("agent:ready", tabA.id); await flushPromises(); });
265 await waitFor("startup live handoff", () => controller?.state.transcriptProtocol === 2 && controller.state.meta?.ready === true);
266 eq(controller?.state.items.filter(item => item.kind === "user" && item.text === "history tab-a").length, 1,
267 "runtime handoff replaces the cold cut without duplicating history");
268
269 // ── rapid A→B→C with out-of-order events: only C hydrates ──────────────────
270 await act(async () => {
271 await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
272 await flushPromises();
273 });
274 eq(controller?.activeTabId, "tab-b", "B applies optimistically on its ticket");
275 eq(controller?.state.hydrating, false, "B becomes readable before its runtime terminal event");
276 ok(hasHistory("tab-b"), "B publishes canonical history while runtime activation is still pending");
277
278 await act(async () => {
279 await controller?.activateTopic("project", tabC.workspaceRoot, tabC.topicId ?? "");
280 await flushPromises();
281 });
282 eq(controller?.activeTabId, "tab-c", "C applies optimistically over B");
283 eq(controller?.state.hydrating, false, "C history also settles independently of runtime activation");
284 ok(hasHistory("tab-c"), "C is readable before its runtime terminal event");
285
286 // Out of order: B's terminal events arrive (superseded) before C's ready.
287 await act(async () => {
288 emitActivation({ requestId: requestIdByTab.get("tab-b") ?? "", tabId: "tab-b", phase: "cancelled" });
289 emitActivation({ requestId: requestIdByTab.get("tab-b") ?? "", tabId: "tab-b", phase: "ready" });
290 emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "ready" });
291 await flushPromises();
292 });
293 eq(controller?.activeTabId, "tab-c", "superseded terminal events do not flip the visible tab");
294 ok(!hasHistory("tab-b") && !hasHistory("tab-a"), "superseded terminal events never hydrate");
295 eq(controller?.state.hydrating, false, "C remains readable while it waits for its own runtime terminal event");
296
297 await act(async () => {
298 emitActivation({ requestId: requestIdByTab.get("tab-c") ?? "", tabId: "tab-c", phase: "starting" });
299 await flushPromises();
300 });
301 eq(controller?.state.hydrating, false, "runtime starting does not hide already-readable history");
302 ok(hasHistory("tab-c"), "runtime starting preserves the readable C transcript");
303 await act(async () => {
304 emitActivation({ requestId: requestIdByTab.get("tab-c") ?? "", tabId: "tab-c", phase: "ready" });
305 await flushPromises();
306 });
307 await waitFor("C hydrates on its ready", () => hasHistory("tab-c") && controller?.state.hydrating === false);
308 ok(!hasHistory("tab-b"), "only the last click's history is visible");
309
310 // ── runtime failure preserves independently readable history ───────────────
311 await act(async () => {
312 await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
313 await flushPromises();
314 });
315 eq(controller?.activeTabId, "tab-a", "A applies optimistically");
316 await act(async () => {
317 emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "failed", error: "session failed to start" });
318 await flushPromises();
319 });
320 eq(controller?.activeTabId, "tab-a", "failed runtime activation keeps the selected readable target");
321 eq(controller?.state.hydrating, false, "failed runtime activation does not re-enter history hydration");
322 ok(hasHistory("tab-a"), "failed runtime activation retains the target transcript");
323 eq(controller?.state.meta?.ready, false, "failed runtime activation keeps write actions fenced");
324 ok(Boolean(controller?.state.meta?.startupErr), "failed runtime activation exposes a safe retry state");
325 const failureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
326 ok(Boolean(failureNotice && failureNotice.kind === "notice" && !failureNotice.text.includes("session failed to start")), "failure notice is sanitized before it reaches the readable target");
327
328 let releaseHistory!: () => void;
329 getTranscriptStore().evictTab(tabB.id);
330 historyGate = { tabId: tabB.id, promise: new Promise<void>(resolve => { releaseHistory = resolve; }) };
331 await act(async () => {
332 await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
333 emitActivation({ requestId: requestIdByTab.get(tabB.id) ?? "", tabId: tabB.id, phase: "failed" });
334 await flushPromises();
335 });
336 eq(controller?.state.hydrateError, undefined, "runtime failure is not a history read failure");
337 eq(controller?.state.hydrating, true, "history preparation continues after runtime failure");
338 tabsById.set(tabB.id, { ...tabB, ready: false, startupErr: "lease blocked", runtime: { phase: "lease_blocked", epoch: "blocked" } });
339 const followsBeforeBlockedRefresh = followStarts;
340 const readsBeforeBlockedRefresh = historyRequests;
341 await act(async () => { desktopStub.emit("agent:ready", tabB.id); await flushPromises(); });
342 eq(followStarts, followsBeforeBlockedRefresh, "blocked metadata refresh never starts live Follow");
343 eq(historyRequests, readsBeforeBlockedRefresh, "blocked metadata refresh joins the pending cold read");
344 await act(async () => { releaseHistory(); await flushPromises(); });
345 await waitFor("history succeeds after runtime failure", () => hasHistory(tabB.id));
346 eq(controller?.state.hydrateError, undefined, "a late valid baseline clears the history error");
347 eq(controller?.state.meta?.ready, false, "late history cannot clear the failed runtime write fence");
348 historyGate = undefined;
349 const readsAfterBlockedHistory = historyRequests;
350 const itemsAfterBlockedHistory = JSON.stringify(controller?.state.items);
351 const mutationAfterBlockedHistory = controller?.state.historyMutation.seq;
352 await act(async () => { await controller?.syncActiveTab(false, true, { preserveCachedHistory: true }); });
353 eq(historyRequests, readsAfterBlockedHistory, "settled blocked metadata refresh keeps the certified window without a latest-page read");
354 eq(JSON.stringify(controller?.state.items), itemsAfterBlockedHistory, "passive blocked refresh preserves the reader's current items");
355 eq(controller?.state.historyMutation.seq, mutationAfterBlockedHistory, "passive blocked refresh does not publish another history replacement");
356 const blockedFollowsBeforeRebuild = followTabs.filter(id => id === tabB.id).length;
357 await act(async () => {
358 desktopStub.emit("runtime:rebuilt", tabB.id, "blocked-rebuilt");
359 desktopStub.emit("runtime:rebuilt");
360 await flushPromises();
361 });
362 eq(followTabs.filter(id => id === tabB.id).length, blockedFollowsBeforeRebuild, "rebuild notifications do not follow a blocked runtime");
363 ok(hasHistory(tabB.id), "rebuild notifications retain independent readable history");
364 // An actual read error, independently of execution recovery, settles locally.
365 getTranscriptStore().evictTab(tabB.id);
366 failedHistoryTabId = tabB.id;
367 await act(async () => { await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? ""); await flushPromises(); });
368 await waitFor("cold read error", () => Boolean(controller?.state.hydrateError));
369 eq(controller?.activeTabId, tabB.id, "cold read error keeps the selected blocked session");
370 eq(controller?.state.hydrating, false, "cold read failure does not leave a permanent skeleton");
371 failedHistoryTabId = "";
372 tabsById.set(tabB.id, tabB);
373
374 await act(async () => {
375 await controller?.activateTopic("project", tabC.workspaceRoot, tabC.topicId ?? "");
376 emitActivation({ requestId: requestIdByTab.get("tab-c") ?? "", tabId: "tab-c", phase: "ready" });
377 await flushPromises();
378 });
379 await waitFor("C is restored as the committed source", () => controller?.activeTabId === "tab-c" && hasHistory("tab-c"));
380
381 // ── activation succeeds but target history fails: source still wins ─────────
382 failedHistoryTabId = "tab-a";
383 failSetActiveTabId = "tab-c";
384 await act(async () => {
385 await controller?.activateTopic("project", tabA.workspaceRoot, tabA.topicId ?? "");
386 await flushPromises();
387 emitActivation({ requestId: requestIdByTab.get("tab-a") ?? "", tabId: "tab-a", phase: "ready" });
388 await flushPromises();
389 });
390 await waitFor("history failure rebinds the pruned committed source", () => controller?.activeTabId?.startsWith("tab-c-restored-") ?? false);
391 ok(hasHistory("tab-c"), "target history failure retains the source controller transcript");
392 const historyFailureNotice = controller?.state.items.findLast((item) => item.kind === "notice");
393 ok(!(historyFailureNotice?.kind === "notice" && historyFailureNotice.text.includes("/private/")), "target history failure does not expose a local path");
394 failedHistoryTabId = "";
395 failSetActiveTabId = "";
396
397 // ── a terminal event that beats the ticket is stashed and replayed ──────────
398 eagerActivationEvents = true;
399 await act(async () => {
400 await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
401 await flushPromises();
402 });
403 await waitFor("stashed ready hydrates after the ticket lands", () => controller?.activeTabId === "tab-b" && hasHistory("tab-b"));
404 eagerActivationEvents = false;
405
406 // ── legacy agent:ready flow still works for non-ticketed tabs ───────────────
407 await act(async () => {
408 desktopStub.emit("agent:ready", "tab-b");
409 await flushPromises();
410 });
411 await waitFor("legacy ready keeps the session", () => controller?.activeTabId === "tab-b" && hasHistory("tab-b"));
412
413 // ── tab:meta refresh push merges; wrong session is fenced out ───────────────
414 await act(async () => {
415 desktopStub.emit("tab:meta", { tabId: "tab-b", meta: metaFor(tabB, { gitBranch: "feature/x", imageInputEnabled: true }) });
416 await flushPromises();
417 });
418 eq(controller?.state.meta?.gitBranch, "feature/x", "tab:meta merges the refreshed git branch");
419 eq(controller?.state.meta?.imageInputEnabled, true, "tab:meta merges the refreshed image-input capability");
420 await act(async () => {
421 desktopStub.emit("tab:meta", { tabId: "tab-b", meta: metaFor(tabB, { gitBranch: "stale", sessionPath: "/elsewhere/other.jsonl" }) });
422 await flushPromises();
423 });
424 eq(controller?.state.meta?.gitBranch, "feature/x", "tab:meta for a different session binding is discarded");
425
426 // ── switch away from a thinking session and back: keep its transcript ──────
427 await act(async () => {
428 await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
429 await flushPromises();
430 });
431 await act(async () => {
432 emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
433 await flushPromises();
434 });
435 await waitFor("running session hydrates on first open", () =>
436 controller?.activeTabId === "tab-r" &&
437 controller.state.running === true &&
438 controller.state.hydrating === false &&
439 (controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
440 );
441 eq(controller?.state.running, true, "reattached thinking session stays marked running");
442 eq(controller?.state.hydrating, false, "running-session hydrate settles instead of leaving Welcome");
443
444 const liveState = getTranscriptStore().states.get(tabR.id)!;
445 await act(async () => {
446 getTranscriptStore().setState(tabR.id, { ...liveState, running: true, pendingUser: "unsaved live prompt" });
447 await flushPromises();
448 });
449 const beforeReselect = historyRequests;
450 await act(async () => {
451 await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
452 await flushPromises();
453 });
454 eq(historyRequests, beforeReselect, "reselecting a live surface never overlays it with an older durable baseline");
455 eq(controller?.state.pendingUser, "unsaved live prompt", "reselecting preserves the optimistic prompt");
456 await act(async () => { getTranscriptStore().setState(tabR.id, liveState); await flushPromises(); });
457
458 await act(async () => {
459 await controller?.activateTopic("project", tabB.workspaceRoot, tabB.topicId ?? "");
460 await flushPromises();
461 });
462 await act(async () => {
463 emitActivation({ requestId: requestIdByTab.get("tab-b") ?? "", tabId: "tab-b", phase: "ready" });
464 await flushPromises();
465 });
466 await waitFor("idle session replaces the thinking surface", () => hasHistory("tab-b") && controller?.state.hydrating === false);
467
468 await act(async () => {
469 await controller?.activateTopic("project", tabR.workspaceRoot, tabR.topicId ?? "");
470 await flushPromises();
471 });
472 eq(controller?.activeTabId, "tab-r", "switch-back selects the thinking session");
473 eq(
474 controller?.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false,
475 false,
476 "switch-back does not present the other session as a hydration placeholder",
477 );
478 await act(async () => {
479 emitActivation({ requestId: requestIdByTab.get("tab-r") ?? "", tabId: "tab-r", phase: "ready" });
480 await flushPromises();
481 });
482 await waitFor("switch-back restores the thinking transcript", () =>
483 controller?.activeTabId === "tab-r" &&
484 controller.state.running === true &&
485 controller.state.hydrating === false &&
486 (controller.state.items.some((item) => item.kind === "user" && item.text === "帮我查询,现在我链接的 deep") ?? false),
487 );
488 eq(controller?.state.running, true, "switch-back keeps the composer in the live turn");
489 eq(controller?.state.items.some((item) => item.kind === "user" && item.text === "history tab-b") ?? false, false, "switch-back does not keep the other session as the visible transcript");
490 eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "switch-back clears the foreign placeholder after live history lands");
491
492 // A ready Ask runtime can race the first history read while its session is
493 // being published. One transient read failure must recover in the same click
494 // instead of restoring the source and making the user click B again.
495 tabsById.set(tabAsk.id, { ...tabAsk, running: true, pendingPrompt: true, cancellable: true });
496 let markHistoryStarted: (() => void) | undefined;
497 const historyStarted = new Promise<void>((resolve) => { markHistoryStarted = resolve; });
498 let releaseHistoryFailure: (() => void) | undefined;
499 const historyFailureGate = new Promise<void>((resolve) => { releaseHistoryFailure = resolve; });
500 transientHistoryFailures.set(tabAsk.id, {
501 remaining: 1,
502 beforeThrow: async () => { markHistoryStarted?.(); await historyFailureGate; },
503 });
504 await act(async () => {
505 await controller?.activateTopic("project", tabAsk.workspaceRoot, tabAsk.topicId ?? "");
506 desktopStub.emit("agent:event", {
507 kind: "ask_request",
508 tabId: tabAsk.id,
509 ask: { id: "ask-tab-ask", questions: [{ id: "choice", prompt: "Choose a repair", options: [] }] },
510 });
511 await flushPromises();
512 });
513 eq(controller?.state.ask?.id, undefined, "Ask waits for the consistent activation snapshot");
514 await act(async () => {
515 // Production emits agent:ready before topic:activation ready. Hold the
516 // startup hydration open so activation-ready must supersede it without
517 // resetting the live Ask.
518 desktopStub.emit("agent:ready", tabAsk.id);
519 await historyStarted;
520 emitActivation({ requestId: requestIdByTab.get(tabAsk.id) ?? "", tabId: tabAsk.id, phase: "ready" });
521 releaseHistoryFailure?.();
522 await flushPromises();
523 });
524 for (let attempt = 0; attempt < 10; attempt += 1) {
525 await act(async () => { await flushPromises(); });
526 }
527 eq(controller?.activeTabId, tabAsk.id, "transient Ask history failure stays on the selected session");
528 ok(hasHistory(tabAsk.id), "transient Ask history failure retries without a second click");
529 eq(controller?.state.pendingPrompt, true, "transient Ask history retry remains blocked on user input");
530 eq(controller?.state.running, true, "transient Ask history retry remains running");
531 eq(controller?.state.ask?.id, "ask-tab-ask", "transient Ask history retry preserves the decision card");
532
533 await act(async () => {
534 root.unmount();
535 });
536
537 // A restored legacy surface reuses its tab ID when preparation publishes a
538 // canonical identity. Exercise both terminal/ticket orders after a failed
539 // startup baseline, including passive agent-ready arriving before the ticket.
540 for (const eager of [false, true]) {
541 const restored = tabMeta(`restored-${eager}`, { ready: false, sessionPath: "C:\\fixture\\legacy.jsonl" });
542 tabsById.clear();
543 tabsById.set(restored.id, restored);
544 backendActiveId = restored.id;
545 failedHistoryTabId = restored.id;
546 const restoredRoot = createRoot(rootEl);
547 await act(async () => { restoredRoot.render(<Probe />); await flushPromises(); });
548 await waitFor("restored startup baseline fails", () => controller?.activeTabId === restored.id && !!controller.state.hydrateError);
549 tabsById.set(restored.id, { ...restored, ready: true, sessionPath: "", session: { hostId: "local", sessionId: `canonical-${eager}` }, sessionGeneration: 1 });
550 failedHistoryTabId = "";
551 eagerActivationEvents = eager;
552 beforeActivationReturn = async () => { desktopStub.emit("agent:ready", restored.id); await flushPromises(); };
553 await act(async () => {
554 await controller?.activateTopic(restored.scope, restored.workspaceRoot, restored.topicId ?? "", `session-id:canonical-${eager}`);
555 if (!eager) emitActivation({ requestId: requestIdByTab.get(restored.id) ?? "", tabId: restored.id, phase: "ready" });
556 await flushPromises();
557 });
558 await waitFor("restored canonical history is visible", () => hasHistory(restored.id) && !controller?.state.hydrating);
559 eq(controller?.state.hydrateError, undefined, `same-tab canonical adoption clears the failed startup baseline (eager=${eager})`);
560 eq(controller?.state.meta?.session?.sessionId, `canonical-${eager}`, "the restored surface retains its formal session identity");
561 eq(controller?.state.transcriptProtocol, 2, "canonical adoption installs the live Follow projection");
562 await act(async () => restoredRoot.unmount());
563 }
564 dom.window.close();
565
566 console.log(`\n${passed} passed, ${failed} failed`);
567 if (failed > 0) process.exit(1);
568
568 lines Plain Text