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