| 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 |