返回 DeepSeek-Reasonix
use-controller-history-live-race.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-history-live-race.test.tsx
1 // Run: tsx src/__tests__/use-controller-history-live-race.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 { useController } from "../lib/useController";
8 import { historySliceFromMessages } from "./mockHistorySlice";
9 import type { HistorySlice, 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 deferred<T>() {
26 let resolve!: (value: T) => void;
27 const promise = new Promise<T>((done) => { resolve = done; });
28 return { promise, resolve };
29 }
30
31 function flushPromises(): Promise<void> {
32 return new Promise((resolve) => setTimeout(resolve, 0));
33 }
34
35 async function waitFor(label: string, predicate: () => boolean) {
36 for (let attempt = 0; attempt < 30; attempt += 1) {
37 await act(async () => { await flushPromises(); });
38 if (predicate()) return;
39 }
40 throw new Error(`timed out waiting for ${label}`);
41 }
42
43 console.log("\nuse controller history/live race");
44
45 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
46 pretendToBeVisual: true,
47 url: "http://localhost/",
48 });
49 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
50 globalThis.window = dom.window as unknown as Window & typeof globalThis;
51 globalThis.document = dom.window.document;
52 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
53 globalThis.Node = dom.window.Node;
54 globalThis.HTMLElement = dom.window.HTMLElement;
55 globalThis.Event = dom.window.Event;
56 globalThis.CustomEvent = dom.window.CustomEvent;
57 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
58 globalThis.MouseEvent = dom.window.MouseEvent;
59 globalThis.localStorage = dom.window.localStorage;
60 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
61 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
62
63 const tab: TabMeta = {
64 id: "tab-live",
65 scope: "project",
66 workspaceRoot: "/repo",
67 workspaceName: "repo",
68 workspacePath: "/repo",
69 topicId: "topic-live",
70 topicTitle: "General",
71 sessionPath: "/repo/sessions/live.jsonl",
72 sessionRevision: 1,
73 sessionDigest: "digest-v1",
74 label: "model",
75 ready: true,
76 running: false,
77 mode: "normal",
78 toolApprovalMode: "ask",
79 tokenMode: "full",
80 active: true,
81 cwd: "/repo",
82 };
83 const meta: Meta = {
84 label: "model",
85 ready: true,
86 eventChannel: "agent:event",
87 sessionPath: tab.sessionPath,
88 sessionRevision: tab.sessionRevision,
89 sessionDigest: tab.sessionDigest,
90 cwd: "/repo",
91 workspaceRoot: "/repo",
92 workspaceName: "repo",
93 workspacePath: "/repo",
94 autoApproveTools: false,
95 bypass: false,
96 collaborationMode: "normal",
97 toolApprovalMode: "ask",
98 tokenMode: "full",
99 goal: "",
100 goalStatus: "stopped",
101 };
102 const historyGate = deferred<HistorySlice>();
103 let historyStarted = false;
104
105 const desktopStub = installDesktopHostStub(({
106 main: {
107 App: {
108 ListTabs: async () => [tab],
109 MetaForTab: async () => meta,
110 ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
111 EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
112 BalanceForTab: async () => ({ available: false, display: "" }),
113 JobsForTab: async () => [],
114 CheckpointsForTab: async () => [],
115 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
116 HistorySliceForTab: async () => {
117 historyStarted = true;
118 return historyGate.promise;
119 },
120 HistoryCheckpointTurnsForTab: async () => [],
121 ReplayPendingPrompts: async () => {},
122 } as Partial<AppBindings> as AppBindings,
123 },
124 }).main.App);
125
126 type Controller = ReturnType<typeof useController>;
127 let controller: Controller | undefined;
128 function Probe() {
129 controller = useController();
130 return null;
131 }
132
133 const rootElement = document.getElementById("root");
134 if (!rootElement) throw new Error("missing root");
135 const root = createRoot(rootElement);
136 await act(async () => {
137 root.render(<Probe />);
138 await flushPromises();
139 });
140 await waitFor("history request", () => historyStarted && (desktopStub.events.get("agent:event")?.size ?? 0) > 0);
141
142 await act(async () => {
143 desktopStub.emit("agent:event", { kind: "turn_started", tabId: tab.id });
144 desktopStub.emit("agent:event", { kind: "text", tabId: tab.id, messageId: "active", text: "active prefix" });
145 await flushPromises();
146 });
147 ok(controller?.state.transcriptConnection === "syncing", "frames wait for atomic baseline installation");
148
149 historyGate.resolve(historySliceFromMessages(
150 tab.id,
151 [{ role: "user", content: "stale durable history" }],
152 { cursor: "", turns: 12 },
153 { revision: 1, digest: "digest-v1" },
154 ));
155 await waitFor("hydration completion", () => controller?.state.hydrating === false);
156
157 ok(controller?.state.running ?? false, "late history keeps the live turn running");
158 ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "late history keeps the live assistant stream");
159 // The page read before the turn started is this session's history, not a
160 // competing version of it: it goes in front of the live turn instead of
161 // replacing it, so the turn never streams over a blank transcript.
162 ok(controller?.state.items[0]?.kind === "user", "late history lands in front of the live turn");
163 ok(controller?.state.items.at(-1)?.kind === "assistant", "late history leaves the live turn at the tail");
164
165 await act(async () => { root.unmount(); });
166 dom.window.close();
167
168 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
169 if (failed > 0) process.exit(1);
170
170 lines Plain Text