返回 DeepSeek-Reasonix
fork-child-memory.test.tsx
根目录 / desktop / frontend / src / __tests__ / fork-child-memory.test.tsx
1 // Run: tsx src/__tests__/fork-child-memory.test.tsx
2 // A turn that already produced a child session is never forked twice: the child
3 // is remembered per turn, so a repeat fork re-reports it instead of creating a
4 // second one, while a fork of another turn still creates its own.
5 import { JSDOM } from "jsdom";
6 import React, { act } from "react";
7 import { createRoot } from "react-dom/client";
8 import type { AppBindings } from "../lib/bridge";
9 import { createForkTargetsRefresh } from "../lib/forkTurn";
10 import { useController } from "../lib/useController";
11 import { historySliceFromMessages } from "./mockHistorySlice";
12 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 let passed = 0;
16 let failed = 0;
17 function ok(value: unknown, label: string) {
18 if (value) { passed += 1; process.stdout.write(` PASS ${label}\n`); }
19 else { failed += 1; process.stdout.write(` FAIL ${label}\n`); }
20 }
21
22 function flushPromises(ms = 0): Promise<void> {
23 return new Promise((resolve) => setTimeout(resolve, ms));
24 }
25
26 async function waitFor(label: string, predicate: () => boolean) {
27 for (let attempt = 0; attempt < 100; attempt += 1) {
28 await act(async () => { await flushPromises(10); });
29 if (predicate()) return;
30 }
31 throw new Error(`timed out waiting for ${label}`);
32 }
33
34 function tabMeta(id: string): TabMeta {
35 return {
36 id,
37 scope: "project",
38 workspaceRoot: "/repo",
39 workspaceName: "repo",
40 workspacePath: "/repo",
41 gitBranch: "main",
42 topicId: `topic-${id}`,
43 topicTitle: id,
44 sessionPath: `/repo/sessions/${id}.jsonl`,
45 label: "model",
46 ready: true,
47 running: false,
48 mode: "normal",
49 toolApprovalMode: "ask",
50 tokenMode: "full",
51 active: id === "tab-a",
52 cwd: "/repo",
53 };
54 }
55
56 function meta(tabId: string): Meta {
57 return {
58 label: "model",
59 ready: true,
60 eventChannel: "agent:event",
61 cwd: "/repo",
62 workspaceRoot: "/repo",
63 workspaceName: "repo",
64 workspacePath: "/repo",
65 sessionPath: `/repo/sessions/${tabId}.jsonl`,
66 gitBranch: "main",
67 autoApproveTools: false,
68 bypass: false,
69 collaborationMode: "normal",
70 toolApprovalMode: "ask",
71 tokenMode: "full",
72 goal: "",
73 goalStatus: "stopped",
74 };
75 }
76
77 console.log("\nfork child memory");
78
79 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
80 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
81 globalThis.window = dom.window as unknown as Window & typeof globalThis;
82 globalThis.document = dom.window.document;
83 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.dom ?? dom.window.navigator });
84 globalThis.Node = dom.window.Node;
85 globalThis.HTMLElement = dom.window.HTMLElement;
86 globalThis.Event = dom.window.Event;
87 globalThis.CustomEvent = dom.window.CustomEvent;
88 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
89 globalThis.MouseEvent = dom.window.MouseEvent;
90 globalThis.localStorage = dom.window.localStorage;
91 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
92 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
93
94 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
95 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
96 const balance: BalanceInfo = { available: false, display: "" };
97 const jobs: JobView[] = [];
98 const checkpoints: CheckpointMeta[] = [];
99 const history: HistoryMessage[] = [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }];
100 const created: string[] = [];
101 let attachFails = true;
102 let childSeq = 0;
103 const pending = new Map<string, { operationId: string; sessionId: string }>();
104 let forkTargetsRead: () => Promise<{ targets: never[]; verifiable: boolean }> = async () => ({ targets: [], verifiable: true });
105 const anchor = (turnId: string) => ({ sourceSessionId: "source-1", sessionGeneration: 1, turnId, boundarySequence: 9,
106 turnNumber: 1, status: "committed", available: true });
107
108 installDesktopHostStub(({
109 main: {
110 App: {
111 ListTabs: async () => [tabMeta("tab-a")],
112 MetaForTab: async (tabId: string) => meta(tabId),
113 ContextUsageForTab: async () => context,
114 EffortForTab: async () => effort,
115 BalanceForTab: async () => balance,
116 JobsForTab: async () => jobs,
117 CheckpointsForTab: async () => checkpoints,
118 ForkTargetsForTab: async () => forkTargetsRead(),
119 HistoryForTab: async () => history,
120 HistoryPageForTab: async (tabId: string) => ({
121 messages: history,
122 startTurn: 0,
123 endTurn: 1,
124 totalTurns: 1,
125 hasOlder: false,
126 }),
127 HistorySliceForTab: async (tabId: string, req: HistorySliceRequest) => historySliceFromMessages(tabId, history, req),
128 HistoryCheckpointTurnsForTab: async () => [],
129 ReplayPendingPrompts: async () => {},
130 CreateForkForTab: async (_tabId: string, target: ReturnType<typeof anchor>) => {
131 created.push(target.turnId);
132 let record = pending.get(target.turnId);
133 if (!record) {
134 childSeq += 1;
135 record = { operationId: `operation-${childSeq}`, sessionId: `child-${childSeq}` };
136 pending.set(target.turnId, record);
137 }
138 if (attachFails) return { ...record, opened: false, error: "conversation fork was created but could not be opened" };
139 return { ...record, tabId: "tab-a", opened: true };
140 },
141 AcknowledgeForkOperation: async (_tabId: string, operationId: string) => {
142 for (const [turnId, record] of pending) if (record.operationId === operationId) pending.delete(turnId);
143 },
144 } as Partial<AppBindings> as AppBindings,
145 },
146 }).main.App);
147
148 let resolveLateForkTargets: ((value: { targets: never[]; verifiable: boolean }) => void) | undefined;
149 forkTargetsRead = () => new Promise((resolve) => { resolveLateForkTargets = resolve; });
150 const lateForkActions: unknown[] = [];
151 const forkTargetReads = createForkTargetsRefresh((_tabId, action) => { lateForkActions.push(action); });
152 const lateForkRead = forkTargetReads.refresh("tab-a");
153 forkTargetReads.invalidate("tab-a");
154 resolveLateForkTargets?.({ targets: [], verifiable: true });
155 await lateForkRead;
156 ok(lateForkActions.length === 0, "a session rebind invalidates a late fork-target response");
157 forkTargetsRead = async () => ({ targets: [], verifiable: true });
158
159 type Controller = ReturnType<typeof useController>;
160 let controller: Controller | undefined;
161 function Probe() { controller = useController(); return null; }
162 const rootEl = document.getElementById("root");
163 if (!rootEl) throw new Error("missing root");
164 const root = createRoot(rootEl);
165
166 const notices = () => controller?.state.items.filter((item) => item.kind === "notice").map((item) => (item as { text?: string }).text ?? "") ?? [];
167
168 try {
169 await act(async () => { root.render(<Probe />); await flushPromises(); });
170 await waitFor("the source tab hydrates", () => controller?.activeTabId === "tab-a" && controller.state.hydrating === false);
171
172 await act(async () => { await controller!.forkTurnForTab("tab-a", anchor("turn-2")); await flushPromises(); });
173 ok(created.length === 1, "the first fork creates one child");
174 ok(notices().some((text) => text.includes("child-1")), "a child whose tab could not open is recovered by name");
175 ok(pending.get("turn-2")?.sessionId === "child-1", "the host retains the completed operation until adoption");
176
177 const beforeRepeat = notices().length;
178 await act(async () => { await controller!.forkTurnForTab("tab-a", anchor("turn-2")); await flushPromises(); });
179 ok(childSeq === 1, "retrying an unacknowledged operation never creates a second child");
180 ok(pending.get("turn-2")?.sessionId === "child-1", "the durable operation survives the repeated request");
181 ok(notices().slice(beforeRepeat).some((text) => text.includes("child-1")), "the repeat reports the same child instead of creating another");
182
183 await act(async () => { await controller!.forkTurnForTab("tab-a", anchor("turn-7")); await flushPromises(); });
184 ok(childSeq === 2, "a fork of another turn creates its own child");
185 ok(pending.get("turn-7")?.sessionId === "child-2", "each anchor keeps its own durable operation");
186
187 // An opened child is acknowledged, and its turn stays free for a later
188 // intentional fork.
189 attachFails = false;
190 await act(async () => { await controller!.forkTurnForTab("tab-a", anchor("turn-9")); await flushPromises(); });
191 ok(childSeq === 3, "an unrelated turn creates normally");
192 ok(pending.get("turn-9") === undefined, "adopting a child acknowledges and clears its operation");
193
194 await act(async () => { await controller!.forkTurnForTab("tab-a", anchor("turn-9")); await flushPromises(); });
195 ok(childSeq === 4, "a new click after acknowledgement creates a second intentional child");
196
197 } finally {
198 await act(async () => { root.unmount(); });
199 dom.window.close();
200 }
201
202 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
203 if (failed > 0) process.exit(1);
204
204 lines Plain Text