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