返回 DeepSeek-Reasonix
transcript-process-fold.test.ts
根目录 / desktop / frontend / src / __tests__ / transcript-process-fold.test.ts
1 // Run: tsx src/__tests__/transcript-process-fold.test.ts
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { renderToStaticMarkup } from "react-dom/server";
6 import { createServer, type ViteDevServer } from "vite";
7 import type { Item } from "../lib/useController";
8
9 let passed = 0;
10 let failed = 0;
11
12 function ok(value: unknown, label: string) {
13 if (value) {
14 process.stdout.write(` PASS ${label}\n`);
15 passed += 1;
16 } else {
17 process.stdout.write(` FAIL ${label}\n`);
18 failed += 1;
19 }
20 }
21
22 console.log("\ntranscript process fold");
23
24 let displayMode = "standard";
25 let processFoldPref = "auto";
26 Object.defineProperty(globalThis, "localStorage", {
27 configurable: true,
28 value: {
29 getItem(key: string) {
30 if (key === "reasonix-display-mode") return displayMode;
31 if (key === "reasonix-process-fold") return processFoldPref;
32 return null;
33 },
34 setItem() {},
35 removeItem() {},
36 clear() {},
37 key() { return null; },
38 length: 0,
39 },
40 });
41
42 let server: ViteDevServer | undefined;
43 try {
44 server = await createServer({
45 appType: "custom",
46 logLevel: "silent",
47 server: { middlewareMode: true },
48 });
49 const { Transcript } = await server.ssrLoadModule("/src/components/Transcript.tsx");
50 const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
51
52 function render(items: Item[], options: { mode?: "standard" | "compact"; running?: boolean; turnStartAt?: number; foldPref?: "auto" | "expanded" } = {}) {
53 displayMode = options.mode ?? "standard";
54 processFoldPref = options.foldPref ?? "auto";
55 const markup = renderToStaticMarkup(
56 React.createElement(
57 LocaleProvider,
58 null,
59 React.createElement(Transcript, {
60 items,
61 onPrompt: () => {},
62 questionNavigator: false,
63 running: options.running ?? false,
64 turnStartAt: options.turnStartAt,
65 }),
66 ),
67 );
68 return new JSDOM(markup).window.document;
69 }
70
71 const warningTurn: Item[] = [
72 { kind: "user", id: "u1", text: "inspect" },
73 { kind: "assistant", id: "a1", text: "", reasoning: "first thought", streaming: false },
74 { kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "done", durationMs: 400 },
75 { kind: "notice", id: "n1", level: "warn", text: "gateway warning" },
76 { kind: "assistant", id: "a2", text: "", reasoning: "second thought", streaming: false },
77 { kind: "tool", id: "t2", name: "bash", args: "{}", readOnly: false, status: "done", durationMs: 600 },
78 { kind: "assistant", id: "a3", text: "final answer", reasoning: "final thought", streaming: false, workDurationMs: 24_000 },
79 ];
80
81 for (const mode of ["standard", "compact"] as const) {
82 const doc = render(warningTurn, { mode });
83 const warning = doc.querySelector(".notice-line--warn");
84 const finalAnswer = Array.from(doc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("final answer"));
85 ok(doc.querySelectorAll(".turn-collapse").length === 1, `${mode} mode renders one work fold for the turn`);
86 ok(warning && !warning.closest(".turn-collapse"), `${mode} warning remains visible without splitting the fold`);
87 ok(finalAnswer && !finalAnswer.closest(".turn-collapse"), `${mode} final answer renders outside the work fold`);
88 }
89
90 // Assistant content is model output addressed to the user — every message
91 // with answer text stays outside the fold, not just the last one (#4092),
92 // and process that ran AFTER an answer opens a new fold so the transcript
93 // keeps the real timeline: plan → answer → tool work → answer.
94 const intermediateDoc = render([
95 { kind: "user", id: "u2", text: "continue" },
96 { kind: "assistant", id: "a4", text: "I will inspect the files", reasoning: "plan", streaming: false },
97 { kind: "tool", id: "t3", name: "read_file", args: "{}", readOnly: true, status: "done" },
98 { kind: "assistant", id: "a5", text: "all done", reasoning: "verify", streaming: false },
99 ]);
100 const intermediate = Array.from(intermediateDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("I will inspect the files"));
101 const final = Array.from(intermediateDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("all done"));
102 const folds = Array.from(intermediateDoc.querySelectorAll(".turn-collapse"));
103 ok(folds.length === 2, "work after an intermediate answer opens a second fold");
104 ok(intermediate && !intermediate.closest(".turn-collapse"), "intermediate assistant text renders outside the work fold");
105 ok(final && !final.closest(".turn-collapse"), "final assistant answer renders outside the work fold");
106 const inOrder = (a: Element | null | undefined, b: Element | null | undefined) => Boolean(
107 a && b && (a.compareDocumentPosition(b) & intermediateDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING),
108 );
109 ok(
110 inOrder(folds[0], intermediate) && inOrder(intermediate, folds[1]) && inOrder(folds[1], final),
111 "folds and answers keep the turn's real timeline",
112 );
113 ok(folds[0]?.textContent?.includes("plan") && !folds[0]?.textContent?.includes("verify"), "first fold holds only the work before the first answer");
114 ok(folds[1]?.textContent?.includes("verify") && folds[1]?.textContent?.includes("read_file"), "second fold holds the work after the first answer");
115 ok(folds[0]?.querySelector(".turn-collapse__label")?.textContent === "1 thoughts", "earlier folds carry a counts-only label");
116 ok(folds[1]?.querySelector(".turn-collapse__label")?.textContent?.startsWith("Worked"), "the closing fold carries the turn's work label");
117
118 // A mid-turn steer is the user's own message (#6238): it renders on the
119 // user side, outside the fold, at its real position — work that followed
120 // the steer folds after it, not ahead of it. Ordinary info notices keep
121 // folding.
122 const steerDoc = render([
123 { kind: "user", id: "u-steer", text: "start" },
124 { kind: "assistant", id: "a-steer-1", text: "", reasoning: "thinking", streaming: false },
125 { kind: "notice", id: "s1", level: "info", text: "↪ use plan B instead" },
126 { kind: "notice", id: "i1", level: "info", text: "plain info notice" },
127 { kind: "assistant", id: "a-steer-2", text: "done via plan B", reasoning: "", streaming: false },
128 ]);
129 const steer = steerDoc.querySelector(".steer-line");
130 ok(steer && !steer.closest(".turn-collapse"), "steer notice renders outside the work fold");
131 ok(steer?.textContent?.includes("use plan B instead"), "steer bubble carries the user's guidance text");
132 const plainInfo = Array.from(steerDoc.querySelectorAll(".notice-line")).find((node) => node.textContent?.includes("plain info notice"));
133 ok(plainInfo && plainInfo.closest(".turn-collapse"), "plain info notices keep folding");
134 const steerFolds = Array.from(steerDoc.querySelectorAll(".turn-collapse"));
135 const steerInOrder = (a: Element | null | undefined, b: Element | null | undefined) => Boolean(
136 a && b && (a.compareDocumentPosition(b) & steerDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING),
137 );
138 ok(
139 steerFolds.length === 2 && steerInOrder(steerFolds[0], steer) && steerInOrder(steer, steerFolds[1]),
140 "work after the steer folds after it, keeping the steer's position",
141 );
142
143 const errorDoc = render([
144 { kind: "user", id: "u-error", text: "finish" },
145 { kind: "assistant", id: "a-error", text: "partial result", reasoning: "worked", streaming: false },
146 { kind: "notice", id: "n-error", level: "warn", text: "turn stopped" },
147 ]);
148 const errorAnswer = Array.from(errorDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("partial result"));
149 const trailingWarning = errorDoc.querySelector(".notice-line--warn");
150 const followsAnswer = Boolean(
151 errorAnswer &&
152 trailingWarning &&
153 (errorAnswer.compareDocumentPosition(trailingWarning) & errorDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING),
154 );
155 ok(followsAnswer, "warnings outside the fold preserve their order relative to the final answer");
156
157 // A delivery pause is a decision point addressed to the user: the status
158 // card and its continue action must stay visible when the turn's process
159 // fold closes, unlike plain info notices.
160 const deliveryDoc = render([
161 { kind: "user", id: "u-delivery", text: "ship it" },
162 { kind: "assistant", id: "a-delivery", text: "", reasoning: "attempted delivery", streaming: false },
163 {
164 kind: "notice",
165 id: "n-delivery",
166 level: "info",
167 variant: "delivery",
168 title: "Delivery checks are not complete",
169 text: "The response was generated, but verification and review still need to be completed.",
170 detail: "final-answer readiness failed 3 times: missing verification",
171 action: "continue_delivery",
172 },
173 ]);
174 const deliveryCard = deliveryDoc.querySelector(".notice-line--delivery");
175 ok(deliveryCard && !deliveryCard.closest(".turn-collapse"), "delivery status card renders outside the work fold");
176 ok(Boolean(deliveryCard?.querySelector("button")), "delivery status card keeps its continue action reachable");
177
178 const originalNow = Date.now;
179 Date.now = () => 25_000;
180 try {
181 const runningDoc = render([
182 { kind: "user", id: "u3", text: "run" },
183 { kind: "assistant", id: "a6", text: "", reasoning: "working", streaming: false, workDurationMs: 5_000 },
184 ], { running: true, turnStartAt: 1_000 });
185 ok(runningDoc.querySelector(".turn-collapse__label")?.textContent === "Working 24s · 1 thoughts", "active turn stays Working and counts its process items");
186 } finally {
187 Date.now = originalNow;
188 }
189
190 const completedDoc = render([
191 { kind: "user", id: "u4", text: "finish" },
192 { kind: "assistant", id: "a7", text: "done", reasoning: "worked", streaming: false, workDurationMs: 24_000 },
193 ]);
194 ok(completedDoc.querySelector(".turn-collapse__label")?.textContent === "Worked 24s · 1 thoughts", "completed turn keeps the persisted wall-clock duration and counts");
195
196 const countsDoc = render(warningTurn);
197 const countsLabel = countsDoc.querySelector(".turn-collapse__label")?.textContent ?? "";
198 ok(countsLabel.includes("2 tools") && countsLabel.includes("3 thoughts"), "fold label surfaces tool and thought counts");
199
200 // A turn whose fold is the only content (e.g. cancelled before any answer)
201 // must not collapse into a bare label — nothing would remain visible.
202 const aloneDoc = render([
203 { kind: "user", id: "u5", text: "cancelled" },
204 { kind: "assistant", id: "a8", text: "", reasoning: "got cut off", streaming: false, workDurationMs: 3_000 },
205 ]);
206 ok(aloneDoc.querySelector(".turn-collapse--open"), "fold with nothing outside stays expanded");
207 const answeredDoc = render([
208 { kind: "user", id: "u6", text: "ask" },
209 { kind: "assistant", id: "a9", text: "answered", reasoning: "quick", streaming: false, workDurationMs: 3_000 },
210 ]);
211 ok(!answeredDoc.querySelector(".turn-collapse--open"), "fold with an answer outside starts collapsed");
212
213 // settings.processFold = expanded keeps completed folds open (#4233, #2278).
214 const expandedDoc = render([
215 { kind: "user", id: "u7", text: "ask" },
216 { kind: "assistant", id: "a10", text: "answered", reasoning: "quick", streaming: false, workDurationMs: 3_000 },
217 ], { foldPref: "expanded" });
218 ok(expandedDoc.querySelector(".turn-collapse--open"), "keep-expanded preference leaves the fold open");
219
220 // Each reasoning segment inside the fold is independently collapsible (#6340).
221 const segmentDoc = render(warningTurn);
222 const segmentHeads = segmentDoc.querySelectorAll("button.turn-collapse__reasoning-head");
223 ok(segmentHeads.length === 3, "every reasoning segment gets its own toggle");
224 ok(Array.from(segmentHeads).every((head) => head.getAttribute("aria-expanded") === "true"), "reasoning segments default to expanded");
225 } finally {
226 await server?.close();
227 delete (globalThis as { localStorage?: Storage }).localStorage;
228 }
229
230 console.log(`\n${passed} passed, ${failed} failed`);
231 if (failed > 0) process.exit(1);
232
232 lines TYPESCRIPT