返回 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.ActivateTopic is still in flight, the
5 // stale completion must neither flip the visible tab away from the user's
6 // last click nor delete the newer surface's cached state (the single-surface
7 // prune removes every other tab state, blanking the visible transcript).
8
9 import { readFileSync } from "node:fs";
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 type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
17
18 let passed = 0;
19 let failed = 0;
20
21 function ok(value: boolean, label: string) {
22 if (value) {
23 process.stdout.write(` PASS ${label}\n`);
24 passed += 1;
25 } else {
26 process.stdout.write(` FAIL ${label}\n`);
27 failed += 1;
28 }
29 }
30
31 function eq(actual: unknown, expected: unknown, label: string) {
32 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
33 }
34
35 function flushPromises(): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, 0));
37 }
38
39 function deferred<T>() {
40 let resolve!: (value: T) => void;
41 let reject!: (reason?: unknown) => void;
42 const promise = new Promise<T>((res, rej) => {
43 resolve = res;
44 reject = rej;
45 });
46 return { promise, resolve, reject };
47 }
48
49 async function waitFor(label: string, predicate: () => boolean) {
50 for (let attempt = 0; attempt < 30; attempt += 1) {
51 await act(async () => {
52 await flushPromises();
53 });
54 if (predicate()) return;
55 }
56 throw new Error(`timed out waiting for ${label}`);
57 }
58
59 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
60 const workspaceRoot = `/repo/${id}`;
61 return {
62 id,
63 scope: "project",
64 workspaceRoot,
65 workspaceName: id,
66 workspacePath: workspaceRoot,
67 gitBranch: "main",
68 topicId: `topic-${id}`,
69 topicTitle: id,
70 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
71 label: `model-${id}`,
72 ready: true,
73 running: false,
74 mode: "normal",
75 toolApprovalMode: "ask",
76 tokenMode: "full",
77 active: false,
78 cwd: workspaceRoot,
79 ...overrides,
80 };
81 }
82
83 function metaFor(tab: TabMeta): Meta {
84 return {
85 label: tab.label,
86 ready: tab.ready,
87 startupErr: tab.startupErr,
88 eventChannel: "agent:event",
89 cwd: tab.cwd || tab.workspaceRoot,
90 workspaceRoot: tab.workspaceRoot,
91 workspaceName: tab.workspaceName,
92 workspacePath: tab.workspacePath,
93 gitBranch: tab.gitBranch,
94 autoApproveTools: false,
95 bypass: false,
96 collaborationMode: tab.collaborationMode ?? "normal",
97 toolApprovalMode: tab.toolApprovalMode ?? "ask",
98 tokenMode: tab.tokenMode ?? "full",
99 goal: "",
100 goalStatus: "stopped",
101 };
102 }
103
104 function userMessage(content: string): HistoryMessage {
105 return { role: "user", content };
106 }
107
108 console.log("\nactivate topic stale completion");
109
110 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
111 pretendToBeVisual: true,
112 url: "http://localhost/",
113 });
114 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
115 globalThis.window = dom.window as unknown as Window & typeof globalThis;
116 globalThis.document = dom.window.document;
117 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
118 globalThis.Node = dom.window.Node;
119 globalThis.HTMLElement = dom.window.HTMLElement;
120 globalThis.Event = dom.window.Event;
121 globalThis.CustomEvent = dom.window.CustomEvent;
122 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
123 globalThis.MouseEvent = dom.window.MouseEvent;
124 globalThis.localStorage = dom.window.localStorage;
125 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
126 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
127
128 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
129 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
130 const balance: BalanceInfo = { available: false, display: "" };
131 const jobs: JobView[] = [];
132 const checkpoints: CheckpointMeta[] = [];
133 const tabA = tabMeta("tab-a", { active: true });
134 const tabX = tabMeta("tab-x");
135 const tabY = tabMeta("tab-y");
136 let backendActiveId = "tab-a";
137 // Per-tab holds so any activation can be stalled mid-flight and released.
138 const activationHolds = new Map<string, Promise<void>>();
139 const tabsById = new Map([tabA, tabX, tabY].map((tab) => [tab.id, tab]));
140
141 function currentTabs(): TabMeta[] {
142 return Array.from(tabsById.values()).map((tab) => ({ ...tab, active: tab.id === backendActiveId }));
143 }
144
145 window.runtime = {
146 EventsOn: () => () => {},
147 BrowserOpenURL: () => {},
148 };
149 window.go = {
150 main: {
151 App: {
152 ListTabs: async () => currentTabs(),
153 MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
154 ContextUsageForTab: async () => context,
155 EffortForTab: async () => effort,
156 BalanceForTab: async () => balance,
157 JobsForTab: async () => jobs,
158 CheckpointsForTab: async () => checkpoints,
159 HistoryForTab: async (tabID: string) => {
160 if (tabID === "tab-x") return [userMessage("history X")];
161 if (tabID === "tab-y") return [userMessage("history Y")];
162 return [userMessage("history A")];
163 },
164 HistoryPageForTab: async (tabID: string) => {
165 const messages = await window.go.main.App.HistoryForTab(tabID);
166 return { messages, startTurn: 0, endTurn: messages.length, totalTurns: messages.length, hasOlder: false };
167 },
168 HistoryCheckpointTurnsForTab: async () => [],
169 ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
170 const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabA;
171 const hold = activationHolds.get(target.id);
172 if (hold) await hold;
173 backendActiveId = target.id;
174 return { ...target, active: true };
175 },
176 SetActiveTab: async (tabID: string) => {
177 backendActiveId = tabID;
178 },
179 ReplayPendingPrompts: async () => {},
180 } as Partial<AppBindings> as AppBindings,
181 },
182 };
183
184 type Controller = ReturnType<typeof useController>;
185 let controller: Controller | undefined;
186
187 function Probe() {
188 controller = useController();
189 return null;
190 }
191
192 const rootEl = document.getElementById("root");
193 if (!rootEl) throw new Error("missing root");
194 const root = createRoot(rootEl);
195
196 await act(async () => {
197 root.render(<Probe />);
198 await flushPromises();
199 });
200 await waitFor("initial active tab", () => controller?.activeTabId === "tab-a");
201
202 // Click topic X: the backend call hangs (slow prune / disk).
203 const activateXGate = deferred<void>();
204 activationHolds.set("tab-x", activateXGate.promise);
205 let activateX: Promise<TabMeta> | undefined;
206 await act(async () => {
207 activateX = controller?.activateTopic("project", tabX.workspaceRoot, tabX.topicId ?? "");
208 await flushPromises();
209 });
210 eq(controller?.activeTabId, "tab-a", "held activation does not flip the tab early");
211
212 // The user clicks topic Y before X's backend call returns; Y resolves first.
213 await act(async () => {
214 await controller?.activateTopic("project", tabY.workspaceRoot, tabY.topicId ?? "");
215 await flushPromises();
216 });
217 await waitFor("Y is active with its history", () =>
218 controller?.activeTabId === "tab-y" && controller.state.items.some((item) => item.kind === "user" && item.text === "history Y"));
219
220 // X's stale completion lands after Y applied. Last click must win.
221 await act(async () => {
222 activateXGate.resolve();
223 activationHolds.delete("tab-x");
224 await activateX;
225 await flushPromises();
226 });
227 await act(async () => {
228 await flushPromises();
229 });
230 eq(controller?.activeTabId, "tab-y", "stale activation must not flip the visible tab");
231 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history Y") === true,
232 "stale activation must not delete the visible tab's cached state");
233
234 // A fresh activation afterwards still applies normally (guard is not sticky).
235 await act(async () => {
236 await controller?.activateTopic("project", tabX.workspaceRoot, tabX.topicId ?? "");
237 await flushPromises();
238 });
239 await waitFor("X activates cleanly on a fresh click", () => controller?.activeTabId === "tab-x");
240
241 // --- Through the REAL production navigation queue (#6613 review P1) ---
242 //
243 // App.enqueueNavigation serializes clicks: a click made while another request
244 // runs only becomes a pending queue entry — it does NOT run activateTopic, so
245 // the controller epoch does not advance by itself. The App wiring must bump
246 // the epoch at ENQUEUE time (noteNavigationIntent), otherwise the running
247 // stale activation passes the guard, flips the tab, and prunes cached state.
248 type NavInput = { workspaceRoot: string; topicId: string };
249 const navRefs: NavigationCoalescingRefs<NavInput> = {
250 seqRef: { current: 0 },
251 runningRef: { current: false },
252 pendingRef: { current: null },
253 };
254 const enqueueNav = (workspaceRoot: string, topicId: string): Promise<void> => {
255 controller?.noteNavigationIntent(); // the App.tsx wiring under test
256 return enqueueNavigationRequest(navRefs, { workspaceRoot, topicId }, async (request) => {
257 await controller?.activateTopic("project", request.workspaceRoot, request.topicId);
258 });
259 };
260
261 const gateY = deferred<void>();
262 activationHolds.set("tab-y", gateY.promise);
263 const gateA = deferred<void>();
264 activationHolds.set("tab-a", gateA.promise);
265
266 let queuedFirst: Promise<void> | undefined;
267 let queuedSecond: Promise<void> | undefined;
268 await act(async () => {
269 queuedFirst = enqueueNav(tabY.workspaceRoot, tabY.topicId ?? ""); // runs, held mid-flight
270 await flushPromises();
271 });
272 await act(async () => {
273 queuedSecond = enqueueNav(tabA.workspaceRoot, tabA.topicId ?? ""); // queued, does not run yet
274 await flushPromises();
275 });
276
277 // The first (now stale) activation resolves while the second is still queued.
278 await act(async () => {
279 gateY.resolve();
280 activationHolds.delete("tab-y");
281 await queuedFirst;
282 await flushPromises();
283 });
284 eq(controller?.activeTabId, "tab-x", "queued click invalidates the running activation (no flip to tab-y)");
285 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history X") === true,
286 "queued click keeps the visible tab's cached state intact");
287
288 // The queued request then runs and lands on the user's last click.
289 await act(async () => {
290 gateA.resolve();
291 activationHolds.delete("tab-a");
292 await queuedSecond;
293 await flushPromises();
294 });
295 await waitFor("queued last click applies once it runs", () => controller?.activeTabId === "tab-a");
296
297 // Wiring lock: App.enqueueNavigation must invalidate in-flight activations at
298 // enqueue time — the queue-based scenario above only proves the mechanism.
299 const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
300 ok(
301 /const enqueueNavigation = useCallback\(\(input: DesktopNavigationIntent\)[\s\S]{0,900}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,900}?enqueueNavigationWithIntent\(input, navigationIntentSeq\)/.test(appSource),
302 "App.enqueueNavigation captures a shared navigation intent before handing the request to the queue",
303 );
304 ok(
305 /const enqueueNavigationWithIntent = useCallback\([\s\S]{0,900}?enqueueNavigationRequest\([\s\S]{0,900}?\{ \.\.\.input, navigationIntentSeq \}/.test(appSource),
306 "App.enqueueNavigationWithIntent forwards the captured intent into enqueueNavigationRequest",
307 );
308 ok(
309 /const enqueueTabSwitch = useCallback\([\s\S]{0,1400}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,1400}?switchTab\(request\.tabId, request\.optimisticTab, request\.navigationIntentSeq\)/.test(appSource),
310 "App.enqueueTabSwitch invalidates older navigation at enqueue time and forwards the shared intent",
311 );
312 ok(
313 /const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\)/.test(appSource),
314 "App navigation results require both queue ownership and the shared navigation intent",
315 );
316
317 console.log(`\n${passed} passed, ${failed} failed`);
318 if (failed > 0) process.exit(1);
319
319 lines Plain Text