返回 DeepSeek-Reasonix
chat-view-source.test.ts
根目录 / desktop / frontend / src / __tests__ / chat-view-source.test.ts
1 import assert from "node:assert/strict";
2 import { ChatSource, type ChatInput } from "../lib/chatViewSource";
3 import type { Item } from "../lib/useController";
4
5 const items: Item[] = [
6 { kind: "user", id: "u1", text: "first", checkpointTurn: 1 },
7 { kind: "assistant", id: "a1", text: "settled", reasoning: "reason", streaming: false },
8 { kind: "user", id: "u2", text: "second", checkpointTurn: 2 },
9 { kind: "assistant", id: "a2", text: "", reasoning: "", streaming: true },
10 ];
11 const input: ChatInput = { items, running: true, hydrating: false, hasOlder: true, loadingOlder: false };
12 const source = new ChatSource("test");
13 source.update(input);
14 await Promise.resolve();
15 const order = source.getOrderSnapshot();
16 const oldAnswer = source.getNodeSnapshot("a1");
17 let lists = 0, historical = 0, active = 0;
18 source.subscribeOrder(() => lists++);
19 source.subscribeNode("a1", () => historical++);
20 source.subscribeNode("a2", () => active++);
21 for (let i = 0; i < 120; i++) source.updateLive({ id: "a2", text: `answer ${i}`, reasoning: `thought ${i}`, reasoningComplete: true });
22 assert.equal(active, 120);
23 assert.equal(lists, 0);
24 assert.equal(historical, 0);
25 assert.equal(source.getOrderSnapshot(), order);
26 assert.equal(source.getNodeSnapshot("a1"), oldAnswer);
27
28 const settled = items.map(item => item.id === "a2" ? { ...item, text: "answer 119", reasoning: "thought 119", streaming: false } as Item : item);
29 source.update({ ...input, items: settled, running: false });
30 await Promise.resolve();
31 assert.equal(source.getOrderSnapshot(), order, "settlement preserves every node host key");
32 assert.equal(source.getNodeSnapshot("u2:process")?.kind, "process");
33 const process = source.getNodeSnapshot("u2:process");
34 assert.ok(process?.kind === "process" && process.collapsed);
35 source.toggleProcess("u2");
36 source.update({ ...input, items: [...settled], running: false });
37 await Promise.resolve();
38 const expanded = source.getNodeSnapshot("u2:process");
39 assert.ok(expanded?.kind === "process" && !expanded.collapsed, "manual open survives publication");
40 const earlier: Item[] = [{ kind: "user", id: "u0", text: "older", checkpointTurn: 0 }, { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false }];
41 const beforePrepend = source.getNodeSnapshot("a1");
42 source.update({ ...input, items: [...earlier, ...settled], running: false });
43 await Promise.resolve();
44 assert.equal(source.getNodeSnapshot("a1"), beforePrepend);
45 assert.deepEqual(source.getOrderSnapshot().slice(-order.length), order);
46
47 const warning: Item = { kind: "notice", id: "warning", level: "warn", text: "interrupted" };
48 source.update({ ...input, items: [...settled, warning], running: false });
49 await Promise.resolve();
50 const failed = source.getNodeSnapshot("u2:process");
51 assert.ok(failed?.kind === "process" && failed.foldable && !failed.collapsed, "an interrupted turn stays open but can be folded");
52 assert.ok(source.getOrderSnapshot().includes("warning"));
53 source.update({ ...input, items: [settled[1]], running: false });
54 await Promise.resolve();
55 const incomplete = source.getNodeSnapshot("history-head:process");
56 assert.ok(incomplete?.kind === "process" && incomplete.foldable, "a group without its user message still has a fold seat");
57 source.update(input);
58 source.dispose();
59 const notified = active;
60 await Promise.resolve();
61 source.updateLive({ id: "a2", text: "late", reasoning: "", reasoningComplete: true });
62 assert.equal(active, notified);
63 assert.deepEqual(source.getOrderSnapshot(), []);
64 console.log("chat view: stable streaming, settlement, prepend, fold, incomplete history and disposal passed");
65
66 const reasoningSource = new ChatSource("reasoning-isolation");
67 let reasoningAnswer: Extract<Item, { kind: "assistant" }> = {
68 kind: "assistant", id: "reasoning-answer", text: "answer", reasoning: "thought", streaming: true, reasoningComplete: false,
69 };
70 const publishReasoning = () => reasoningSource.update({ ...input, items: [items[0], reasoningAnswer] });
71 publishReasoning();
72 await Promise.resolve();
73 let reasoningChanges = 0;
74 reasoningSource.subscribeNode("reasoning-answer:reasoning", () => reasoningChanges++);
75 const unchangedReasoning = reasoningSource.getNodeSnapshot("reasoning-answer:reasoning");
76 for (let index = 0; index < 20; index++) reasoningSource.updateLive({
77 id: reasoningAnswer.id, text: `answer ${index}`, reasoning: "thought", reasoningComplete: false,
78 });
79 assert.equal(reasoningChanges, 0, "text-only stream publications do not invalidate reasoning");
80 assert.equal(reasoningSource.getNodeSnapshot("reasoning-answer:reasoning"), unchangedReasoning);
81 reasoningAnswer = { ...reasoningAnswer, text: "projected answer" };
82 publishReasoning();
83 await Promise.resolve();
84 assert.equal(reasoningChanges, 0, "controller/history projection shares the same reasoning isolation");
85 reasoningSource.updateLive({ id: reasoningAnswer.id, text: "projected answer", reasoning: "thought", reasoningComplete: true });
86 assert.equal(reasoningChanges, 1, "completion alone still publishes even when reasoning text is unchanged");
87 reasoningSource.updateLive({ id: reasoningAnswer.id, text: "projected answer", reasoning: "new thought", reasoningComplete: true });
88 assert.equal(reasoningChanges, 2, "reasoning content changes still publish");
89 reasoningAnswer = { ...reasoningAnswer, reasoning: "new thought", reasoningComplete: true, streaming: false, reasoningDurationMs: 1234 };
90 reasoningSource.update({ ...input, running: false, items: [items[0], reasoningAnswer] });
91 await Promise.resolve();
92 assert.equal(reasoningChanges, 3, "settlement and duration still publish");
93 const settledReasoning = reasoningSource.getNodeSnapshot("reasoning-answer:reasoning");
94 assert.ok(settledReasoning?.kind === "reasoning");
95 assert.equal(settledReasoning.item.streaming, false);
96 assert.equal(settledReasoning.item.reasoningDurationMs, 1234);
97 reasoningSource.dispose();
98
99 const recovered = new ChatSource("recovered-weather");
100 const weather: Item[] = [
101 { kind: "user", id: "weather-user", text: "weather", checkpointTurn: 4 },
102 { kind: "tool", id: "failed-fetch", name: "web_fetch", args: "{}", readOnly: true, status: "error", error: "DNS refused" },
103 { kind: "assistant", id: "retry-message", text: "Trying another source", reasoning: "Need current data", streaming: false },
104 { kind: "notice", id: "permission", level: "info", title: "Permission saved", text: "permission saved to /tmp/reasonix.toml: long command" },
105 { kind: "tool", id: "successful-fetch", name: "bash", args: "{}", readOnly: true, status: "done", output: "25 degrees" },
106 { kind: "assistant", id: "weather-answer", text: "Sunny, 25 degrees", reasoning: "Sources agree", streaming: false },
107 ];
108 recovered.update({ ...input, items: weather, running: false });
109 await Promise.resolve();
110 const compact = recovered.getNodeSnapshot("weather-user:process");
111 assert.ok(compact?.kind === "process" && compact.collapsed, "recovered call errors do not prevent completed-turn folding");
112 assert.equal(compact.toolCallCount, 2);
113 assert.equal(compact.messageCount, 1);
114 assert.equal(compact.failureCount, 1, "failed call count remains visible in the collapsed summary");
115 assert.ok(compact.members.includes("permission"), "system receipts are inside the process range");
116 assert.ok(!compact.members.includes("weather-answer"), "final answer remains outside the process range");
117 recovered.toggleProcess("weather-user");
118 recovered.dispose();
119 const restored = new ChatSource("recovered-weather");
120 restored.update({ ...input, items: weather, running: false });
121 assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
122 restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
123 const interrupted = restored.getNodeSnapshot("weather-user:process") as typeof compact;
124 assert.ok(interrupted.foldable && !interrupted.collapsed, "terminal interruption stays open and keeps the manual choice");
125 restored.dispose();
126
127 const deliverables = new ChatSource("deliverables");
128 const deliverableItems: Item[] = [
129 { kind: "user", id: "deliverable-user", text: "build a game", checkpointTurn: 5 },
130 { kind: "tool", id: "write-game", name: "write_file", args: '{"path":"game.html","content":"game"}', readOnly: false, status: "done", output: "wrote game.html" },
131 { kind: "tool", id: "write-notes", name: "write_file", args: '{"path":"notes.md","content":"notes"}', readOnly: false, status: "done", output: "wrote notes.md" },
132 { kind: "tool", id: "failed-write", name: "write_file", args: '{"path":"missing.md"}', readOnly: false, status: "error", error: "denied" },
133 { kind: "tool", id: "present-old", name: "present", args: '{"files":[{"path":"game.html"}]}', readOnly: true, status: "done", output: "Presented game.html", presentedFiles: [{ path: "game.html", description: "Old description" }] },
134 { kind: "tool", id: "present-doc", name: "present", args: '{"files":[{"path":"README.md"}]}', readOnly: true, status: "done", output: "Presented README.md", presentedFiles: [{ path: "README.md", description: "Instructions" }] },
135 { kind: "tool", id: "present-new", name: "present", args: '{"files":[{"path":"game.html"}]}', readOnly: true, status: "done", output: "Presented game.html", presentedFiles: [{ path: "game.html", description: "Playable game" }] },
136 { kind: "notice", id: "turn-result", level: "info", text: "Turn complete", completionSummary: {
137 preset: "balanced", verdict: "complete", mutations: 2, changed_files: 2, checks_passed: 0, checks_failed: 0,
138 checks_suppressed: 0, review: "passed", constraint_degraded: false,
139 receipt: { verdict: "complete", diff: { id: "5:result", turn: 5, coverage: "complete", added: 4, removed: 1, reasons: [], files: [
140 { path: "game.html", kind: "create", added: 3, removed: 0 },
141 { path: "notes.md", kind: "create", added: 1, removed: 1 },
142 ] } },
143 } },
144 { kind: "assistant", id: "deliverable-answer", text: "Open `game.html`.", reasoning: "", streaming: false },
145 ];
146 deliverables.update({ ...input, items: deliverableItems, running: false });
147 await Promise.resolve();
148 const tail = deliverables.getNodeSnapshot("deliverable-user:tail");
149 assert.ok(tail?.kind === "tail");
150 assert.deepEqual(tail.presentedFiles, [
151 { path: "game.html", description: "Playable game", toolCallId: "present-new" },
152 { path: "README.md", description: "Instructions", toolCallId: "present-doc" },
153 ]);
154 assert.deepEqual(tail.modifiedFiles, [
155 { path: "game.html", toolCallId: "write-game", operation: "written" },
156 { path: "notes.md", toolCallId: "write-notes", operation: "written" },
157 ], "the changed-files review keeps presented files in the same turn inventory");
158 assert.equal(tail.completionSummary?.receipt?.diff?.added, 4, "the tail carries the frozen review receipt");
159 const presentProcess = deliverables.getNodeSnapshot("deliverable-user:process");
160 assert.ok(presentProcess?.kind === "process" && presentProcess.toolCallCount === 6);
161 deliverables.dispose();
162
163 const filesWithoutAnswer = new ChatSource("files-without-answer");
164 filesWithoutAnswer.update({ ...input, running: false, items: [
165 { kind: "user", id: "files-user", text: "make files", checkpointTurn: 6 },
166 { kind: "tool", id: "write-only", name: "write_file", args: '{"path":"draft.txt","content":"draft"}', readOnly: false, status: "done", output: "wrote draft.txt" },
167 { kind: "tool", id: "present-only", name: "present", args: '{"files":[{"path":"result.pdf"}]}', readOnly: true, status: "done", output: "Presented result.pdf", presentedFiles: [{ path: "result.pdf" }] },
168 ] });
169 const noAnswerTail = filesWithoutAnswer.getNodeSnapshot("files-user:tail");
170 assert.ok(noAnswerTail?.kind === "tail");
171 assert.equal(noAnswerTail.answerKey, undefined);
172 assert.deepEqual(noAnswerTail.presentedFiles, [{ path: "result.pdf", toolCallId: "present-only" }]);
173 assert.deepEqual(noAnswerTail.modifiedFiles, [{ path: "draft.txt", toolCallId: "write-only", operation: "written" }]);
174 assert.ok(filesWithoutAnswer.getOrderSnapshot().includes("files-user:tail"));
175 filesWithoutAnswer.dispose();
176
177 const auditSource = new ChatSource("proxy-audit");
178 auditSource.update({ ...input, running: false, items: [
179 { kind: "user", id: "audit-user", text: "inspect" },
180 { kind: "notice", id: "audit", code: "capability_proxy_audit", level: "info", text: "proxy target", detail: '{"callId":"call","target":"read"}' },
181 { kind: "tool", id: "call", name: "use_capability", args: "{}", readOnly: true, status: "done" },
182 { kind: "notice", id: "unassociated", code: "capability_proxy_audit", level: "info", text: "diagnostic only", detail: "{}" },
183 ] });
184 assert.ok(!auditSource.getOrderSnapshot().includes("audit"), "paired audit must not duplicate the tool row");
185 const auditProcess = auditSource.getNodeSnapshot("audit-user:process");
186 assert.ok(auditProcess?.kind === "process" && !auditProcess.members.includes("audit"));
187 assert.equal(auditSource.toolAudits("call").length, 1, "paired audit stays available in tool details");
188 assert.ok(auditSource.getOrderSnapshot().includes("unassociated"), "unpaired audit remains available as diagnostics");
189 auditSource.dispose();
190
191 const maintenanceSource = new ChatSource("maintenance-card");
192 maintenanceSource.update({ ...input, running: true, items: [
193 { kind: "user", id: "compact-user", text: "/compact" },
194 { kind: "tool", id: "previous-tool", name: "bash", args: "{}", readOnly: true, status: "done" },
195 { kind: "assistant", id: "previous-answer", text: "done", reasoning: "", streaming: false },
196 { kind: "compaction", id: "maintenance:op", pending: true, trigger: "manual", messages: 0, summary: "", archive: "", operationId: "op", status: "running" },
197 ] });
198 await Promise.resolve();
199 const maintenanceProcess = maintenanceSource.getNodeSnapshot("compact-user:process");
200 assert.ok(maintenanceProcess?.kind === "process" && !maintenanceProcess.members.includes("maintenance:op"), "maintenance card is independent from the previous process fold");
201 assert.ok(maintenanceSource.getOrderSnapshot().includes("maintenance:op"), "pending maintenance remains mounted");
202 maintenanceSource.dispose();
203
204 const endings: Record<string, Item[]> = {
205 clean: [{ kind: "assistant", id: "clean-answer", text: "done", reasoning: "", streaming: false, turnFinal: true }],
206 stopped: [{ kind: "notice", id: "stopped-note", level: "info", text: "Stopped" }],
207 errored: [{ kind: "notice", id: "errored-note", level: "warn", text: "provider error" }],
208 unfinishedTool: [{ kind: "tool", id: "hung", name: "fleet", args: "{}", readOnly: true, status: "stopped" }],
209 streamingLeftover: [{ kind: "assistant", id: "leftover", text: "partial", reasoning: "", streaming: true }],
210 };
211 for (const [ending, tail] of Object.entries(endings)) {
212 const source = new ChatSource(`ending-${ending}`);
213 const turn: Item[] = [
214 { kind: "user", id: "q", text: "go", checkpointTurn: 1 },
215 { kind: "tool", id: "t1", name: "bash", args: "{}", readOnly: true, status: "done", output: "ok" },
216 ...tail,
217 ];
218 source.update({ ...input, items: turn, running: false });
219 await Promise.resolve();
220 const seat = () => source.getNodeSnapshot("q:process") as Extract<NonNullable<ReturnType<typeof source.getNodeSnapshot>>, { kind: "process" }>;
221 assert.ok(seat().foldable, `${ending}: every ended turn has a fold control`);
222 const startsFolded = seat().collapsed;
223 assert.equal(startsFolded, ending === "clean", `${ending}: only a settled turn starts folded`);
224 source.toggleProcess("q");
225 assert.equal(seat().collapsed, !startsFolded, `${ending}: the control flips the turn`);
226 source.update({ ...input, items: [...turn], running: false });
227 await Promise.resolve();
228 assert.equal(seat().collapsed, !startsFolded, `${ending}: the manual choice survives republication`);
229 source.toggleProcess("q");
230 assert.equal(seat().collapsed, startsFolded, `${ending}: the control flips back`);
231 source.dispose();
232 }
233 console.log("chat view: every ended turn is foldable by hand");
234
234 lines TYPESCRIPT