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