| 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 tool = (id: string): Item => ({ kind: "tool", id, name: "read_file", args: "", readOnly: true, status: "done", output: "ok" }); |
| 6 | const answer = (id: string, streaming = false): Item => ({ kind: "assistant", id, text: "done", reasoning: "", streaming }); |
| 7 | const base: ChatInput = { items: [], running: false, hydrating: false, hasOlder: true, loadingOlder: false }; |
| 8 | const process = (source: ChatSource, key: string) => { |
| 9 | const node = source.getNodeSnapshot(`${key}:process`); |
| 10 | assert.equal(node?.kind, "process"); |
| 11 | return node as Extract<NonNullable<typeof node>, { kind: "process" }>; |
| 12 | }; |
| 13 | |
| 14 | const head: Item[] = [tool("t1"), tool("t2"), answer("a1")]; |
| 15 | const settled = new ChatSource("head-settled"); |
| 16 | settled.update({ ...base, items: head }); |
| 17 | await Promise.resolve(); |
| 18 | assert.ok(process(settled, "history-head").foldable, "a head group with tools can fold"); |
| 19 | assert.ok(process(settled, "history-head").collapsed, "a settled head group starts folded"); |
| 20 | settled.toggleProcess("history-head"); |
| 21 | assert.equal(process(settled, "history-head").collapsed, false, "the head group folds and unfolds by hand"); |
| 22 | settled.toggleProcess("history-head"); |
| 23 | settled.dispose(); |
| 24 | |
| 25 | const running = new ChatSource("head-running"); |
| 26 | running.update({ ...base, items: [tool("t1"), answer("a1", true)], running: true }); |
| 27 | await Promise.resolve(); |
| 28 | const live = process(running, "history-head"); |
| 29 | assert.ok(!live.foldable && !live.collapsed, "a head group still running stays open"); |
| 30 | running.dispose(); |
| 31 | |
| 32 | const chosen = new ChatSource("head-choice"); |
| 33 | chosen.update({ ...base, items: head }); |
| 34 | await Promise.resolve(); |
| 35 | chosen.toggleProcess("history-head"); |
| 36 | const user: Item = { kind: "user", id: "u0", text: "long task", checkpointTurn: 1 }; |
| 37 | chosen.update({ ...base, items: [user, ...head], hasOlder: false }); |
| 38 | await Promise.resolve(); |
| 39 | assert.equal(chosen.getNodeSnapshot("history-head:process"), undefined, "the head key is gone once the user message arrives"); |
| 40 | assert.equal(process(chosen, "u0").collapsed, false, "the manual choice follows the turn when its user message loads"); |
| 41 | chosen.dispose(); |
| 42 | |
| 43 | const folded = new ChatSource("head-folded"); |
| 44 | folded.update({ ...base, items: head }); |
| 45 | await Promise.resolve(); |
| 46 | folded.update({ ...base, items: [user, ...head], hasOlder: false }); |
| 47 | await Promise.resolve(); |
| 48 | assert.ok(process(folded, "u0").collapsed, "an untouched head stays folded after the user message loads"); |
| 49 | folded.dispose(); |
| 50 | |
| 51 | const unrelated = new ChatSource("head-unrelated"); |
| 52 | unrelated.update({ ...base, items: head }); |
| 53 | await Promise.resolve(); |
| 54 | unrelated.toggleProcess("history-head"); |
| 55 | const other: Item[] = [{ kind: "user", id: "u9", text: "other", checkpointTurn: 9 }, tool("x1"), answer("ax")]; |
| 56 | unrelated.update({ ...base, items: other }); |
| 57 | await Promise.resolve(); |
| 58 | assert.ok(process(unrelated, "u9").collapsed, "a choice made on the head does not leak onto a turn that does not contain it"); |
| 59 | unrelated.dispose(); |
| 60 | |
| 61 | console.log("chat view: history head fold, running head and choice migration passed"); |
| 62 |