返回 DeepSeek-Reasonix
use-controller-cancel-reconcile.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-cancel-reconcile.test.tsx
1 // Run: tsx src/__tests__/use-controller-cancel-reconcile.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { useController } from "../lib/useController";
7 import type { AppBindings } from "../lib/bridge";
8 import type { ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, Meta, TabMeta, WireEvent } from "../lib/types";
9 import { historySliceFromMessages } from "./mockHistorySlice";
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 async function waitFor(label: string, predicate: () => boolean) {
34 for (let attempt = 0; attempt < 50; attempt += 1) {
35 await act(async () => {
36 await flushPromises(20);
37 });
38 if (predicate()) return;
39 }
40 throw new Error(`timed out waiting for ${label}`);
41 }
42
43 function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
44 return {
45 id: "tab-a",
46 scope: "project",
47 workspaceRoot: "/repo",
48 workspaceName: "repo",
49 workspacePath: "/repo",
50 topicId: "topic-a",
51 topicTitle: "General",
52 session: { hostId: "local", sessionId: "canonical-a" },
53 label: "model",
54 ready: true,
55 running: false,
56 cancellable: false,
57 mode: "normal",
58 toolApprovalMode: "ask",
59 tokenMode: "full",
60 active: true,
61 cwd: "/repo",
62 ...overrides,
63 };
64 }
65
66 function meta(): Meta {
67 return {
68 label: "model",
69 ready: true,
70 eventChannel: "agent:event",
71 cwd: "/repo",
72 workspaceRoot: "/repo",
73 workspaceName: "repo",
74 workspacePath: "/repo",
75 session: { hostId: "local", sessionId: "canonical-a" },
76 autoApproveTools: false,
77 bypass: false,
78 collaborationMode: "normal",
79 toolApprovalMode: "ask",
80 tokenMode: "full",
81 goal: "",
82 goalStatus: "stopped",
83 };
84 }
85
86 console.log("\nuse controller cancel reconcile");
87
88 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
89 pretendToBeVisual: true,
90 url: "http://localhost/",
91 });
92 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
93 globalThis.window = dom.window as unknown as Window & typeof globalThis;
94 globalThis.document = dom.window.document;
95 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
96 globalThis.Node = dom.window.Node;
97 globalThis.HTMLElement = dom.window.HTMLElement;
98 globalThis.Event = dom.window.Event;
99 globalThis.CustomEvent = dom.window.CustomEvent;
100 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
101 globalThis.MouseEvent = dom.window.MouseEvent;
102 globalThis.localStorage = dom.window.localStorage;
103 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
104 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
105
106 let backendRunning = false;
107 const backendHistory: HistoryMessage[] = [{
108 role: "user",
109 content: "hello",
110 messageId: "initial-user",
111 createdAt: 1000,
112 checkpointTurn: 0,
113 attachments: [{ kind: "image", digest: "a".repeat(64), name: "photo.png", mime: "image/png", width: 1, height: 1, bytes: 68 }],
114 }];
115 let cancelCalls = 0;
116 let cancelInboxCalls = 0;
117 let cancelInboxError: Error | null = null;
118 let cancelDiscardedItemIDs: string[] = [];
119 let interruptCalls = 0;
120 let interruptError: Error | null = null;
121 let effortCalls = 0;
122 let checkpointHistoryCalls = 0;
123 let historyLoads = 0;
124 let checkpointLoads = 0;
125 let turnReplayCalls = 0;
126 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
127 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
128
129 const desktopStub = installDesktopHostStub(({
130 main: {
131 App: {
132 ListTabs: async () => [tabMeta({ running: backendRunning, cancellable: backendRunning })],
133 MetaForTab: async () => meta(),
134 ContextUsageForTab: async () => context,
135 EffortForTab: async () => effort,
136 SetEffortForTab: async () => {
137 effortCalls += 1;
138 throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
139 },
140 BalanceForTab: async () => ({ available: false, display: "" }),
141 JobsForTab: async () => [],
142 CheckpointsForTab: async () => {
143 checkpointLoads += 1;
144 return [{ turn: 0, prompt: "hello", files: [], time: Date.now(), canConversation: true }];
145 },
146 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
147 HistoryForTab: async () => [],
148 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
149 historyLoads += 1;
150 return historySliceFromMessages(
151 tabID,
152 backendHistory,
153 req,
154 );
155 },
156 HistoryCheckpointTurnsForTab: async () => {
157 checkpointHistoryCalls += 1;
158 return [];
159 },
160 ReplayPendingPrompts: async () => {},
161 TurnEventsForTab: async (_tabID: string, afterSeq: number) => {
162 turnReplayCalls += 1;
163 const events = afterSeq !== 1 ? [] : [
164 {
165 turnId: "turn-gap",
166 seq: 2,
167 status: "in_progress",
168 event: { kind: "turn_started", turnId: "turn-gap", seq: 2, status: "in_progress" },
169 },
170 {
171 turnId: "turn-gap",
172 seq: 3,
173 status: "waiting_user",
174 event: { kind: "turn_status", turnId: "turn-gap", seq: 3, status: "waiting_user" },
175 },
176 ];
177 return {
178 events,
179 floorSeq: 1,
180 latestSeq: 3,
181 nextAfterSeq: events.length > 0 ? 3 : afterSeq,
182 hasMore: false,
183 resetRequired: false,
184 };
185 },
186 SubmitToTab: async () => {},
187 SubmitToTabWithID: async (tabId: string, text: string, submissionId: string) => {
188 const messageId = `user-${submissionId}`;
189 const createdAt = Date.now();
190 backendHistory.push({ role: "user", content: text, messageId, submissionId, createdAt });
191 desktopStub.emit("agent:event", { kind: "user_message", tabId, text, messageId, submissionId, createdAt });
192 },
193 CancelTab: async () => {
194 cancelCalls += 1;
195 backendRunning = false;
196 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
197 },
198 CancelTabWithInboxItems: async () => {
199 cancelInboxCalls += 1;
200 if (cancelInboxError) throw cancelInboxError;
201 backendRunning = false;
202 },
203 CancelTabWithInboxItemsResult: async () => {
204 cancelInboxCalls += 1;
205 if (cancelInboxError) throw cancelInboxError;
206 backendRunning = false;
207 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
208 return { discardedItemIds: [...cancelDiscardedItemIDs] };
209 },
210 InterruptTurnForTab: async () => {
211 interruptCalls += 1;
212 if (interruptError) throw interruptError;
213 backendRunning = false;
214 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", status: "cancelled" });
215 },
216 } as Partial<AppBindings> as AppBindings,
217 },
218 }).main.App);
219
220 type Controller = ReturnType<typeof useController>;
221 let controller: Controller | undefined;
222
223 function Probe() {
224 controller = useController();
225 return null;
226 }
227
228 const rootEl = document.getElementById("root");
229 if (!rootEl) throw new Error("missing root");
230 const root = createRoot(rootEl);
231
232 await act(async () => {
233 root.render(<Probe />);
234 await flushPromises();
235 });
236 await waitFor("active tab", () => controller?.activeTabId === "tab-a");
237 await act(async () => {
238 await flushPromises(50);
239 });
240 historyLoads = 0;
241 checkpointLoads = 0;
242
243 // A future event must pause projection until the missing durable prefix has
244 // been replayed. This interleaving is driven only by resolved promises (no
245 // timing sleeps), then a duplicate seq=3 is ignored idempotently.
246 await act(async () => {
247 desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 1, status: "queued" });
248 desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "waiting_user" });
249 for (let step = 0; step < 20; step += 1) await Promise.resolve();
250 });
251 eq(turnReplayCalls, 0, "v2 does not consult the retired ledger sequence space");
252 eq(controller?.state.pendingPrompt, true, "future event projects only after the missing prefix");
253 await act(async () => {
254 desktopStub.emit("agent:event", { kind: "turn_status", tabId: "tab-a", turnId: "turn-gap", seq: 3, status: "in_progress" });
255 await Promise.resolve();
256 });
257 eq(controller?.state.pendingPrompt, false, "ordered Follow revision is authoritative despite legacy sequence values");
258 const historyLoadsBeforeSettlement = historyLoads;
259 const historyMutationBeforeSettlement = controller?.state.historyMutation.seq ?? 0;
260 await act(async () => {
261 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", turnId: "turn-gap", seq: 4, status: "completed" });
262 await Promise.resolve();
263 });
264 // A terminal turn is folded into the bounded durable window independently of
265 // cancellation. Let that expected read settle before measuring the later
266 // cancellation path, whose invariant is still that it schedules no reload.
267 await act(async () => { await flushPromises(); });
268 eq(historyLoads, historyLoadsBeforeSettlement, "completion never rebases from history");
269 ok((controller?.state.historyMutation.seq ?? 0) >= historyMutationBeforeSettlement, "completion retains the installed transcript");
270 historyLoads = 0;
271
272 backendRunning = true;
273 await act(async () => {
274 desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a" });
275 await flushPromises();
276 });
277 eq(controller?.state.running, true, "turn_started marks the tab running");
278
279 await act(async () => {
280 controller?.cancel();
281 await flushPromises();
282 await flushPromises();
283 });
284
285 for (let attempt = 0; attempt < 20 && controller?.state.running; attempt += 1) {
286 await act(async () => {
287 await flushPromises(50);
288 });
289 }
290
291 eq(controller?.state.running, false, "cancel reconciliation clears the running state");
292 eq(cancelCalls, 1, "CancelTab is called once");
293 eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
294 await waitFor("cancelled checkpoint refresh", () => checkpointLoads > 0);
295 eq(historyLoads, 0, "cancellation never reloads or replaces the transcript");
296 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"), "cancelled prompt stays in the projected transcript");
297 ok(controller?.state.checkpoints.some((checkpoint) => checkpoint.turn === 0 && checkpoint.canConversation), "cancelled prompt keeps its conversation checkpoint");
298
299 // The screenshot repro stops before turn_started, while the backend has
300 // already accepted the prompt and will persist it during cancellation cleanup.
301 historyLoads = 0;
302 checkpointLoads = 0;
303 backendRunning = true;
304 await act(async () => {
305 await controller?.send("hello");
306 await flushPromises();
307 });
308 ok(
309 controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"),
310 "an immediate stop still has the optimistic prompt item",
311 );
312 await act(async () => {
313 controller?.cancel();
314 await flushPromises();
315 });
316 await waitFor("immediate cancellation", () => !controller?.state.running && checkpointLoads > 0);
317 eq(historyLoads, 0, "immediate cancellation does not schedule a transcript hydrate");
318 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello"), "immediate cancellation keeps the optimistic prompt bubble");
319 ok(controller?.state.checkpoints.some((checkpoint) => checkpoint.turn === 0 && checkpoint.canConversation), "immediate cancellation keeps the rewind checkpoint");
320
321 // A new submission after the interrupted terminal boundary must remain in the
322 // reducer because cancellation has no whole-history replacement path anymore.
323 historyLoads = 0;
324 await act(async () => {
325 await controller?.send("corrected");
326 await flushPromises();
327 });
328 ok(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "corrected"),
329 "resubmission keeps its local echo through completed cancellation cleanup");
330 eq(historyLoads, 0, "resubmission cannot race a stale cancellation history response");
331
332 await act(async () => {
333 desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", checkpointTurn: 0 });
334 await flushPromises();
335 });
336 eq(checkpointHistoryCalls, 0, "TurnDone does not request the full checkpoint-turn history");
337
338 await act(async () => {
339 await controller?.setEffort("max");
340 await flushPromises();
341 });
342
343 const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
344 eq(effortCalls, 1, "SetEffortForTab is called once");
345 ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
346
347 cancelDiscardedItemIDs = ["withdrawn-guidance"];
348 let cancelOutcome: Awaited<ReturnType<NonNullable<typeof controller>["cancel"]>> | undefined;
349 await act(async () => {
350 cancelOutcome = await controller?.cancel(["withdrawn-guidance", "delivered-guidance"]);
351 await flushPromises();
352 });
353 eq(cancelOutcome?.discardedItemIds.join(","), "withdrawn-guidance", "cancel returns only backend-confirmed withdrawn IDs");
354
355 cancelInboxError = new Error("reasonix_error:inbox_invalid_state");
356 await act(async () => {
357 cancelOutcome = await controller?.cancel(["queued-guidance"]);
358 await flushPromises();
359 });
360 ok(Boolean(cancelOutcome?.error), "cancellation outcome preserves failure for the decision card");
361 const inboxCancelNotice = controller?.state.items.find((item) =>
362 item.kind === "notice" && item.text.includes("Cancel failed: This inbox instruction cannot be changed"),
363 );
364 eq(cancelInboxCalls, 2, "receipt-capable cancellation is called for durable guidance");
365 ok(Boolean(inboxCancelNotice), "cancel failure formats the stable inbox code for the active locale");
366 ok(inboxCancelNotice?.kind === "notice" && !inboxCancelNotice.text.includes("reasonix_error:"), "cancel failure never renders the stable transport code");
367
368 // Stop is a session-level request: an exact-turn fence rejection (stale or
369 // replaced turn id) must fall back to the unconditional cancel instead of
370 // leaving the user with a "Cancel failed" notice and a running turn.
371 const noticesBefore = controller?.state.items.filter((item) => item.kind === "notice").length ?? 0;
372 const cancelCallsBefore = cancelCalls;
373 backendRunning = true;
374 interruptError = new Error('turn "turn-live" is not the active turn for tab "tab-a"');
375 await act(async () => {
376 desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-live" });
377 await flushPromises();
378 });
379 eq(controller?.state.activeTurnId, "turn-live", "turn_started with a turn id records the active turn");
380 await act(async () => {
381 await controller?.cancel();
382 await flushPromises();
383 });
384 eq(interruptCalls, 1, "exact-turn stop is attempted first");
385 eq(cancelCalls, cancelCallsBefore + 1, "fence rejection falls back to the unconditional CancelTab");
386 eq(controller?.state.items.filter((item) => item.kind === "notice").length, noticesBefore, "fence rejection does not surface a Cancel failed notice");
387 await waitFor("fallback cancel reconciliation", () => controller?.state.running === false);
388
389 // An idle backend answers with a stable code; the UI reconciles quietly.
390 backendRunning = false;
391 interruptError = new Error("reasonix_error:turn_not_running");
392 await act(async () => {
393 desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-idle" });
394 await flushPromises();
395 });
396 await act(async () => {
397 await controller?.cancel();
398 await flushPromises();
399 });
400 eq(interruptCalls, 2, "idle stop still asks the backend once");
401 eq(cancelCalls, cancelCallsBefore + 1, "idle stop does not retry through CancelTab");
402 eq(controller?.state.items.filter((item) => item.kind === "notice").length, noticesBefore, "idle stop does not surface a Cancel failed notice");
403 await waitFor("idle stop reconciliation", () => controller?.state.running === false);
404
405 // A transport gap can hide the final event entirely: the resnapshot must
406 // settle core state while retaining the mounted transcript and never cancel.
407 backendRunning = true;
408 await act(async () => {
409 desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-a", turnId: "turn-gap-final" });
410 await flushPromises();
411 });
412 const gapCancelCalls = cancelCalls;
413 const projectedUsers = controller?.state.items.filter(item => item.kind === "user") ?? [];
414 const gapHistoryLoads = historyLoads;
415 backendRunning = false;
416 await act(async () => {
417 desktopStub.emit("desktop:resync", { generation: "g-current", reason: "gap", expectedSeq: 2, actualSeq: 4 });
418 await flushPromises();
419 });
420 await waitFor("event gap runtime snapshot", () => controller?.state.running === false);
421 eq(cancelCalls, gapCancelCalls, "event gap recovery never replays a state-changing command");
422 ok(projectedUsers.every(item => controller?.state.items.includes(item)), "event gap recovery retains projected user item identities");
423 eq(historyLoads, gapHistoryLoads + 1, "transport gap obtains one consistent Follow snapshot");
424
425 await act(async () => {
426 root.unmount();
427 });
428 dom.window.close();
429
430 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
431 if (failed > 0) process.exit(1);
432
432 lines Plain Text