返回 DeepSeek-Reasonix
running-tab-history-hydration.test.tsx
根目录 / desktop / frontend / src / __tests__ / running-tab-history-hydration.test.tsx
1 // Run: tsx src/__tests__/running-tab-history-hydration.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 { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, 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 if (actual === expected) ok(true, label);
27 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
28 }
29
30 function flushPromises(): Promise<void> {
31 return new Promise((resolve) => setTimeout(resolve, 0));
32 }
33
34 async function waitFor(label: string, predicate: () => boolean) {
35 for (let attempt = 0; attempt < 30; attempt += 1) {
36 await act(async () => {
37 await flushPromises();
38 });
39 if (predicate()) return;
40 }
41 throw new Error(`timed out waiting for ${label}`);
42 }
43
44 async function settle() {
45 for (let attempt = 0; attempt < 10; attempt += 1) {
46 await act(async () => {
47 await flushPromises();
48 });
49 }
50 }
51
52 function tabMeta(id: string, overrides: Partial<TabMeta> = {}): TabMeta {
53 const workspaceRoot = `/repo/${id}`;
54 return {
55 id,
56 scope: "project",
57 workspaceRoot,
58 workspaceName: id,
59 workspacePath: workspaceRoot,
60 gitBranch: "main",
61 topicId: `topic-${id}`,
62 topicTitle: id,
63 sessionPath: `${workspaceRoot}/sessions/${id}.jsonl`,
64 label: `model-${id}`,
65 ready: true,
66 running: false,
67 mode: "normal",
68 toolApprovalMode: "ask",
69 tokenMode: "full",
70 active: false,
71 cwd: workspaceRoot,
72 ...overrides,
73 };
74 }
75
76 function metaFor(tab: TabMeta): Meta {
77 return {
78 label: tab.label,
79 ready: tab.ready,
80 startupErr: tab.startupErr,
81 eventChannel: "agent:event",
82 cwd: tab.cwd || tab.workspaceRoot,
83 workspaceRoot: tab.workspaceRoot,
84 workspaceName: tab.workspaceName,
85 workspacePath: tab.workspacePath,
86 sessionPath: tab.sessionPath,
87 sessionRevision: tab.sessionRevision,
88 sessionDigest: tab.sessionDigest,
89 gitBranch: tab.gitBranch,
90 autoApproveTools: false,
91 bypass: false,
92 collaborationMode: tab.collaborationMode ?? "normal",
93 toolApprovalMode: tab.toolApprovalMode ?? "ask",
94 tokenMode: tab.tokenMode ?? "full",
95 goal: "",
96 goalStatus: "stopped",
97 };
98 }
99
100 function userMessage(content: string): HistoryMessage {
101 return { role: "user", content };
102 }
103
104 console.log("\nrunning tab history hydration");
105
106 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
107 pretendToBeVisual: true,
108 url: "http://localhost/",
109 });
110 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
111 globalThis.window = dom.window as unknown as Window & typeof globalThis;
112 globalThis.document = dom.window.document;
113 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
114 globalThis.Node = dom.window.Node;
115 globalThis.HTMLElement = dom.window.HTMLElement;
116 globalThis.Event = dom.window.Event;
117 globalThis.CustomEvent = dom.window.CustomEvent;
118 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
119 globalThis.MouseEvent = dom.window.MouseEvent;
120 globalThis.localStorage = dom.window.localStorage;
121 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
122 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
123
124 const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 };
125 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
126 const balance: BalanceInfo = { available: false, display: "" };
127 const jobs: JobView[] = [];
128 const checkpoints: CheckpointMeta[] = [];
129
130 const tabA = tabMeta("tab-a", { active: true });
131 // The session the user clicks into: it has been running for a while, so the
132 // backend reports it running before hydration finishes.
133 const tabR = tabMeta("tab-r");
134 const tabsById = new Map([tabA, tabR].map((tab) => [tab.id, tab]));
135 const runningTabs = new Set<string>(["tab-r"]);
136 let backendActiveId = "tab-a";
137
138 function currentTabs(): TabMeta[] {
139 return Array.from(tabsById.values()).map((tab) => {
140 const running = runningTabs.has(tab.id);
141 return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
142 });
143 }
144
145 function historyFor(tabID: string): HistoryMessage[] {
146 if (tabID === "tab-r") return [userMessage("history R 1"), userMessage("history R 2")];
147 if (tabID === "tab-s") return [userMessage("history S")];
148 return [userMessage("cached A")];
149 }
150
151 const desktopStub = installDesktopHostStub(({
152 main: {
153 App: {
154 RegisterNavigationIntent: async () => {},
155 ListTabs: async () => currentTabs(),
156 MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
157 ContextUsageForTab: async () => context,
158 EffortForTab: async () => effort,
159 BalanceForTab: async () => balance,
160 JobsForTab: async () => jobs,
161 CheckpointsForTab: async () => checkpoints,
162 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
163 HistoryForTab: async (tabID: string) => historyFor(tabID),
164 HistoryPageForTab: async (tabID: string) => {
165 const messages = historyFor(tabID);
166 const turns = messages.filter((message) => message.role === "user").length;
167 return { messages, startTurn: 0, endTurn: turns, totalTurns: turns, hasOlder: false };
168 },
169 HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
170 historySliceFromMessages(tabID, historyFor(tabID), req),
171 HistoryCheckpointTurnsForTab: async () => [],
172 ReplayPendingPrompts: async () => {},
173 SetActiveTab: async (tabID: string) => {
174 backendActiveId = tabID;
175 },
176 CancelTab: async (tabID: string) => {
177 runningTabs.delete(tabID);
178 },
179 } as Partial<AppBindings> as AppBindings,
180 },
181 }).main.App);
182
183 type Controller = ReturnType<typeof useController>;
184 let controller: Controller | undefined;
185
186 function Probe() {
187 controller = useController();
188 return null;
189 }
190
191 const rootEl = document.getElementById("root");
192 if (!rootEl) throw new Error("missing root");
193 const root = createRoot(rootEl);
194
195 await act(async () => {
196 root.render(<Probe />);
197 await flushPromises();
198 });
199
200 await waitFor(
201 "initial active tab hydrated",
202 () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"),
203 );
204
205 // Clicking a session that the backend already reports as running: the tab has
206 // no cached transcript, so hydration is the only thing that can put the
207 // conversation on screen.
208 await act(async () => {
209 void controller?.switchTab("tab-r", { ...tabR, running: true, cancellable: true });
210 await flushPromises();
211 });
212 await settle();
213
214 eq(controller?.activeTabId, "tab-r", "switching to the running session activates its tab");
215 eq(controller?.state.running, true, "the running session keeps its live status");
216 ok(
217 controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 1") ?? false,
218 "an uncached running session still hydrates its persisted history",
219 );
220 ok(
221 controller?.state.items.some((item) => item.kind === "user" && item.text === "history R 2") ?? false,
222 "the whole history page lands, not just the newest turn",
223 );
224
225 // Second door: a session that is mid-stream when it is opened. Its live text
226 // makes the transcript look cached, which used to skip the history fetch
227 // outright and leave the streaming turn floating over an empty transcript.
228 tabsById.set("tab-s", tabMeta("tab-s"));
229 runningTabs.add("tab-s");
230 await act(async () => {
231 desktopStub.emit("agent:event", { kind: "turn_started", tabId: "tab-s" } as WireEvent);
232 desktopStub.emit("agent:event", { kind: "text", tabId: "tab-s", text: "streaming S" } as WireEvent);
233 await flushPromises();
234 });
235 await act(async () => {
236 void controller?.switchTab("tab-s", { ...tabsById.get("tab-s")!, running: true, cancellable: true });
237 await flushPromises();
238 });
239 await settle();
240
241 eq(controller?.activeTabId, "tab-s", "switching to the streaming session activates its tab");
242 ok(
243 controller?.state.items.some((item) => item.kind === "user" && item.text === "history S") ?? false,
244 "a mid-stream session still fetches and installs its history",
245 );
246 ok(controller?.state.live !== undefined, "installing history leaves the live stream alone");
247 eq(controller?.state.items[0]?.kind, "user", "the persisted page lands in front of the streaming turn");
248 eq(controller?.state.items[controller.state.items.length - 1]?.kind, "assistant", "the streaming turn stays at the tail");
249
250 await act(async () => {
251 root.unmount();
252 });
253 dom.window.close();
254
255 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
256 if (failed > 0) process.exit(1);
257
257 lines Plain Text