返回 DeepSeek-Reasonix
use-controller-stale-turn-watchdog.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-stale-turn-watchdog.test.tsx
1 // Run: tsx src/__tests__/use-controller-stale-turn-watchdog.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 type { HistoryMessage, Meta, TabMeta, WireEvent } from "../lib/types";
8 import { useController } from "../lib/useController";
9 import { historySliceFromMessages } from "./mockHistorySlice";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0;
13 let failed = 0;
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 function flushPromises(): Promise<void> {
25 return new Promise((resolve) => globalThis.setTimeout(resolve, 0));
26 }
27
28 async function waitFor(label: string, predicate: () => boolean) {
29 for (let attempt = 0; attempt < 30; attempt += 1) {
30 await act(async () => { await flushPromises(); });
31 if (predicate()) return;
32 }
33 throw new Error(`timed out waiting for ${label}`);
34 }
35
36 console.log("\nstale turn watchdog");
37
38 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
39 pretendToBeVisual: true,
40 url: "http://localhost/",
41 });
42 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
43 globalThis.window = dom.window as unknown as Window & typeof globalThis;
44 globalThis.document = dom.window.document;
45 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
46 globalThis.Node = dom.window.Node;
47 globalThis.HTMLElement = dom.window.HTMLElement;
48 globalThis.Event = dom.window.Event;
49 globalThis.CustomEvent = dom.window.CustomEvent;
50 globalThis.localStorage = dom.window.localStorage;
51 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
52 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
53
54 const originalNow = Date.now;
55 let fakeNow = 1_000;
56 Date.now = () => fakeNow;
57 let nextTimerID = 1;
58 const watchdogTimers = new Map<number, () => void>();
59 window.setTimeout = ((handler: TimerHandler, delay?: number) => {
60 const id = nextTimerID++;
61 if ((delay ?? 0) >= 29_000 && typeof handler === "function") watchdogTimers.set(id, handler);
62 else globalThis.setTimeout(handler, delay);
63 return id;
64 }) as typeof window.setTimeout;
65 window.clearTimeout = ((id?: number) => {
66 if (id !== undefined) watchdogTimers.delete(id);
67 }) as typeof window.clearTimeout;
68
69 const tabID = "tab-watchdog";
70 const sessionPath = "/repo/sessions/watchdog.jsonl";
71 let backendRunning = false;
72 let revision = 1;
73 let history: HistoryMessage[] = [];
74 let listTabsCalls = 0;
75 let historyCalls = 0;
76
77 function tabMeta(): TabMeta {
78 return {
79 id: tabID,
80 scope: "project",
81 workspaceRoot: "/repo",
82 workspaceName: "repo",
83 workspacePath: "/repo",
84 topicId: "topic-watchdog",
85 topicTitle: "Watchdog",
86 sessionPath,
87 sessionRevision: revision,
88 sessionDigest: `digest-${revision}`,
89 label: "model",
90 ready: true,
91 running: backendRunning,
92 cancellable: backendRunning,
93 mode: "normal",
94 toolApprovalMode: "ask",
95 tokenMode: "full",
96 active: true,
97 cwd: "/repo",
98 };
99 }
100
101 function meta(): Meta {
102 return {
103 label: "model",
104 ready: true,
105 eventChannel: "agent:event",
106 sessionPath,
107 sessionRevision: revision,
108 sessionDigest: `digest-${revision}`,
109 cwd: "/repo",
110 workspaceRoot: "/repo",
111 workspaceName: "repo",
112 workspacePath: "/repo",
113 autoApproveTools: false,
114 bypass: false,
115 collaborationMode: "normal",
116 toolApprovalMode: "ask",
117 tokenMode: "full",
118 goal: "",
119 goalStatus: "stopped",
120 };
121 }
122
123 const desktopStub = installDesktopHostStub(({
124 main: {
125 App: {
126 ListTabs: async () => {
127 listTabsCalls += 1;
128 return [tabMeta()];
129 },
130 MetaForTab: async () => meta(),
131 ContextUsageForTab: async () => ({ used: 0, window: 1000, sessionTokens: 0 }),
132 EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
133 BalanceForTab: async () => ({ available: false, display: "" }),
134 JobsForTab: async () => [],
135 CheckpointsForTab: async () => [],
136 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
137 HistorySliceForTab: async (_id, request) => {
138 historyCalls += 1;
139 return historySliceFromMessages(tabID, history, request, { revision, digest: `digest-${revision}` });
140 },
141 HistoryCheckpointTurnsForTab: async () => [],
142 ReplayPendingPrompts: async () => {},
143 SubmitToTabWithID: async () => {
144 backendRunning = true;
145 },
146 } as Partial<AppBindings> as AppBindings,
147 },
148 }).main.App);
149
150 type Controller = ReturnType<typeof useController>;
151 let controller: Controller | undefined;
152 function Probe() {
153 controller = useController();
154 return null;
155 }
156
157 const rootElement = document.getElementById("root");
158 if (!rootElement) throw new Error("missing root");
159 const root = createRoot(rootElement);
160 await act(async () => {
161 root.render(<Probe />);
162 await flushPromises();
163 });
164 await waitFor("initial session", () => controller?.activeTabId === tabID && historyCalls > 0);
165
166 await act(async () => {
167 desktopStub.emit("agent:event", { kind: "turn_started", tabId: tabID });
168 desktopStub.emit("agent:event", { kind: "turn_done", tabId: tabID });
169 await flushPromises();
170 });
171 fakeNow += 60_000;
172 await act(async () => {
173 await controller?.send("hello");
174 await flushPromises();
175 });
176 ok(controller?.state.running ?? false, "optimistic submit enters running state without any agent events");
177 ok(controller?.state.turnActive === false, "missing turn_started leaves no live turn evidence");
178 ok(watchdogTimers.size === 0, "v2 never arms a timer that guesses task completion");
179
180 const firstTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
181 if (firstTimer) {
182 watchdogTimers.delete(firstTimer[0]);
183 fakeNow += 30_000;
184 await act(async () => {
185 firstTimer[1]();
186 await flushPromises();
187 });
188 }
189 ok(listTabsCalls >= 1, "initial tab discovery completed");
190 ok(controller?.state.running ?? false, "a genuinely running backend remains running");
191 ok(watchdogTimers.size === 0, "network silence does not restart a watchdog");
192
193 backendRunning = false;
194 revision = 2;
195 history = [
196 { role: "user", content: "hello" },
197 { role: "assistant", content: "recovered answer" },
198 ];
199 const secondTimer = watchdogTimers.entries().next().value as [number, () => void] | undefined;
200 if (secondTimer) {
201 watchdogTimers.delete(secondTimer[0]);
202 fakeNow += 30_000;
203 await act(async () => {
204 secondTimer[1]();
205 await flushPromises();
206 });
207 }
208 ok(controller?.state.running === true, "idle metadata alone cannot end the turn");
209 await act(async () => {
210 desktopStub.emit("agent:event", { kind: "message", tabId: tabID, messageId: "final", text: "recovered answer" });
211 desktopStub.emit("agent:event", { kind: "turn_done", tabId: tabID });
212 await flushPromises();
213 });
214 ok(controller?.state.running === false, "ordered backend completion settles the spinner");
215 ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "recovered answer") ?? false, "completion updates the same visible transcript");
216 ok(watchdogTimers.size === 0, "settled turn leaves no watchdog timer behind");
217
218 await act(async () => { root.unmount(); });
219 Date.now = originalNow;
220 dom.window.close();
221
222 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
223 if (failed > 0) process.exit(1);
224
224 lines Plain Text