返回 DeepSeek-Reasonix
goal-activation-tab-routing.test.tsx
根目录 / desktop / frontend / src / __tests__ / goal-activation-tab-routing.test.tsx
1 // Run: tsx src/__tests__/goal-activation-tab-routing.test.tsx
2 //
3 // App/bridge harness for the first Goal + structured Skill path: hang the
4 // combined backend call for A, switch active to B, then assert the source tab
5 // and workbench target token stayed fixed.
6
7 import { JSDOM } from "jsdom";
8 import React, { act } from "react";
9 import { createRoot } from "react-dom/client";
10 import type { AppBindings } from "../lib/bridge";
11 import { useController } from "../lib/useController";
12 import { historySliceFromMessages } from "./mockHistorySlice";
13 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types";
14 import { installDesktopHostStub } from "./desktopHostStub";
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: boolean, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function eq(actual: unknown, expected: unknown, label: string) {
30 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
31 }
32
33 function flushPromises(): Promise<void> {
34 return new Promise((resolve) => setTimeout(resolve, 0));
35 }
36
37 function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
38 return {
39 id: "tab-a",
40 scope: "project",
41 workspaceRoot: "/repo",
42 workspaceName: "repo",
43 workspacePath: "/repo",
44 gitBranch: "main",
45 topicId: "topic-a",
46 topicTitle: "A",
47 label: "model",
48 ready: true,
49 running: false,
50 mode: "normal",
51 toolApprovalMode: "ask",
52 tokenMode: "full",
53 active: true,
54 cwd: "/repo",
55 ...overrides,
56 };
57 }
58
59 function metaFor(tab: TabMeta): Meta {
60 return {
61 label: tab.label,
62 ready: tab.ready,
63 startupErr: tab.startupErr,
64 eventChannel: "agent:event",
65 cwd: tab.cwd || tab.workspaceRoot,
66 workspaceRoot: tab.workspaceRoot,
67 workspaceName: tab.workspaceName,
68 workspacePath: tab.workspacePath,
69 gitBranch: tab.gitBranch,
70 autoApproveTools: false,
71 bypass: false,
72 collaborationMode: tab.collaborationMode ?? "normal",
73 toolApprovalMode: tab.toolApprovalMode ?? "ask",
74 tokenMode: tab.tokenMode ?? "full",
75 goal: tab.goal ?? "",
76 goalStatus: tab.goal ? "running" : "stopped",
77 };
78 }
79
80 console.log("\ngoal activation tab routing");
81
82 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
83 pretendToBeVisual: true,
84 url: "http://localhost/",
85 });
86 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
87 globalThis.window = dom.window as unknown as Window & typeof globalThis;
88 globalThis.document = dom.window.document;
89 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
90 globalThis.Node = dom.window.Node;
91 globalThis.HTMLElement = dom.window.HTMLElement;
92 globalThis.Event = dom.window.Event;
93 globalThis.CustomEvent = dom.window.CustomEvent;
94 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
95 globalThis.MouseEvent = dom.window.MouseEvent;
96 globalThis.localStorage = dom.window.localStorage;
97 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
98 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
99
100 const tabA = tabMeta({ id: "tab-a", topicId: "topic-a", topicTitle: "A", active: true });
101 const tabB = tabMeta({ id: "tab-b", topicId: "topic-b", topicTitle: "B", active: false, cwd: "/repo-b", workspaceRoot: "/repo-b", workspacePath: "/repo-b" });
102 let tabs: TabMeta[] = [tabA, tabB];
103
104 const initialGoalCalls: string[] = [];
105 let releaseGoal!: () => void;
106 const goalGate = new Promise<void>((resolve) => {
107 releaseGoal = resolve;
108 });
109
110 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
111 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
112 const balance: BalanceInfo = { available: false, display: "" };
113 const jobs: JobView[] = [];
114 const checkpoints: CheckpointMeta[] = [];
115
116 const appStubTable = ({
117 main: {
118 App: {
119 RegisterNavigationIntent: async () => {},
120 ListTabs: async () => tabs.map((tab) => ({ ...tab })),
121 SetActiveTab: async (tabId: string) => {
122 tabs = tabs.map((tab) => ({ ...tab, active: tab.id === tabId }));
123 },
124 MetaForTab: async (tabId: string) => {
125 const tab = tabs.find((entry) => entry.id === tabId) ?? tabA;
126 return metaFor(tab);
127 },
128 ContextUsageForTab: async () => context,
129 EffortForTab: async () => effort,
130 BalanceForTab: async () => balance,
131 JobsForTab: async () => jobs,
132 CheckpointsForTab: async () => checkpoints,
133 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
134 HistoryForTab: async (): Promise<HistoryMessage[]> => [],
135 HistorySliceForTab: async (tabId: string, request: HistorySliceRequest) =>
136 historySliceFromMessages(tabId, [], request),
137 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
138 HistoryCheckpointTurnsForTab: async () => [],
139 ReplayPendingPrompts: async () => {},
140 SetGoalForTab: async (tabID: string, goal: string) => {
141 tabs = tabs.map((tab) =>
142 tab.id === tabID
143 ? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
144 : tab,
145 );
146 },
147 SubmitInitialGoalToTabWithID: async (
148 tabID: string,
149 goal: string,
150 display: string,
151 _input: string,
152 invocations: { name: string }[],
153 collaborationMode: string,
154 toolApprovalMode: string,
155 _submissionID: string,
156 ): Promise<string[]> => {
157 await goalGate;
158 initialGoalCalls.push(
159 `${tabID}:${goal}:${display}:${invocations[0]?.name ?? ""}:${collaborationMode}:${toolApprovalMode}`,
160 );
161 tabs = tabs.map((tab) =>
162 tab.id === tabID
163 ? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
164 : tab,
165 );
166 return [];
167 },
168 SubmitInvocationsToTab: async () => {
169 throw new Error("split SubmitInvocationsToTab must not be used for initial Goals");
170 },
171 SubmitInvocationsToTabWithID: async () => {
172 throw new Error("split SubmitInvocationsToTabWithID must not be used for initial Goals");
173 },
174 SubmitToTab: async () => {
175 throw new Error("plain SubmitToTab must not be used for structured first Goal turns");
176 },
177 SubmitToTabWithID: async () => {
178 throw new Error("plain SubmitToTabWithID must not be used for structured first Goal turns");
179 },
180 } as Partial<AppBindings> as AppBindings,
181 },
182 }).main.App;
183 installDesktopHostStub(appStubTable);
184
185 type Controller = ReturnType<typeof useController>;
186 let controller: Controller | undefined;
187
188 function Probe() {
189 controller = useController();
190 return null;
191 }
192
193 const rootEl = document.getElementById("root");
194 if (!rootEl) throw new Error("missing root");
195 const root = createRoot(rootEl);
196
197 await act(async () => {
198 root.render(<Probe />);
199 await flushPromises();
200 });
201
202 await waitForActive("tab-a");
203 eq(controller?.activeTabId, "tab-a", "harness starts on tab A");
204
205 const sourceTabId = "tab-a";
206 let pending!: Promise<void>;
207 await act(async () => {
208 if (!controller) throw new Error("controller missing");
209 pending = controller.sendToTab(sourceTabId, "Cross-tab safe goal", "/ui-ux-pro-max Cross-tab safe goal", undefined, {
210 display: "/ui-ux-pro-max Cross-tab safe goal",
211 input: "Cross-tab safe goal",
212 invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
213 }, {
214 goal: "Cross-tab safe goal",
215 collaborationMode: "normal",
216 toolApprovalMode: "ask",
217 });
218 await flushPromises();
219 });
220
221 // While the atomic bridge call for A is suspended, the UI switches to tab B.
222 // The in-flight call must keep both its tab and target token.
223 await act(async () => {
224 await controller?.switchTab("tab-b", tabB);
225 await flushPromises();
226 });
227 eq(controller?.activeTabId, "tab-b", "active tab switched to B during deferred Goal activation");
228
229 releaseGoal();
230 await act(async () => {
231 await pending;
232 await flushPromises();
233 });
234
235 eq(
236 initialGoalCalls.join("|"),
237 "tab-a:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:normal:ask",
238 "atomic Goal submit kept source tab A",
239 );
240 eq(initialGoalCalls.length, 1, "atomic Goal submit ran once");
241
242 // Bridge failure path: controller must reject without falling back to the split
243 // structured submit.
244 const failedInitialGoalCalls: string[] = [];
245 const failInvokeCalls: string[] = [];
246 appStubTable.SubmitInitialGoalToTabWithID = async (tabID: string) => {
247 failedInitialGoalCalls.push(tabID);
248 throw new Error("workbench target changed");
249 };
250 appStubTable.SubmitInvocationsToTab = async (tabID: string) => {
251 failInvokeCalls.push(tabID);
252 };
253
254 let activationFailed = false;
255 await act(async () => {
256 try {
257 await controller!.sendToTab("tab-a", "Must not run skill", "/ui-ux-pro-max Must not run skill", undefined, {
258 display: "/ui-ux-pro-max Must not run skill",
259 input: "Must not run skill",
260 invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
261 }, {
262 goal: "Must not run skill",
263 collaborationMode: "normal",
264 toolApprovalMode: "ask",
265 });
266 } catch (error) {
267 activationFailed = error instanceof Error && error.message.includes("workbench target changed");
268 }
269 await flushPromises();
270 });
271 eq(activationFailed, true, "controller propagates atomic Goal bridge rejection");
272 eq(failedInitialGoalCalls.join("|"), "tab-a", "failed atomic submit still targeted source tab A");
273 eq(failInvokeCalls.length, 0, "failed atomic Goal submit does not call split SubmitInvocationsToTab");
274
275 await act(async () => {
276 root.unmount();
277 });
278 dom.window.close();
279
280 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
281 if (failed > 0) process.exit(1);
282
283 async function waitForActive(tabId: string) {
284 for (let attempt = 0; attempt < 50; attempt += 1) {
285 if (controller?.activeTabId === tabId) return;
286 await act(async () => {
287 await flushPromises();
288 });
289 }
290 throw new Error(`timed out waiting for active tab ${tabId}`);
291 }
292
292 lines Plain Text