返回 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);
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);
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 recovered = new ChatSource("recovered-weather");
67 const weather: Item[] = [
68 { kind: "user", id: "weather-user", text: "weather", checkpointTurn: 4 },
69 { kind: "tool", id: "failed-fetch", name: "web_fetch", args: "{}", readOnly: true, status: "error", error: "DNS refused" },
70 { kind: "assistant", id: "retry-message", text: "Trying another source", reasoning: "Need current data", streaming: false },
71 { kind: "notice", id: "permission", level: "info", title: "Permission saved", text: "permission saved to /tmp/reasonix.toml: long command" },
72 { kind: "tool", id: "successful-fetch", name: "bash", args: "{}", readOnly: true, status: "done", output: "25 degrees" },
73 { kind: "assistant", id: "weather-answer", text: "Sunny, 25 degrees", reasoning: "Sources agree", streaming: false },
74 ];
75 recovered.update({ ...input, items: weather, running: false });
76 await Promise.resolve();
77 const compact = recovered.getNodeSnapshot("weather-user:process");
78 assert.ok(compact?.kind === "process" && compact.collapsed, "recovered call errors do not prevent completed-turn folding");
79 assert.equal(compact.toolCallCount, 2);
80 assert.equal(compact.messageCount, 1);
81 assert.equal(compact.failureCount, 1, "failed call count remains visible in the collapsed summary");
82 assert.ok(compact.members.includes("permission"), "system receipts are inside the process range");
83 assert.ok(!compact.members.includes("weather-answer"), "final answer remains outside the process range");
84 recovered.toggleProcess("weather-user");
85 recovered.dispose();
86 const restored = new ChatSource("recovered-weather");
87 restored.update({ ...input, items: weather, running: false });
88 assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
89 restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
90 assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).foldable, false, "terminal interruption is not hidden by a partial answer");
91 restored.dispose();
92
93 const deliverables = new ChatSource("deliverables");
94 const deliverableItems: Item[] = [
95 { kind: "user", id: "deliverable-user", text: "build a game", checkpointTurn: 5 },
96 { kind: "tool", id: "write-game", name: "write_file", args: '{"path":"game.html","content":"game"}', readOnly: false, status: "done", output: "wrote game.html" },
97 { kind: "tool", id: "write-notes", name: "write_file", args: '{"path":"notes.md","content":"notes"}', readOnly: false, status: "done", output: "wrote notes.md" },
98 { kind: "tool", id: "failed-write", name: "write_file", args: '{"path":"missing.md"}', readOnly: false, status: "error", error: "denied" },
99 { 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" }] },
100 { 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" }] },
101 { 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" }] },
102 { kind: "assistant", id: "deliverable-answer", text: "Open `game.html`.", reasoning: "", streaming: false },
103 ];
104 deliverables.update({ ...input, items: deliverableItems, running: false });
105 await Promise.resolve();
106 const tail = deliverables.getNodeSnapshot("deliverable-user:tail");
107 assert.ok(tail?.kind === "tail");
108 assert.deepEqual(tail.presentedFiles, [
109 { path: "game.html", description: "Playable game", toolCallId: "present-new" },
110 { path: "README.md", description: "Instructions", toolCallId: "present-doc" },
111 ]);
112 assert.deepEqual(tail.modifiedFiles, [
113 { path: "notes.md", toolCallId: "write-notes", operation: "written" },
114 ], "presented files suppress duplicate compact mutation entries");
115 const presentProcess = deliverables.getNodeSnapshot("deliverable-user:process");
116 assert.ok(presentProcess?.kind === "process" && presentProcess.toolCallCount === 6);
117 deliverables.dispose();
118
119 const filesWithoutAnswer = new ChatSource("files-without-answer");
120 filesWithoutAnswer.update({ ...input, running: false, items: [
121 { kind: "user", id: "files-user", text: "make files", checkpointTurn: 6 },
122 { kind: "tool", id: "write-only", name: "write_file", args: '{"path":"draft.txt","content":"draft"}', readOnly: false, status: "done", output: "wrote draft.txt" },
123 { kind: "tool", id: "present-only", name: "present", args: '{"files":[{"path":"result.pdf"}]}', readOnly: true, status: "done", output: "Presented result.pdf", presentedFiles: [{ path: "result.pdf" }] },
124 ] });
125 const noAnswerTail = filesWithoutAnswer.getNodeSnapshot("files-user:tail");
126 assert.ok(noAnswerTail?.kind === "tail");
127 assert.equal(noAnswerTail.answerKey, undefined);
128 assert.deepEqual(noAnswerTail.presentedFiles, [{ path: "result.pdf", toolCallId: "present-only" }]);
129 assert.deepEqual(noAnswerTail.modifiedFiles, [{ path: "draft.txt", toolCallId: "write-only", operation: "written" }]);
130 assert.ok(filesWithoutAnswer.getOrderSnapshot().includes("files-user:tail"));
131 filesWithoutAnswer.dispose();
132
133 const auditSource = new ChatSource("proxy-audit");
134 auditSource.update({ ...input, running: false, items: [
135 { kind: "user", id: "audit-user", text: "inspect" },
136 { kind: "notice", id: "audit", code: "capability_proxy_audit", level: "info", text: "proxy target", detail: '{"callId":"call","target":"read"}' },
137 { kind: "tool", id: "call", name: "use_capability", args: "{}", readOnly: true, status: "done" },
138 { kind: "notice", id: "unassociated", code: "capability_proxy_audit", level: "info", text: "diagnostic only", detail: "{}" },
139 ] });
140 assert.ok(!auditSource.getOrderSnapshot().includes("audit"), "paired audit must not duplicate the tool row");
141 const auditProcess = auditSource.getNodeSnapshot("audit-user:process");
142 assert.ok(auditProcess?.kind === "process" && !auditProcess.members.includes("audit"));
143 assert.equal(auditSource.toolAudits("call").length, 1, "paired audit stays available in tool details");
144 assert.ok(auditSource.getOrderSnapshot().includes("unassociated"), "unpaired audit remains available as diagnostics");
145 auditSource.dispose();
146
146 lines TYPESCRIPT