返回 DeepSeek-Reasonix
activate-topic-stale.test.tsx
根目录 / desktop / frontend / src / __tests__ / activate-topic-stale.test.tsx
1 // Run: tsx src/__tests__/activate-topic-stale.test.tsx
2 //
3 // Locks in last-click-wins for single-surface topic activation (#6607): when
4 // a newer navigation starts while app.StartTopicActivation is still in
5 // flight, the stale completion must neither flip the visible tab away from
6 // the user's last click nor delete the newer surface's cached state (the
7 // single-surface prune removes every other tab state, blanking the visible
8 // transcript).
9
10 import { JSDOM } from "jsdom";
11 import React, { act } from "react";
12 import { createRoot } from "react-dom/client";
13 import type { AppBindings } from "../lib/bridge";
14 import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing";
15 import { useController } from "../lib/useController";
16 import { historySliceFromMessages } from "./mockHistorySlice";
17 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
18 import { installDesktopHostStub } from "./desktopHostStub";
19
20 let passed = 0;
21 let failed = 0;
22
23 function ok(value: boolean, label: string) {
24 if (value) {
25 process.stdout.write(` PASS ${label}\n`);
26 passed += 1;
27 } else {
28 process.stdout.write(` FAIL ${label}\n`);
29 failed += 1;
30 }
31 }
32
33 function eq(actual: unknown, expected: unknown, label: string) {
34 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
35 }
36
37 function flushPromises(): Promise<void> {
38 return new Promise((resolve) => setTimeout(resolve, 0));
39 }
40
41 function deferred<T>() {
42 let resolve!: (value: T) => void;
43 let reject!: (reason?: unknown) => void;
44 const promise = new Promise<T>((res, rej) => {
45 resolve = res;
46 reject = rej;
47 });
48 return { promise, resolve, reject };
49 }
50
51 async function waitFor(label: string, predicate: () => boolean) {
52 for (let attempt = 0; attempt < 30; attempt += 1) {
53 await act(async () => {
54 await flushPromises();
55 });
56 if (predicate()) return;
57 }
58 throw new Error(`timed out waiting for ${label}`);
59 }
60
61 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
62 const workspaceRoot = `/repo/${id}`;
63 return {
64 id,
65 scope: "project",
66 workspaceRoot,
67 workspaceName: id,
68 workspacePath: workspaceRoot,
69 gitBranch: "main",
70 topicId: `topic-${id}`,
71 topicTitle: id,
72 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
73 label: `model-${id}`,
74 ready: true,
75 running: false,
76 mode: "normal",
77 toolApprovalMode: "ask",
78 tokenMode: "full",
79 active: false,
80 cwd: workspaceRoot,
81 ...overrides,
82 };
83 }
84
85 function metaFor(tab: TabMeta): Meta {
86 return {
87 label: tab.label,
88 ready: tab.ready,
89 startupErr: tab.startupErr,
90 eventChannel: "agent:event",
91 cwd: tab.cwd || tab.workspaceRoot,
92 workspaceRoot: tab.workspaceRoot,
93 workspaceName: tab.workspaceName,
94 workspacePath: tab.workspacePath,
95 gitBranch: tab.gitBranch,
96 autoApproveTools: false,
97 bypass: false,
98 collaborationMode: tab.collaborationMode ?? "normal",
99 toolApprovalMode: tab.toolApprovalMode ?? "ask",
100 tokenMode: tab.tokenMode ?? "full",
101 goal: "",
102 goalStatus: "stopped",
103 };
104 }
105
106 function userMessage(content: string): HistoryMessage {
107 return { role: "user", content };
108 }
109
110 console.log("\nactivate topic stale completion");
111
112 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
113 pretendToBeVisual: true,
114 url: "http://localhost/",
115 });
116 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
117 globalThis.window = dom.window as unknown as Window & typeof globalThis;
118 globalThis.document = dom.window.document;
119 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
120 globalThis.Node = dom.window.Node;
121 globalThis.HTMLElement = dom.window.HTMLElement;
122 globalThis.Event = dom.window.Event;
123 globalThis.CustomEvent = dom.window.CustomEvent;
124 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
125 globalThis.MouseEvent = dom.window.MouseEvent;
126 globalThis.localStorage = dom.window.localStorage;
127 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
128 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
129
130 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
131 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
132 const balance: BalanceInfo = { available: false, display: "" };
133 const jobs: JobView[] = [];
134 const checkpoints: CheckpointMeta[] = [];
135 const tabA = tabMeta("tab-a", { active: true });
136 const tabX = tabMeta("tab-x");
137 const tabY = tabMeta("tab-y");
138 let backendActiveId = "tab-a";
139 // Per-tab holds so any activation can be stalled mid-flight and released.
140 const activationHolds = new Map<string, Promise<void>>();
141 const tabsById = new Map([tabA, tabX, tabY].map((tab) => [tab.id, tab]));
142 const replayTargets: string[] = [];
143 // The pending ticketed activation backend-side: a newer StartTopicActivation
144 // supersedes it (cancelled), exactly like the real generation protocol.
145 let mockPendingActivation: { requestId: string; tabId: string } | undefined;
146
147 function emitTopicActivation(event: TopicActivationEvent): void {
148 desktopStub.emit("topic:activation", event);
149 }
150
151 function currentTabs(): TabMeta[] {
152 return Array.from(tabsById.values()).map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
153 }
154
155 const appStubTable = ({
156 main: {
157 App: {
158 RegisterNavigationIntent: async () => {},
159 ListTabs: async () => currentTabs(),
160 MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
161 ContextUsageForTab: async () => context,
162 EffortForTab: async () => effort,
163 BalanceForTab: async () => balance,
164 JobsForTab: async () => jobs,
165 CheckpointsForTab: async () => checkpoints,
166 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
167 HistoryForTab: async (tabID: string) => {
168 if (tabID === "tab-x") return [userMessage("history X")];
169 if (tabID === "tab-y") return [userMessage("history Y")];
170 return [userMessage("history A")];
171 },
172 HistoryPageForTab: async (tabID: string) => {
173 const messages = await appStubTable.HistoryForTab(tabID);
174 return { messages, startTurn: 0, endTurn: messages.length, totalTurns: messages.length, hasOlder: false };
175 },
176 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
177 historySliceFromMessages(tabID, await appStubTable.HistoryForTab(tabID), req),
178 HistoryCheckpointTurnsForTab: async () => [],
179 ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
180 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabA;
181 const hold = activationHolds.get(target.id);
182 if (hold) await hold;
183 backendActiveId = target.id;
184 return { ...target, active: true };
185 },
186 StartTopicActivation: async (req: TopicActivationRequest) => {
187 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === req.workspaceRoot && tab.topicId === req.topicId) ?? tabA;
188 const hold = activationHolds.get(target.id);
189 if (hold) await hold;
190 backendActiveId = target.id;
191 const requestId = req.requestId || `mock-activation-${target.id}`;
192 const previous = mockPendingActivation;
193 mockPendingActivation = { requestId, tabId: target.id };
194 if (previous && previous.requestId !== requestId) {
195 emitTopicActivation({ requestId: previous.requestId, tabId: previous.tabId, phase: "cancelled" });
196 }
197 emitTopicActivation({ requestId, tabId: target.id, phase: "starting" });
198 window.setTimeout(() => {
199 if (mockPendingActivation?.requestId !== requestId) return;
200 mockPendingActivation = undefined;
201 emitTopicActivation({ requestId, tabId: target.id, phase: "ready" });
202 }, 0);
203 return { requestId, tabId: target.id, meta: { ...target, active: true } };
204 },
205 SetActiveTab: async (tabID: string) => {
206 const hold = activationHolds.get(tabID);
207 if (hold) await hold;
208 backendActiveId = tabID;
209 },
210 ReplayPendingPrompts: async () => {},
211 ReplayPendingPromptsForTab: async (tabID: string) => {
212 replayTargets.push(tabID);
213 if (!tabsById.get(tabID)?.pendingPrompt) return;
214 desktopStub.emit("agent:event", {
215 kind: "ask_request",
216 tabId: tabID,
217 ask: { id: `pending-${tabID}`, questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
218 });
219 },
220 } as Partial<AppBindings> as AppBindings,
221 },
222 }).main.App;
223 const desktopStub = installDesktopHostStub(appStubTable);
224
225 type Controller = ReturnType<typeof useController>;
226 let controller: Controller | undefined;
227
228 function Probe() {
229 controller = useController();
230 return null;
231 }
232
233 const rootEl = document.getElementById("root");
234 if (!rootEl) throw new Error("missing root");
235 const root = createRoot(rootEl);
236
237 await act(async () => {
238 root.render(<Probe />);
239 await flushPromises();
240 });
241 await waitFor("initial active tab", () => controller?.activeTabId === "tab-a");
242
243 // Click topic X: the backend call hangs (slow prune / disk).
244 const activateXGate = deferred<void>();
245 activationHolds.set("tab-x", activateXGate.promise);
246 let activateX: Promise<TabMeta> | undefined;
247 await act(async () => {
248 activateX = controller?.activateTopic("project", tabX.workspaceRoot, tabX.topicId ?? "");
249 await flushPromises();
250 });
251 eq(controller?.activeTabId, "tab-a", "held activation does not flip the tab early");
252
253 // The user clicks topic Y before X's backend call returns; Y resolves first.
254 await act(async () => {
255 await controller?.activateTopic("project", tabY.workspaceRoot, tabY.topicId ?? "");
256 await flushPromises();
257 });
258 await waitFor("Y is active with its history", () =>
259 controller?.activeTabId === "tab-y" && controller.state.items.some((item) => item.kind === "user" && item.text === "history Y"));
260
261 // X's stale completion lands after Y applied. Last click must win.
262 await act(async () => {
263 activateXGate.resolve();
264 activationHolds.delete("tab-x");
265 await activateX;
266 await flushPromises();
267 });
268 await act(async () => {
269 await flushPromises();
270 });
271 eq(controller?.activeTabId, "tab-y", "stale activation must not flip the visible tab");
272 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history Y") === true,
273 "stale activation must not delete the visible tab's cached state");
274
275 // A fresh activation afterwards still applies normally (guard is not sticky).
276 await act(async () => {
277 await controller?.activateTopic("project", tabX.workspaceRoot, tabX.topicId ?? "");
278 await flushPromises();
279 });
280 await waitFor("X activates cleanly on a fresh click", () => controller?.activeTabId === "tab-x");
281
282 // --- Through the REAL production navigation queue (#6613 review P1) ---
283 //
284 // App.enqueueNavigation serializes clicks: a click made while another request
285 // runs only becomes a pending queue entry — it does NOT run activateTopic, so
286 // the controller epoch does not advance by itself. The App wiring must bump
287 // the epoch at ENQUEUE time (noteNavigationIntent), otherwise the running
288 // stale activation passes the guard, flips the tab, and prunes cached state.
289 type NavInput = { workspaceRoot: string; topicId: string };
290 const navRefs: NavigationCoalescingRefs<NavInput> = {
291 seqRef: { current: 0 },
292 runningRef: { current: false },
293 pendingRef: { current: null },
294 };
295 const enqueueNav = (workspaceRoot: string, topicId: string): Promise<void> => {
296 controller?.noteNavigationIntent(); // the App.tsx wiring under test
297 return enqueueNavigationRequest(navRefs, { workspaceRoot, topicId }, async (request) => {
298 await controller?.activateTopic("project", request.workspaceRoot, request.topicId);
299 });
300 };
301
302 const gateY = deferred<void>();
303 activationHolds.set("tab-y", gateY.promise);
304 const gateA = deferred<void>();
305 activationHolds.set("tab-a", gateA.promise);
306
307 let queuedFirst: Promise<void> | undefined;
308 let queuedSecond: Promise<void> | undefined;
309 await act(async () => {
310 queuedFirst = enqueueNav(tabY.workspaceRoot, tabY.topicId ?? ""); // runs, held mid-flight
311 await flushPromises();
312 });
313 await act(async () => {
314 queuedSecond = enqueueNav(tabA.workspaceRoot, tabA.topicId ?? ""); // queued, does not run yet
315 await flushPromises();
316 });
317
318 // The first (now stale) activation resolves while the second is still queued.
319 await act(async () => {
320 gateY.resolve();
321 activationHolds.delete("tab-y");
322 await queuedFirst;
323 await flushPromises();
324 });
325 eq(controller?.activeTabId, "tab-x", "queued click invalidates the running activation (no flip to tab-y)");
326 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history X") === true,
327 "queued click keeps the visible tab's cached state intact");
328
329 // The queued request then runs and lands on the user's last click.
330 await act(async () => {
331 gateA.resolve();
332 activationHolds.delete("tab-a");
333 await queuedSecond;
334 await flushPromises();
335 });
336 await waitFor("queued last click applies once it runs", () => controller?.activeTabId === "tab-a");
337
338 // A prompt can arrive on A before a slow A→X→A switch completes. The final A
339 // activation must preserve its card, and X's stale completion must reassert A
340 // without clearing or replaying a sibling tab's prompt.
341 const promptBlockedA = { ...tabA, running: true, pendingPrompt: true, cancellable: true };
342 tabsById.set(tabA.id, promptBlockedA);
343 await act(async () => {
344 desktopStub.emit("agent:event", {
345 kind: "ask_request",
346 tabId: tabA.id,
347 ask: { id: "pending-tab-a", questions: [{ id: "choice", prompt: "Keep me through A-X-A", options: [] }] },
348 });
349 await flushPromises();
350 });
351 eq(controller?.state.ask?.id, "pending-tab-a", "A starts the rapid switch with a visible ask");
352
353 const slowSwitchGate = deferred<void>();
354 activationHolds.set(tabX.id, slowSwitchGate.promise);
355 let slowSwitch: Promise<TabMeta[] | undefined> | undefined;
356 await act(async () => {
357 slowSwitch = controller?.switchTab(tabX.id, tabX);
358 await flushPromises();
359 });
360 eq(controller?.activeTabId, tabX.id, "A→X renders the slow target optimistically");
361
362 await act(async () => {
363 await controller?.switchTab(tabA.id, promptBlockedA);
364 await flushPromises();
365 });
366 eq(controller?.activeTabId, tabA.id, "A→X→A returns to the prompt owner");
367 eq(controller?.state.ask?.id, "pending-tab-a", "returning to A preserves its ask");
368 ok(replayTargets.includes(tabA.id), "post-activation replay is scoped to A");
369
370 await act(async () => {
371 slowSwitchGate.resolve();
372 activationHolds.delete(tabX.id);
373 await slowSwitch;
374 await flushPromises();
375 });
376 eq(controller?.activeTabId, tabA.id, "late X activation cannot replace A");
377 eq(backendActiveId, tabA.id, "late X activation reasserts A as backend owner");
378 eq(controller?.state.ask?.id, "pending-tab-a", "late X completion cannot clear A's ask");
379
380 // useController owns periodic runtime metadata refreshes. Unmount explicitly
381 // so the suite verifies their cleanup and does not keep the discovery runner
382 // alive after all assertions have passed.
383 await act(async () => root.unmount());
384 dom.window.close();
385
386 console.log(`\n${passed} passed, ${failed} failed`);
387 if (failed > 0) process.exit(1);
388
388 lines Plain Text