| 1 | import assert from "node:assert/strict"; |
| 2 | import { act, createElement, useState, type Dispatch, type SetStateAction } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { createTranscriptHarness } from "./transcript-dom-harness"; |
| 5 | import { reconcileMountedOrder, revealEarlierMountedOrder } from "../lib/chatMountedOrder"; |
| 6 | import type { Item } from "../lib/useController"; |
| 7 | import type { ChatActions } from "../components/ChatNodes"; |
| 8 | |
| 9 | for (const count of [1, 5, 23, 24, 25, 48, 49]) { |
| 10 | const resident = Array.from({ length: 60 }, (_, index) => `resident-${index}`); |
| 11 | const prefix = Array.from({ length: count }, (_, index) => `history-${index}`); |
| 12 | const full = [...prefix, ...resident]; |
| 13 | let mounted = reconcileMountedOrder(resident, full); |
| 14 | assert.equal(mounted, resident, `${count}-node prepend keeps the resident order object before the first frame`); |
| 15 | let frames = 0; |
| 16 | while (mounted.length < full.length) { |
| 17 | const previous = mounted; |
| 18 | mounted = revealEarlierMountedOrder(mounted, full); |
| 19 | frames += 1; |
| 20 | assert.deepEqual(mounted.slice(-resident.length), resident, `${count}-node prepend keeps the resident suffix after frame ${frames}`); |
| 21 | assert.ok(mounted.length > previous.length && mounted.length - previous.length <= 24, `${count}-node prepend reveals a bounded frame`); |
| 22 | } |
| 23 | assert.deepEqual(mounted, full, `${count}-node prepend converges to the source order`); |
| 24 | assert.equal(frames, Math.ceil(count / 24), `${count}-node prepend uses the expected frame count`); |
| 25 | } |
| 26 | |
| 27 | const harness = await createTranscriptHarness({ deterministic: true }); |
| 28 | const items: Item[] = [ |
| 29 | { kind: "user", id: "u1", text: "hello", checkpointTurn: 1 }, |
| 30 | { kind: "tool", id: "t1", name: "read_file", args: "{}", output: "result", readOnly: true, status: "done" }, |
| 31 | { kind: "assistant", id: "a1", text: "answer", reasoning: "thought", streaming: false }, |
| 32 | ]; |
| 33 | try { |
| 34 | await harness.loadModule("/src/components/ChatToolBody.tsx"); |
| 35 | await harness.render(items); |
| 36 | await harness.settle(); |
| 37 | assert.ok(harness.container.querySelector(".chat-column")); |
| 38 | assert.ok(harness.container.querySelector('[id^="reasonix-chat-transcript-"] .chat-column'), "native upgrade evidence scopes history to the transcript"); |
| 39 | assert.equal(harness.container.querySelectorAll(".transcript__window-item").length, 0); |
| 40 | assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "completed process unmounts heavy rows"); |
| 41 | const disclosure = harness.container.querySelector<HTMLButtonElement>(".chat-process"); |
| 42 | await act(async () => disclosure!.click()); |
| 43 | const tool = harness.container.querySelector<HTMLElement>(".chat-tool [data-disclosure-row]"); |
| 44 | assert.ok(tool); |
| 45 | await act(async () => tool.click()); |
| 46 | await harness.settle(); |
| 47 | assert.ok(harness.container.querySelector(".dsh-ToolRow-ioCard"), "tool opens an inline preview"); |
| 48 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".dsh-ToolRow-inspectButton")!.click()); |
| 49 | assert.ok(harness.container.querySelector('[role="dialog"]')); |
| 50 | for (let i = 0; i < 60; i++) { |
| 51 | await harness.render(items.map(item => item.kind === "assistant" ? { ...item, text: `answer ${i}`, streaming: true } : item), { running: true }); |
| 52 | await act(async () => { harness.resizeNotifications.forEach(notify => notify()); harness.clock.advance(16); }); |
| 53 | } |
| 54 | assert.ok(harness.container.querySelector(".chat-column"), "60 geometry changes do not trip React nested update fuse"); |
| 55 | await harness.render(items, { geometrySessionKey: "other" }); |
| 56 | assert.equal(harness.container.querySelector('[role="dialog"]'), null, "session replacement closes details"); |
| 57 | |
| 58 | const restored: Item[] = []; |
| 59 | for (let turn = 1; turn <= 12; turn += 1) { |
| 60 | restored.push( |
| 61 | { kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn }, |
| 62 | { kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false }, |
| 63 | ); |
| 64 | } |
| 65 | await harness.render(restored, { geometrySessionKey: "history-identity" }); |
| 66 | await harness.settle(); |
| 67 | const existing = new Map(Array.from(harness.container.querySelectorAll<HTMLElement>("[data-chat-anchor-key]")) |
| 68 | .map(node => [node.dataset.chatAnchorKey!, node])); |
| 69 | assert.equal(existing.size, 60, "fixture spans multiple legacy 24-node chunks"); |
| 70 | const selectedParagraph = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a6"] .md p')!; |
| 71 | const selectedText = selectedParagraph.firstChild!; |
| 72 | const range = harness.dom.window.document.createRange(); |
| 73 | range.selectNodeContents(selectedText); |
| 74 | const selection = harness.dom.window.getSelection()!; |
| 75 | selection.removeAllRanges(); |
| 76 | selection.addRange(range); |
| 77 | const selectedBefore = selection.toString(); |
| 78 | await harness.render([ |
| 79 | { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 }, |
| 80 | { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false }, |
| 81 | ...restored, |
| 82 | ], { geometrySessionKey: "history-identity" }); |
| 83 | await harness.settle(); |
| 84 | for (const [key, node] of existing) { |
| 85 | assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${key}"]`), node, `history prepend preserves ${key} DOM identity`); |
| 86 | } |
| 87 | assert.equal(selection.toString(), selectedBefore, "history prepend preserves the native text selection"); |
| 88 | |
| 89 | const deepHistory: Item[] = []; |
| 90 | for (let turn = 1; turn <= 15; turn += 1) { |
| 91 | deepHistory.push( |
| 92 | { kind: "user", id: `old-u${turn}`, text: `old prompt ${turn}`, checkpointTurn: -turn }, |
| 93 | { kind: "assistant", id: `old-a${turn}`, text: `old answer ${turn}`, reasoning: "", streaming: false }, |
| 94 | ); |
| 95 | } |
| 96 | await harness.render([ |
| 97 | ...deepHistory, |
| 98 | { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 }, |
| 99 | { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false }, |
| 100 | ...restored, |
| 101 | ], { geometrySessionKey: "history-identity" }); |
| 102 | assert.equal(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), null, "deep history is still mounting after two bounded frames"); |
| 103 | assert.equal(harness.container.querySelector('[data-nav-turn="old-u1"]'), null, "navigation omits history without a mounted target"); |
| 104 | await harness.settle(); |
| 105 | assert.ok(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), "deep history eventually mounts"); |
| 106 | assert.ok(harness.container.querySelector('[data-nav-turn="old-u1"]'), "navigation publishes the target after its DOM commit"); |
| 107 | let newerLoads = 0; |
| 108 | await harness.render(restored, { |
| 109 | geometrySessionKey: "history-identity", |
| 110 | hasNewerHistory: true, |
| 111 | historyStartTurn: 4, |
| 112 | historyEndTurn: 12, |
| 113 | totalTurns: 20, |
| 114 | onLoadNewerHistory: async () => { newerLoads += 1; return "loaded"; }, |
| 115 | }); |
| 116 | const newer = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!; |
| 117 | await act(async () => newer.click()); |
| 118 | assert.equal(newerLoads, 0, "a native transcript selection protects its resident page from reclaim"); |
| 119 | assert.ok(harness.container.querySelector(".chat-history-selection"), "selection protection explains why paging paused"); |
| 120 | await act(async () => { |
| 121 | selection.removeAllRanges(); |
| 122 | harness.dom.window.document.dispatchEvent(new harness.dom.window.Event("selectionchange")); |
| 123 | }); |
| 124 | await act(async () => newer.click()); |
| 125 | assert.equal(newerLoads, 1, "newer paging resumes after the selection is cleared"); |
| 126 | assert.match(harness.container.querySelector(".chat-history-window")?.textContent ?? "", /5.*12.*20/, "the bounded window reports its visible turn range"); |
| 127 | |
| 128 | // Reading summary is presentation work, so this measures actual tool renders |
| 129 | // without a wall-clock threshold or counting the source's projection work. |
| 130 | // Mount the list once, then publish through its source: rerendering the test |
| 131 | // harness's LocaleProvider would also invalidate every locale consumer. |
| 132 | await harness.unmount(); |
| 133 | const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts"); |
| 134 | const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx"); |
| 135 | const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts"); |
| 136 | const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts"); |
| 137 | const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts"); |
| 138 | const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx"); |
| 139 | const source = new ChatSource("render-isolation"); |
| 140 | const root = createRoot(harness.container); |
| 141 | const loader = new ChatContentLoader(); |
| 142 | const scroll = new ChatScrollController("render-isolation"); |
| 143 | const mounts = new ChatMountedOrder(); |
| 144 | let changeActions!: Dispatch<SetStateAction<ChatActions>>; |
| 145 | function Surface() { |
| 146 | const [actions, setActions] = useState<ChatActions>({ openDetails: () => {}, recover: () => {}, |
| 147 | fork: { targetFor: () => undefined, loaded: true, verifiable: true, blocked: null, create: () => {} } }); |
| 148 | changeActions = setActions; |
| 149 | return createElement(ChatNodeList, { source, mounts, loader, scroll, actions }); |
| 150 | } |
| 151 | const publish = async (items: Item[], running = true) => { |
| 152 | await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false })); |
| 153 | await harness.settle(); |
| 154 | }; |
| 155 | try { |
| 156 | const renders = new Map<string, number>(); |
| 157 | const toolItem = (id: string, status: "done" | "running" | "error" = "done"): Item => ({ |
| 158 | kind: "tool", id, name: "custom_tool", args: "{}", status, |
| 159 | get summary() { renders.set(id, (renders.get(id) ?? 0) + 1); return `summary ${id}`; }, |
| 160 | }); |
| 161 | const siblings = Array.from({ length: 32 }, (_, index) => toolItem(`isolated-${index}`)); |
| 162 | const question: Item = { kind: "user", id: "isolated-user", text: "run tools" }; |
| 163 | await publish([question, ...siblings]); |
| 164 | await act(async () => root.render(createElement(LocaleProvider, null, createElement(Surface)))); |
| 165 | await harness.settle(); |
| 166 | assert.equal(renders.size, siblings.length, "the fixture renders every existing tool"); |
| 167 | renders.clear(); |
| 168 | const appended = toolItem("isolated-new", "running"); |
| 169 | await publish([question, ...siblings, appended]); |
| 170 | assert.deepEqual([...renders.keys()], [appended.id], "adding a tool renders no unchanged sibling despite process membership/count changes"); |
| 171 | renders.clear(); |
| 172 | const failed = toolItem(appended.id, "error"); |
| 173 | await publish([question, ...siblings, failed]); |
| 174 | assert.deepEqual([...renders.keys()], [failed.id], "a failure-count update renders only the changed tool"); |
| 175 | assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${failed.id}"] .chat-tool`)?.getAttribute("data-state"), "error"); |
| 176 | const finalAnswer: Item = { kind: "assistant", id: "isolated-answer", text: "finished", reasoning: "", streaming: false }; |
| 177 | await publish([question, ...siblings, failed, finalAnswer], false); |
| 178 | assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "settlement still hides every process member"); |
| 179 | assert.ok(harness.container.querySelector('[data-chat-anchor-key="isolated-answer"][data-turn-process-answer]'), "the final answer still gets collapsed-process spacing"); |
| 180 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-process")!.click()); |
| 181 | assert.equal(harness.container.querySelectorAll(".chat-tool").length, siblings.length + 1, "expansion still reveals every process member"); |
| 182 | assert.equal(harness.container.querySelector('[data-turn-process-answer]'), null, "expansion clears collapsed-answer spacing"); |
| 183 | renders.clear(); |
| 184 | await act(async () => changeActions(old => ({ ...old, fork: { ...old.fork!, blocked: "creating" } }))); |
| 185 | assert.equal(renders.size, 0, "fork state changes do not rerender unchanged tools"); |
| 186 | assert.ok(harness.container.querySelector('[aria-label*="Creating the branch"]'), "fork controls still receive current state"); |
| 187 | |
| 188 | await harness.loadModule("/src/components/PresentedFiles.tsx"); |
| 189 | const writes: Item[] = [ |
| 190 | { kind: "tool", id: "write-a", name: "write_file", args: '{"path":"a.ts","content":"a"}', status: "done" }, |
| 191 | { kind: "tool", id: "present-a", name: "present", args: "{}", status: "done", presentedFiles: [{ path: "a.ts", description: "A" }] }, |
| 192 | ]; |
| 193 | const liveAnswer: Item = { ...finalAnswer, streaming: true, reasoning: "unchanged", reasoningComplete: true }; |
| 194 | await publish([question, ...siblings, ...writes, liveAnswer]); |
| 195 | await harness.waitFor(() => Boolean(harness.container.querySelector(".turn-files") && harness.container.querySelector(".presented-files")), "file cards"); |
| 196 | const tail = source.getNodeSnapshot(`${question.id}:tail`); |
| 197 | assert.ok(tail?.kind === "tail"); |
| 198 | let modifiedRenders = 0, presentedRenders = 0; |
| 199 | Object.defineProperty(tail.modifiedFiles, "map", { configurable: true, value: function (...args: unknown[]) { |
| 200 | modifiedRenders++; return Array.prototype.map.apply(this, args as never); |
| 201 | } }); |
| 202 | Object.defineProperty(tail.presentedFiles, "slice", { configurable: true, value: function (...args: unknown[]) { |
| 203 | presentedRenders++; return Array.prototype.slice.apply(this, args as never); |
| 204 | } }); |
| 205 | for (let index = 0; index < 20; index++) await act(async () => source.updateLive({ |
| 206 | id: liveAnswer.id, text: `answer ${index}`, reasoning: "unchanged", reasoningComplete: true, |
| 207 | })); |
| 208 | assert.equal(modifiedRenders, 0, "text-only stream updates do not recompute modified-file cards"); |
| 209 | assert.equal(presentedRenders, 0, "text-only stream updates do not rerender presented-file cards"); |
| 210 | await publish([question, ...siblings, ...writes, |
| 211 | { kind: "tool", id: "write-b", name: "write_file", args: '{"path":"b.ts","content":"b"}', status: "done" }, |
| 212 | { kind: "tool", id: "present-b", name: "present", args: "{}", status: "done", presentedFiles: [{ path: "b.ts", description: "B" }] }, liveAnswer]); |
| 213 | assert.match(harness.container.querySelector(".turn-files")?.textContent ?? "", /b\.ts/, "new modified files remain visible"); |
| 214 | assert.match(harness.container.querySelector(".presented-files")?.textContent ?? "", /b\.ts/, "new presented files remain visible"); |
| 215 | |
| 216 | // Memoization must preserve positive updates as well as skip stale work. |
| 217 | const declarations: Item = { kind: "tool", id: "present-many", name: "present", args: "{}", status: "done", |
| 218 | presentedFiles: Array.from({ length: 5 }, (_, index) => ({ path: `file-${index}.ts`, description: `description-${index}` })) }; |
| 219 | await publish([question, declarations, liveAnswer]); |
| 220 | const cards = () => harness.container.querySelectorAll(".presented-file"); |
| 221 | assert.equal(cards().length, 4); |
| 222 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".presented-files .presented-files__toggle")!.click()); |
| 223 | assert.equal(cards().length, 5, "memoized file cards still expand from their own state"); |
| 224 | await publish([question, { ...declarations, presentedFiles: declarations.presentedFiles!.map((file, index) => index ? file : { ...file, description: "updated description" }) }, liveAnswer]); |
| 225 | assert.equal(cards().length, 5, "file metadata updates preserve disclosure state"); |
| 226 | assert.match(harness.container.querySelector(".presented-files")?.textContent ?? "", /updated description/, "same-path description updates are not cached away"); |
| 227 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".presented-files .presented-files__toggle")!.click()); |
| 228 | assert.equal(cards().length, 4); |
| 229 | |
| 230 | const boundary = { sourceSessionId: "source", sessionGeneration: 1, turnId: "turn", boundarySequence: 10, |
| 231 | turnNumber: 1, status: "committed", messageId: liveAnswer.id, available: true }; |
| 232 | const forkCalls: string[] = []; |
| 233 | await act(async () => changeActions(old => ({ ...old, fork: { targetFor: () => boundary, loaded: true, |
| 234 | verifiable: true, blocked: null, create: () => { forkCalls.push("old"); } } }))); |
| 235 | await act(async () => changeActions(old => ({ ...old, fork: { ...old.fork!, create: () => { forkCalls.push("latest"); } } }))); |
| 236 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-actions button.chat-action-icon:not(.copybtn)")!.click()); |
| 237 | assert.deepEqual(forkCalls, ["latest"], "fork context replacement uses the latest callback"); |
| 238 | await act(async () => changeActions(old => ({ ...old, fork: undefined }))); |
| 239 | assert.equal(harness.container.querySelector(".chat-actions button.chat-action-icon:not(.copybtn)"), null, "removing fork support removes the old control"); |
| 240 | |
| 241 | const copied: string[] = []; |
| 242 | Object.defineProperty(harness.dom.window.navigator, "clipboard", { configurable: true, |
| 243 | value: { writeText: async (text: string) => { copied.push(text); } } }); |
| 244 | const longReasoning = "complete reasoning ".repeat(600); |
| 245 | await publish([question, { ...liveAnswer, reasoning: longReasoning }]); |
| 246 | await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click()); |
| 247 | await act(async () => source.updateLive({ id: liveAnswer.id, text: "new answer while reading reasoning", reasoning: longReasoning, reasoningComplete: true })); |
| 248 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-reasoning .copybtn")!.click()); |
| 249 | assert.equal(copied.at(-1), longReasoning, "isolated reasoning still copies full content beyond its preview"); |
| 250 | await act(async () => source.updateLive({ id: liveAnswer.id, text: "latest answer", reasoning: "replacement reasoning", reasoningComplete: true })); |
| 251 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-reasoning .copybtn")!.click()); |
| 252 | assert.equal(copied.at(-1), "replacement reasoning", "changed reasoning replaces the copy callback content"); |
| 253 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".chat-actions .copybtn")!.click()); |
| 254 | assert.equal(copied.at(-1), "latest answer", "reasoning projection never replaces the canonical answer used for copying"); |
| 255 | } finally { |
| 256 | await act(async () => root.unmount()); |
| 257 | source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose(); |
| 258 | } |
| 259 | console.log("chat natural flow: process disclosure, details, stable history identity, mounted navigation and session isolation passed"); |
| 260 | } finally { await harness.unmount(); await harness.close(); } |
| 261 |