// Conversation view (#1507, CHAT-02 brief §2.2): full branch history through // the real board routes and the real WebUI. The hostile-render fixture (R1) // must stay inert text: no element, handler or navigation from session content. // WEBUI_EVIDENCE= also saves full-page screenshots at 320 and 1440 in the // light and dark modes. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { browser } from './browser.mjs'; import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs'; const HOSTILE = ' [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil'; const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END'; test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => { const f = await historyFixture(); let b; try { const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'); const log = session(file, f.projectRoot); log.add(user('Show me the file'), at(-300)); log.add(assistant([{ type: 'thinking', thinking: 'SECRET_THOUGHT' }, { type: 'text', text: 'Reading it.' }, { type: 'toolCall', id: 'call_1|fc_2', name: 'read', arguments: { path: 'README.md' } }], 'toolUse'), at(-290)); log.add(toolResult('call_1|fc_2', HOSTILE), at(-280)); log.raw('{"type":"message","id":'); log.add(assistant('Here: ' + HOSTILE), at(-270)); log.add(user('And the summary?'), at(-260)); log.add(assistant(LONG), at(-250)); b = await browser(); await b.viewport(1440, 1000); const wait = waiter(b); await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); const href = await b.evaluate('location.href'); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-status").textContent.startsWith("7 messages")'); assert.equal(await b.evaluate('document.querySelector("#board-view").hidden'), true); assert.equal(await b.evaluate('document.activeElement.id'), 'conv-title'); assert.match(await b.evaluate('document.querySelector("#conv-meta").textContent'), /^repo · Show me the file · started /); assert.deepEqual((await turns(b)).map(t => t[0]), ['User', 'Assistant', 'Tool', 'Notice', 'Assistant', 'User', 'Assistant']); assert.match(await b.evaluate('document.querySelectorAll("#conv-log > li")[3].textContent'), /Line 5 could not be read/); // The long answer in full: the board's 240-character summary is not used. assert.equal(await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent'), LONG); // Thinking and tools collapsed; their contents are not rendered visible. assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#conv-log details")].map(d => [d.className, d.querySelector("summary").textContent, d.open, d.querySelector("pre").checkVisibility()])'), [ ['conv-thinking', 'Thinking', false, false], ['conv-tool-call', 'Tool call: read', false, false], ['conv-tool-result', 'Tool result', false, false], ]); assert.equal(await b.evaluate('document.querySelector(".conv-tool-call pre").textContent'), '{"path":"README.md"}'); // Hostile content: shown as text, controls made visible, nothing active created. await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)'); const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent'); assert.ok(shown.startsWith('Here: [click](javascript:window.injected=3) link ␛[31mRED'), shown); assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown); assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6)); assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0); assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false); assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false); await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()'); assert.equal(await b.evaluate('typeof window.injected'), 'undefined'); assert.equal(await b.evaluate('location.href'), href); // A same-inode rewrite of the history: the next check refuses, the view keeps what it showed. writeFileSync(file, readFileSync(file, 'utf8').replace('Show me the file', 'Show me the FILE')); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("[data-marker=reconcile]")'); assert.match(await b.evaluate('document.querySelector("[data-marker=reconcile]").textContent'), /out of date \(source-replaced\).*loaded before\.Reload conversation$/); assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 7); assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the file/); assert.match(await b.evaluate('document.querySelector("#conv-status").textContent'), /not checking for new entries/); // Evidence shows thinking as a reader first sees it (closed), from the top of the page. await b.evaluate('document.querySelector(".conv-thinking").open = false; scrollTo(0, 0)'); for (const width of [320, 1440]) for (const mode of ['light', 'dark']) { await b.viewport(width, 1000); await b.evaluate(`(() => { const m = document.querySelector("#mode"); m.value = ${JSON.stringify(mode)}; m.dispatchEvent(new Event("change")); })()`); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `no horizontal overflow at ${width} ${mode}`); if (process.env.WEBUI_EVIDENCE) { const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }); writeFileSync(join(process.env.WEBUI_EVIDENCE, `conversation-${width}-${mode}.png`), Buffer.from(data, 'base64')); } } // Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button. await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")'); assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); await b.evaluate('document.querySelector("#conv-back").click()'); assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true); assert.equal(await b.evaluate('document.activeElement.dataset.history'), 'repo/fixture'); // The inspector opens the same view. await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('document.querySelector("#inspection [data-history]")'); await b.evaluate('document.querySelector("#inspection [data-history]").click()'); await wait('!document.querySelector("#conversation").hidden && document.querySelector("#conv-status").textContent.startsWith("7 messages")'); assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the FILE/); } finally { if (b) await b.close(); await f.close(); } }); test('conversation view: a fork keeps the open branch, says so, and opens the new one on request', { timeout: 60000 }, async () => { const f = await historyFixture(); let b; try { const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'); const log = session(file, f.projectRoot); const first = log.add(user('Question'), at(-120)); log.add(assistant('MAIN_ANSWER'), at(-110)); b = await browser(); await b.viewport(1440, 1000); const wait = waiter(b); await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); // Pi forks from the first entry; the fork is now its default leaf, on branch b.fork-1. log.raw(JSON.stringify({ type: 'message', id: 'fork-1', parentId: first, timestamp: at(0), message: assistant('FORK_ANSWER') })); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("[data-marker=branch]")'); assert.match(await b.evaluate('document.querySelector("[data-marker=branch]").textContent'), /continued on another branch\. This view stays on the branch it opened\.Open the latest branch$/); const text = await b.evaluate('document.querySelector("#conv-log").textContent'); assert.ok(text.includes('MAIN_ANSWER') && !text.includes('FORK_ANSWER'), 'no silent switch'); // The open branch grows; the next poll still follows it, not the default branch. log.add(user('MAIN_MORE'), at(0)); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")'); assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); // The fork becomes the default leaf again. A same-inode rewrite then refuses the next check. log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') })); writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"')); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("[data-marker=reconcile]")'); // Reload stays on the branch the view was on, and still says the conversation went elsewhere. await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")'); await wait('document.querySelector("[data-marker=branch]")'); assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); // "Open the latest branch" takes the default. await b.evaluate('document.querySelector("[data-conv-action=latest]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")'); assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]); assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null); // The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so. writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n')); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("[data-marker=reconcile]")'); await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); await wait('document.querySelector("[data-marker=gone]")'); assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/); assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); } finally { if (b) await b.close(); await f.close(); } }); test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => { const f = await historyFixture(); let b; try { const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot); log.add(user('Question'), at(-120)); log.add(assistant('OLD_ANSWER'), at(-110)); b = await browser(); await b.viewport(1440, 1000); const wait = waiter(b); await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); // The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last. const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) }); await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelector("[data-marker=newer]")'); await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)'); assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/); // Once the new file has entries, the same button opens it. log2.add(user('NEW_QUESTION'), at(1)); await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")'); assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null); } finally { if (b) await b.close(); await f.close(); } }); test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => { const f = await historyFixture(); let b; try { // No session file yet: the board still lists the registered seat. b = await browser(); await b.viewport(390, 900); const wait = waiter(b); await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); await wait('document.querySelector("#conv-status").textContent.includes("No readable history")'); assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 0); assert.equal(await b.evaluate('document.querySelector("#conv-pick").disabled'), true); } finally { if (b) await b.close(); await f.close(); } });