返回 DeepSeek-Reasonix
chat-natural-flow.test.tsx
根目录 / desktop / frontend / src / __tests__ / chat-natural-flow.test.tsx
1 import assert from "node:assert/strict";
2 import { act, createElement, useState, type Dispatch, type SetStateAction } from "react";
3 import { createRoot } from "react-dom/client";
4 import { createTranscriptHarness } from "./transcript-dom-harness";
5 import { reconcileMountedOrder, revealEarlierMountedOrder } from "../lib/chatMountedOrder";
6 import type { Item } from "../lib/useController";
7 import type { ChatActions } from "../components/ChatNodes";
8
9 for (const count of [1, 5, 23, 24, 25, 48, 49]) {
10 const resident = Array.from({ length: 60 }, (_, index) => `resident-${index}`);
11 const prefix = Array.from({ length: count }, (_, index) => `history-${index}`);
12 const full = [...prefix, ...resident];
13 let mounted = reconcileMountedOrder(resident, full);
14 assert.equal(mounted, resident, `${count}-node prepend keeps the resident order object before the first frame`);
15 let frames = 0;
16 while (mounted.length < full.length) {
17 const previous = mounted;
18 mounted = revealEarlierMountedOrder(mounted, full);
19 frames += 1;
20 assert.deepEqual(mounted.slice(-resident.length), resident, `${count}-node prepend keeps the resident suffix after frame ${frames}`);
21 assert.ok(mounted.length > previous.length && mounted.length - previous.length <= 24, `${count}-node prepend reveals a bounded frame`);
22 }
23 assert.deepEqual(mounted, full, `${count}-node prepend converges to the source order`);
24 assert.equal(frames, Math.ceil(count / 24), `${count}-node prepend uses the expected frame count`);
25 }
26
27 const harness = await createTranscriptHarness({ deterministic: true });
28 const items: Item[] = [
29 { kind: "user", id: "u1", text: "hello", checkpointTurn: 1 },
30 { kind: "tool", id: "t1", name: "read_file", args: "{}", output: "result", readOnly: true, status: "done" },
31 { kind: "assistant", id: "a1", text: "answer", reasoning: "thought", streaming: false },
32 ];
33 try {
34 await harness.loadModule("/src/components/ChatToolBody.tsx");
35 await harness.render(items);
36 await harness.settle();
37 assert.ok(harness.container.querySelector(".chat-column"));
38 assert.ok(harness.container.querySelector('[id^="reasonix-chat-transcript-"] .chat-column'), "native upgrade evidence scopes history to the transcript");
39 assert.equal(harness.container.querySelectorAll(".transcript__window-item").length, 0);
40 assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "completed process unmounts heavy rows");
41 const disclosure = harness.container.querySelector<HTMLButtonElement>(".chat-process");
42 await act(async () => disclosure!.click());
43 const tool = harness.container.querySelector<HTMLElement>(".chat-tool [data-disclosure-row]");
44 assert.ok(tool);
45 await act(async () => tool.click());
46 await harness.settle();
47 assert.ok(harness.container.querySelector(".dsh-ToolRow-ioCard"), "tool opens an inline preview");
48 await act(async () => harness.container.querySelector<HTMLButtonElement>(".dsh-ToolRow-inspectButton")!.click());
49 assert.ok(harness.container.querySelector('[role="dialog"]'));
50 for (let i = 0; i < 60; i++) {
51 await harness.render(items.map(item => item.kind === "assistant" ? { ...item, text: `answer ${i}`, streaming: true } : item), { running: true });
52 await act(async () => { harness.resizeNotifications.forEach(notify => notify()); harness.clock.advance(16); });
53 }
54 assert.ok(harness.container.querySelector(".chat-column"), "60 geometry changes do not trip React nested update fuse");
55 await harness.render(items, { geometrySessionKey: "other" });
56 assert.equal(harness.container.querySelector('[role="dialog"]'), null, "session replacement closes details");
57
58 const restored: Item[] = [];
59 for (let turn = 1; turn <= 12; turn += 1) {
60 restored.push(
61 { kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn },
62 { kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false },
63 );
64 }
65 await harness.render(restored, { geometrySessionKey: "history-identity" });
66 await harness.settle();
67 const existing = new Map(Array.from(harness.container.querySelectorAll<HTMLElement>("[data-chat-anchor-key]"))
68 .map(node => [node.dataset.chatAnchorKey!, node]));
69 assert.equal(existing.size, 60, "fixture spans multiple legacy 24-node chunks");
70 const selectedParagraph = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a6"] .md p')!;
71 const selectedText = selectedParagraph.firstChild!;
72 const range = harness.dom.window.document.createRange();
73 range.selectNodeContents(selectedText);
74 const selection = harness.dom.window.getSelection()!;
75 selection.removeAllRanges();
76 selection.addRange(range);
77 const selectedBefore = selection.toString();
78 await harness.render([
79 { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 },
80 { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false },
81 ...restored,
82 ], { geometrySessionKey: "history-identity" });
83 await harness.settle();
84 for (const [key, node] of existing) {
85 assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${key}"]`), node, `history prepend preserves ${key} DOM identity`);
86 }
87 assert.equal(selection.toString(), selectedBefore, "history prepend preserves the native text selection");
88
89 const deepHistory: Item[] = [];
90 for (let turn = 1; turn <= 15; turn += 1) {
91 deepHistory.push(
92 { kind: "user", id: `old-u${turn}`, text: `old prompt ${turn}`, checkpointTurn: -turn },
93 { kind: "assistant", id: `old-a${turn}`, text: `old answer ${turn}`, reasoning: "", streaming: false },
94 );
95 }
96 await harness.render([
97 ...deepHistory,
98 { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 },
99 { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false },
100 ...restored,
101 ], { geometrySessionKey: "history-identity" });
102 assert.equal(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), null, "deep history is still mounting after two bounded frames");
103 assert.equal(harness.container.querySelector('[data-nav-turn="old-u1"]'), null, "navigation omits history without a mounted target");
104 await harness.settle();
105 assert.ok(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), "deep history eventually mounts");
106 assert.ok(harness.container.querySelector('[data-nav-turn="old-u1"]'), "navigation publishes the target after its DOM commit");
107 let newerLoads = 0;
108 await harness.render(restored, {
109 geometrySessionKey: "history-identity",
110 hasNewerHistory: true,
111 historyStartTurn: 4,
112 historyEndTurn: 12,
113 totalTurns: 20,
114 onLoadNewerHistory: async () => { newerLoads += 1; return "loaded"; },
115 });
116 const newer = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!;
117 await act(async () => newer.click());
118 assert.equal(newerLoads, 0, "a native transcript selection protects its resident page from reclaim");
119 assert.ok(harness.container.querySelector(".chat-history-selection"), "selection protection explains why paging paused");
120 await act(async () => {
121 selection.removeAllRanges();
122 harness.dom.window.document.dispatchEvent(new harness.dom.window.Event("selectionchange"));
123 });
124 await act(async () => newer.click());
125 assert.equal(newerLoads, 1, "newer paging resumes after the selection is cleared");
126 assert.match(harness.container.querySelector(".chat-history-window")?.textContent ?? "", /5.*12.*20/, "the bounded window reports its visible turn range");
127
128 // Reading summary is presentation work, so this measures actual tool renders
129 // without a wall-clock threshold or counting the source's projection work.
130 // Mount the list once, then publish through its source: rerendering the test
131 // harness's LocaleProvider would also invalidate every locale consumer.
132 await harness.unmount();
133 const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts");
134 const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx");
135 const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts");
136 const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts");
137 const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts");
138 const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx");
139 const source = new ChatSource("render-isolation");
140 const root = createRoot(harness.container);
141 const loader = new ChatContentLoader();
142 const scroll = new ChatScrollController("render-isolation");
143 const mounts = new ChatMountedOrder();
144 let changeActions!: Dispatch<SetStateAction<ChatActions>>;
145 function Surface() {
146 const [actions, setActions] = useState<ChatActions>({ openDetails: () => {}, recover: () => {},
147 fork: { targetFor: () => undefined, loaded: true, verifiable: true, blocked: null, create: () => {} } });
148 changeActions = setActions;
149 return createElement(ChatNodeList, { source, mounts, loader, scroll, actions });
150 }
151 const publish = async (items: Item[], running = true) => {
152 await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false }));
153 await harness.settle();
154 };
155 try {
156 const renders = new Map<string, number>();
157 const toolItem = (id: string, status: "done" | "running" | "error" = "done"): Item => ({
158 kind: "tool", id, name: "custom_tool", args: "{}", status,
159 get summary() { renders.set(id, (renders.get(id) ?? 0) + 1); return `summary ${id}`; },
160 });
161 const siblings = Array.from({ length: 32 }, (_, index) => toolItem(`isolated-${index}`));
162 const question: Item = { kind: "user", id: "isolated-user", text: "run tools" };
163 await publish([question, ...siblings]);
164 await act(async () => root.render(createElement(LocaleProvider, null, createElement(Surface))));
165 await harness.settle();
166 assert.equal(renders.size, siblings.length, "the fixture renders every existing tool");
167 renders.clear();
168 const appended = toolItem("isolated-new", "running");
169 await publish([question, ...siblings, appended]);
170 assert.deepEqual([...renders.keys()], [appended.id], "adding a tool renders no unchanged sibling despite process membership/count changes");
171 renders.clear();
172 const failed = toolItem(appended.id, "error");
173 await publish([question, ...siblings, failed]);
174 assert.deepEqual([...renders.keys()], [failed.id], "a failure-count update renders only the changed tool");
175 assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${failed.id}"] .chat-tool`)?.getAttribute("data-state"), "error");
176 const finalAnswer: Item = { kind: "assistant", id: "isolated-answer", text: "finished", reasoning: "", streaming: false };
177 await publish([question, ...siblings, failed, finalAnswer], false);
178 assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "settlement still hides every process member");
179 assert.ok(harness.container.querySelector('[data-chat-anchor-key="isolated-answer"][data-turn-process-answer]'), "the final answer still gets collapsed-process spacing");
180 await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-process")!.click());
181 assert.equal(harness.container.querySelectorAll(".chat-tool").length, siblings.length + 1, "expansion still reveals every process member");
182 assert.equal(harness.container.querySelector('[data-turn-process-answer]'), null, "expansion clears collapsed-answer spacing");
183 renders.clear();
184 await act(async () => changeActions(old => ({ ...old, fork: { ...old.fork!, blocked: "creating" } })));
185 assert.equal(renders.size, 0, "fork state changes do not rerender unchanged tools");
186 assert.ok(harness.container.querySelector('[aria-label*="Creating the branch"]'), "fork controls still receive current state");
187
188 await harness.loadModule("/src/components/PresentedFiles.tsx");
189 const writes: Item[] = [
190 { kind: "tool", id: "write-a", name: "write_file", args: '{"path":"a.ts","content":"a"}', status: "done" },
191 { kind: "tool", id: "present-a", name: "present", args: "{}", status: "done", presentedFiles: [{ path: "a.ts", description: "A" }] },
192 ];
193 const liveAnswer: Item = { ...finalAnswer, streaming: true, reasoning: "unchanged", reasoningComplete: true };
194 await publish([question, ...siblings, ...writes, liveAnswer]);
195 await harness.waitFor(() => Boolean(harness.container.querySelector(".turn-files") && harness.container.querySelector(".presented-files")), "file cards");
196 const tail = source.getNodeSnapshot(`${question.id}:tail`);
197 assert.ok(tail?.kind === "tail");
198 let modifiedRenders = 0, presentedRenders = 0;
199 Object.defineProperty(tail.modifiedFiles, "map", { configurable: true, value: function (...args: unknown[]) {
200 modifiedRenders++; return Array.prototype.map.apply(this, args as never);
201 } });
202 Object.defineProperty(tail.presentedFiles, "slice", { configurable: true, value: function (...args: unknown[]) {
203 presentedRenders++; return Array.prototype.slice.apply(this, args as never);
204 } });
205 for (let index = 0; index < 20; index++) await act(async () => source.updateLive({
206 id: liveAnswer.id, text: `answer ${index}`, reasoning: "unchanged", reasoningComplete: true,
207 }));
208 assert.equal(modifiedRenders, 0, "text-only stream updates do not recompute modified-file cards");
209 assert.equal(presentedRenders, 0, "text-only stream updates do not rerender presented-file cards");
210 await publish([question, ...siblings, ...writes,
211 { kind: "tool", id: "write-b", name: "write_file", args: '{"path":"b.ts","content":"b"}', status: "done" },
212 { kind: "tool", id: "present-b", name: "present", args: "{}", status: "done", presentedFiles: [{ path: "b.ts", description: "B" }] }, liveAnswer]);
213 assert.match(harness.container.querySelector(".turn-files")?.textContent ?? "", /b\.ts/, "new modified files remain visible");
214 assert.match(harness.container.querySelector(".presented-files")?.textContent ?? "", /b\.ts/, "new presented files remain visible");
215
216 // Memoization must preserve positive updates as well as skip stale work.
217 const declarations: Item = { kind: "tool", id: "present-many", name: "present", args: "{}", status: "done",
218 presentedFiles: Array.from({ length: 5 }, (_, index) => ({ path: `file-${index}.ts`, description: `description-${index}` })) };
219 await publish([question, declarations, liveAnswer]);
220 const cards = () => harness.container.querySelectorAll(".presented-file");
221 assert.equal(cards().length, 4);
222 await act(async () => harness.container.querySelector<HTMLButtonElement>(".presented-files .presented-files__toggle")!.click());
223 assert.equal(cards().length, 5, "memoized file cards still expand from their own state");
224 await publish([question, { ...declarations, presentedFiles: declarations.presentedFiles!.map((file, index) => index ? file : { ...file, description: "updated description" }) }, liveAnswer]);
225 assert.equal(cards().length, 5, "file metadata updates preserve disclosure state");
226 assert.match(harness.container.querySelector(".presented-files")?.textContent ?? "", /updated description/, "same-path description updates are not cached away");
227 await act(async () => harness.container.querySelector<HTMLButtonElement>(".presented-files .presented-files__toggle")!.click());
228 assert.equal(cards().length, 4);
229
230 const boundary = { sourceSessionId: "source", sessionGeneration: 1, turnId: "turn", boundarySequence: 10,
231 turnNumber: 1, status: "committed", messageId: liveAnswer.id, available: true };
232 const forkCalls: string[] = [];
233 await act(async () => changeActions(old => ({ ...old, fork: { targetFor: () => boundary, loaded: true,
234 verifiable: true, blocked: null, create: () => { forkCalls.push("old"); } } })));
235 await act(async () => changeActions(old => ({ ...old, fork: { ...old.fork!, create: () => { forkCalls.push("latest"); } } })));
236 await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-actions button.chat-action-icon:not(.copybtn)")!.click());
237 assert.deepEqual(forkCalls, ["latest"], "fork context replacement uses the latest callback");
238 await act(async () => changeActions(old => ({ ...old, fork: undefined })));
239 assert.equal(harness.container.querySelector(".chat-actions button.chat-action-icon:not(.copybtn)"), null, "removing fork support removes the old control");
240
241 const copied: string[] = [];
242 Object.defineProperty(harness.dom.window.navigator, "clipboard", { configurable: true,
243 value: { writeText: async (text: string) => { copied.push(text); } } });
244 const longReasoning = "complete reasoning ".repeat(600);
245 await publish([question, { ...liveAnswer, reasoning: longReasoning }]);
246 await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
247 await act(async () => source.updateLive({ id: liveAnswer.id, text: "new answer while reading reasoning", reasoning: longReasoning, reasoningComplete: true }));
248 await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-reasoning .copybtn")!.click());
249 assert.equal(copied.at(-1), longReasoning, "isolated reasoning still copies full content beyond its preview");
250 await act(async () => source.updateLive({ id: liveAnswer.id, text: "latest answer", reasoning: "replacement reasoning", reasoningComplete: true }));
251 await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-reasoning .copybtn")!.click());
252 assert.equal(copied.at(-1), "replacement reasoning", "changed reasoning replaces the copy callback content");
253 await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-actions .copybtn")!.click());
254 assert.equal(copied.at(-1), "latest answer", "reasoning projection never replaces the canonical answer used for copying");
255 } finally {
256 await act(async () => root.unmount());
257 source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose();
258 }
259 console.log("chat natural flow: process disclosure, details, stable history identity, mounted navigation and session isolation passed");
260 } finally { await harness.unmount(); await harness.close(); }
261
261 lines Plain Text