返回 DeepSeek-Reasonix
use-controller-send-fallback.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-send-fallback.test.tsx
1 // Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { AppBindings } from "../lib/bridge";
7 import { LocaleProvider, preloadLocale, useI18n } from "../lib/i18n";
8 import { useController } from "../lib/useController";
9 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function eq(actual: unknown, expected: unknown, label: string) {
26 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
27 }
28
29 function flushPromises(): Promise<void> {
30 return new Promise((resolve) => setTimeout(resolve, 0));
31 }
32
33 function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
34 return {
35 id: "tab-send",
36 scope: "project",
37 workspaceRoot: "/repo/send",
38 workspaceName: "send",
39 workspacePath: "/repo/send",
40 gitBranch: "main",
41 topicId: "topic-send",
42 topicTitle: "Send",
43 label: "model-send",
44 ready: true,
45 running: false,
46 mode: "normal",
47 toolApprovalMode: "ask",
48 tokenMode: "full",
49 active: true,
50 cwd: "/repo/send",
51 sessionId: "session-send",
52 session: { hostId: "local", sessionId: "session-send" },
53 sessionGeneration: 1,
54 runtime: { phase: "ready", epoch: "runtime-send" },
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 sessionId: tab.sessionId,
71 session: tab.session,
72 sessionGeneration: tab.sessionGeneration,
73 runtime: tab.runtime,
74 autoApproveTools: false,
75 bypass: false,
76 collaborationMode: tab.collaborationMode ?? "normal",
77 toolApprovalMode: tab.toolApprovalMode ?? "ask",
78 tokenMode: tab.tokenMode ?? "full",
79 goal: "",
80 goalStatus: "stopped",
81 };
82 }
83
84 console.log("\nuse controller send fallback");
85
86 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
87 pretendToBeVisual: true,
88 url: "http://localhost/",
89 });
90 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
91 globalThis.window = dom.window as unknown as Window & typeof globalThis;
92 globalThis.document = dom.window.document;
93 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
94 globalThis.Node = dom.window.Node;
95 globalThis.HTMLElement = dom.window.HTMLElement;
96 globalThis.Event = dom.window.Event;
97 globalThis.CustomEvent = dom.window.CustomEvent;
98 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
99 globalThis.MouseEvent = dom.window.MouseEvent;
100 globalThis.localStorage = dom.window.localStorage;
101 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
102 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
103
104 let backendTab = tabMeta({ backgroundJobs: 2 });
105 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
106 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
107 const balance: BalanceInfo = { available: false, display: "" };
108 const jobs: JobView[] = [];
109 const checkpoints: CheckpointMeta[] = [];
110 let tabsAvailable = false;
111 let submitCalls = 0;
112 let rejectSubmit = false;
113 let rejectAnswer = false;
114 let rejectAnswerMessage = "prompt write failed";
115 let rejectListTabs = false;
116 let listTabsCalls = 0;
117 let pendingPromptIdentityCalls = 0;
118 const exactAnswerCalls: Array<{ tabId: string; turnId: string; promptId: string; answer: unknown }> = [];
119 const legacyAnswerCalls: string[] = [];
120
121 const desktopStub = installDesktopHostStub(({
122 main: {
123 App: {
124 ListTabs: async () => {
125 listTabsCalls += 1;
126 if (rejectListTabs) throw new Error("runtime status unavailable");
127 return tabsAvailable ? [backendTab] : [];
128 },
129 MetaForTab: async () => metaFor(backendTab),
130 ContextUsageForTab: async () => context,
131 EffortForTab: async () => effort,
132 BalanceForTab: async () => balance,
133 JobsForTab: async () => jobs,
134 CheckpointsForTab: async () => checkpoints,
135 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
136 HistoryForTab: async (): Promise<HistoryMessage[]> => [],
137 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
138 HistoryCheckpointTurnsForTab: async () => [],
139 ReplayPendingPrompts: async () => {},
140 ReplayPendingPromptsForTab: async () => {},
141 PendingPromptIdentitiesForTab: async (tabId: string) => {
142 pendingPromptIdentityCalls += tabId === "tab-send" ? 1 : 0;
143 return backendTab.pendingPrompt && backendTab.turnId ? [{
144 promptId: backendTab.turnId === "turn-authoritative" ? "ask-fallback" : "ask-retry",
145 turnId: backendTab.turnId,
146 runtimeEpoch: backendTab.runtime?.epoch,
147 kind: "ask",
148 }] : [];
149 },
150 SubmitToTab: async (tabId: string) => {
151 submitCalls += tabId === "tab-send" ? 1 : 0;
152 },
153 SubmitToTabWithID: async (tabId: string) => {
154 submitCalls += tabId === "tab-send" ? 1 : 0;
155 if (rejectSubmit) throw new Error("turn already running");
156 },
157 AnswerQuestionForTab: async (_tabId: string, promptId: string) => { legacyAnswerCalls.push(promptId); },
158 ResolvePromptForSession: async (target, answer) => {
159 exactAnswerCalls.push({ tabId: target.tabId, turnId: target.turnId, promptId: target.promptId, answer });
160 if (rejectAnswer && rejectAnswerMessage.includes("not the active turn")) desktopStub.emit("agent:event", { kind: "prompt_answered", tabId: target.tabId, itemId: target.promptId });
161 if (rejectAnswer) throw new Error(rejectAnswerMessage);
162 },
163 } as Partial<AppBindings> as AppBindings,
164 },
165 }).main.App);
166
167 type Controller = ReturnType<typeof useController>;
168 let controller: Controller | undefined;
169 let setLocale: ReturnType<typeof useI18n>["setPref"] | undefined;
170
171 function Probe() {
172 setLocale = useI18n().setPref;
173 controller = useController();
174 return null;
175 }
176
177 const rootEl = document.getElementById("root");
178 if (!rootEl) throw new Error("missing root");
179 const root = createRoot(rootEl);
180
181 await act(async () => {
182 root.render(<LocaleProvider><Probe /></LocaleProvider>);
183 await flushPromises();
184 });
185 eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
186
187 tabsAvailable = true;
188 await act(async () => {
189 await controller?.send("hello from fallback");
190 await flushPromises();
191 });
192
193 eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
194 eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
195 ok(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "hello from fallback"), "send fallback keeps the optimistic user turn");
196 eq(submitCalls, 1, "send fallback submits to the activated tab");
197
198 await act(async () => {
199 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send" } as WireEvent);
200 await flushPromises();
201 });
202
203 backendTab = tabMeta({ running: true, pendingPrompt: true, turnId: "turn-authoritative" });
204 await act(async () => {
205 desktopStub.emit("agent:event", {
206 kind: "ask_request",
207 tabId: "tab-send",
208 runtimeEpoch: "runtime-send",
209 ask: { id: "ask-fallback", questions: [{ id: "q1", prompt: "Proceed?", options: [{ label: "yes" }] }] },
210 } as WireEvent);
211 await flushPromises();
212 });
213 eq(controller?.state.activeTurnId, undefined, "Ask fixture starts without a local turn id");
214 const beforeAnswerListCalls = listTabsCalls;
215 const beforePendingPromptIdentityCalls = pendingPromptIdentityCalls;
216 await act(async () => {
217 await controller?.answerQuestion("ask-fallback", [{ questionId: "q1", selected: ["yes"] }]);
218 await flushPromises();
219 });
220 eq(listTabsCalls, beforeAnswerListCalls, "Ask answer does not borrow the latest tab turn");
221 eq(pendingPromptIdentityCalls, beforePendingPromptIdentityCalls + 1, "Ask answer resolves one exact pending-prompt identity");
222 eq(exactAnswerCalls.at(-1)?.turnId, "turn-authoritative", "Ask answer uses the authoritative turn fence");
223 eq(legacyAnswerCalls.length, 0, "Ask answer never falls back to the unfenced endpoint");
224 eq(controller?.state.ask, undefined, "successful exact answer clears the matching Ask without replay");
225
226 await act(async () => {
227 desktopStub.emit("agent:event", {
228 kind: "ask_request",
229 tabId: "tab-send",
230 turnId: "turn-authoritative",
231 runtimeEpoch: "runtime-send",
232 ask: { id: "ask-retry", questions: [{ id: "q2", prompt: "Retry?", options: [{ label: "yes" }] }] },
233 } as WireEvent);
234 await flushPromises();
235 });
236 rejectAnswer = true;
237 let answerRejected = false;
238 await preloadLocale("zh");
239 await act(async () => {
240 setLocale?.("zh");
241 await flushPromises();
242 });
243 await act(async () => {
244 try {
245 await controller?.answerQuestion("ask-retry", [{ questionId: "q2", selected: ["yes"] }]);
246 } catch {
247 answerRejected = true;
248 }
249 await flushPromises();
250 });
251 eq(answerRejected, true, "failed exact answer propagates to AskCard");
252 eq(controller?.state.ask?.id, "ask-retry", "failed exact answer preserves the pending Ask");
253 eq(controller?.state.pendingPrompt, true, "failed exact answer keeps the prompt gate active");
254 eq(controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("prompt write failed"))?.text, "提交回答失败:prompt write failed", "failed Ask answer uses the active locale");
255 rejectAnswer = false;
256
257 rejectSubmit = true;
258 await act(async () => {
259 await controller?.send("continue while prompt is pending").catch(() => {});
260 await flushPromises();
261 await flushPromises();
262 });
263 eq(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "continue while prompt is pending" && submission.status === "failed"), true, "colliding submit marks its optimistic bubble failed");
264 eq(controller?.state.ask?.id, "ask-retry", "colliding submit preserves the pending Ask");
265 eq(controller?.state.running, true, "active backend snapshot keeps the composer blocked after rejection");
266 eq(controller?.state.pendingPrompt, true, "active backend snapshot restores the prompt gate after rejection");
267 eq(controller?.state.activeTurnId, "turn-authoritative", "active backend snapshot restores the authoritative turn id");
268
269 await act(async () => {
270 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send", turnId: "turn-authoritative" } as WireEvent);
271 backendTab = tabMeta({ running: false, pendingPrompt: false, turnId: undefined });
272 await flushPromises();
273 await controller?.send("retry against an idle backend").catch(() => {});
274 await flushPromises();
275 await flushPromises();
276 });
277 eq(controller?.state.running, false, "authoritative idle snapshot releases a rejected submit");
278 eq(controller?.state.pendingPrompt, false, "authoritative idle snapshot leaves no prompt gate");
279
280 rejectListTabs = true;
281 const beforeFailedReconcileCalls = listTabsCalls;
282 await act(async () => {
283 await controller?.send("retry while runtime status is unavailable").catch(() => {});
284 await new Promise((resolve) => setTimeout(resolve, 1_500));
285 });
286 eq(listTabsCalls - beforeFailedReconcileCalls, 4, "rejected submit retries failed ListTabs reads at every bounded delay");
287 eq(controller?.state.running, true, "exhausted status reads leave the composer conservatively blocked");
288 rejectListTabs = false;
289
290 backendTab = tabMeta({ running: true, pendingPrompt: true, turnId: "turn-authoritative" });
291 await act(async () => {
292 desktopStub.emit("agent:event", {
293 kind: "ask_request",
294 tabId: "tab-send",
295 turnId: "turn-authoritative",
296 runtimeEpoch: "runtime-send",
297 ask: { id: "ask-stale", questions: [{ id: "q3", prompt: "Stale?", options: [{ label: "yes" }] }] },
298 } as WireEvent);
299 await flushPromises();
300 });
301 rejectAnswer = true;
302 rejectAnswerMessage = 'turn "turn-old" is not the active turn for tab "tab-send"';
303 await act(async () => {
304 try { await controller?.answerQuestion("ask-stale", [{ questionId: "q3", selected: ["yes"] }]); } catch {}
305 await flushPromises();
306 });
307 eq(controller?.state.ask, undefined, "stale Ask submission expires the old card");
308 rejectAnswer = false;
309 rejectAnswerMessage = "prompt write failed";
310
311 // Pre-admission rejection does not append a backend event. A fresh idle read
312 // with the same sequence must still undo each optimistic submission.
313 rejectSubmit = true;
314 backendTab = tabMeta({ running: false, pendingPrompt: false, turnEventSeq: 700 });
315 await act(async () => {
316 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send" } as WireEvent);
317 await controller?.send("seed idle status after rejected submit").catch(() => {});
318 await flushPromises();
319 await flushPromises();
320 });
321 eq(controller?.state.runtimeStatusSeq, 1, "baseline uses the business cut, never the old ledger sequence");
322 eq(controller?.state.running, false, "first rejection settles to authoritative idle");
323 await act(async () => {
324 await controller?.send("second pre-admission rejection").catch(() => {});
325 await flushPromises();
326 await flushPromises();
327 });
328 eq(controller?.state.running, false, "same-sequence idle settles a new failed submit");
329 eq(controller?.state.cancellable, false, "same-sequence idle removes the stale Stop action");
330
331 await act(async () => {
332 root.unmount();
333 });
334 dom.window.close();
335
336 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
337 if (failed > 0) process.exit(1);
338
338 lines Plain Text