History opens a seat's conversation from the Waiting card, table row and inspector. It pages the whole branch through the CHAT-02 board routes, renders untrusted text inert, polls with the follow cursor, and marks every switch (branch, newer, reconcile, gone). The WebUI proxy passes only the two conversation routes' queries upstream. Dewey authored it. Filbert asked for changes on r1 (24b046af) and approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer', not 'reconcile', a deviation from brief 2.3 item 6 that Filbert accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
196 lines
15 KiB
JavaScript
196 lines
15 KiB
JavaScript
// 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=<dir> 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 = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> \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: <script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> ␛[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(); }
|
|
});
|