feat(webui): CHAT-02 Console, read-only conversation view (#1507)
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]>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// 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(); }
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Repository-layout fixture for the conversation view (#1507, CHAT-02). The
|
||||
// board's history reader reads only <projectRoot>/.pi/state/<seat>/sessions,
|
||||
// with the project named after the root directory and a Pi header whose cwd is
|
||||
// inside the project. Real board, real routes, real WebUI; temporary data only.
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
export const at = s => new Date(Date.now() + s * 1000).toISOString();
|
||||
|
||||
// Appends Pi entries to one session file, each the child of the one before.
|
||||
export function session(file, cwd, { id = 'sess-1', timestamp = at(-600) } = {}) {
|
||||
let n = 0, parent = null;
|
||||
writeFileSync(file, JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd }) + '\n');
|
||||
return {
|
||||
add(message, timestamp = at(0)) {
|
||||
const entry = `${id}-e${++n}`;
|
||||
appendFileSync(file, JSON.stringify({ type: 'message', id: entry, parentId: parent, timestamp, message }) + '\n');
|
||||
parent = entry;
|
||||
return entry;
|
||||
},
|
||||
raw(text) { appendFileSync(file, text + '\n'); },
|
||||
};
|
||||
}
|
||||
export const user = text => ({ role: 'user', content: [{ type: 'text', text }] });
|
||||
export const assistant = (content, stopReason = 'stop') => ({ role: 'assistant', stopReason, content: typeof content === 'string' ? [{ type: 'text', text: content }] : content });
|
||||
export const toolResult = (toolCallId, text, isError = false) => ({ role: 'toolResult', toolCallId, toolName: 'read', content: [{ type: 'text', text }], isError });
|
||||
|
||||
export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-history-'));
|
||||
const projectRoot = join(root, project), sessionsDir = join(projectRoot, '.pi', 'state', seat, 'sessions');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const seatsDir = join(root, 'seats'), captures = [];
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
|
||||
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
|
||||
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
|
||||
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
|
||||
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
|
||||
// Browser helpers shared by the history tests.
|
||||
export function waiter(b) {
|
||||
return (expression, ms = 25000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
|
||||
}
|
||||
export const turns = b => b.evaluate('[...document.querySelectorAll("#conv-log > li")].map(li => [li.querySelector(".who").textContent, li.querySelector(".conv-text")?.textContent ?? [...li.querySelectorAll("summary")].map(s => s.textContent).join(" | ")])');
|
||||
@@ -0,0 +1,99 @@
|
||||
// CHAT-02 return-flow regression (#1507, brief §2.3). Real board, real
|
||||
// conversation routes, real WebUI; nothing is injected into the page and the
|
||||
// Refresh button is never used, so every new entry arrives by polling.
|
||||
// 1 path, 2 exactness, 3 continuation, 4 thread order, 5 interleaving,
|
||||
// 6 relaunch mid-turn, 7 a delayed tool result with a draft being typed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
|
||||
|
||||
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
|
||||
const ANSWER = 'A'.repeat(250) + 'MID_SENTINEL' + ' and the rest of the answer'.repeat(20) + ' END_SENTINEL';
|
||||
const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616).toString(36).padStart(4, '0')).join('-') + ' BIG_END';
|
||||
|
||||
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, 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('Start'), at(-120));
|
||||
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
|
||||
|
||||
b = await browser(); await b.viewport(1440, 1000);
|
||||
const wait = waiter(b);
|
||||
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
|
||||
// The seat is waiting on Jason, so the view opens from its card.
|
||||
await b.navigate(f.base); await wait('document.querySelector("#waiting [data-history]")');
|
||||
await b.evaluate('document.querySelector("#waiting [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
|
||||
const opened = await b.evaluate('document.querySelector("#conv-pick").value');
|
||||
|
||||
// 1. Send from the conversation view, through the board's reply path.
|
||||
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#conv-form").requestSubmit(); })()');
|
||||
await wait('document.querySelector("#conv-receipt").textContent.includes("delivered")');
|
||||
const sent = f.captures.at(-1).at(-1);
|
||||
assert.match(sent, /^REPLY_FROM_JASON\n/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), '', 'delivered draft cleared');
|
||||
|
||||
// The seat takes the message and calls a tool. No answer yet.
|
||||
log.add(user(sent), at(0));
|
||||
log.add(assistant([{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }], 'toolUse'), at(1));
|
||||
await wait('document.querySelector("#conv-log .conv-tool-call")');
|
||||
// 7. A draft typed while the seat works. The tool result lands only after that poll.
|
||||
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()');
|
||||
log.add(toolResult('call-1', 'file body'), at(2));
|
||||
await wait('document.querySelector("#conv-log .conv-tool-result")');
|
||||
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true], 'draft and caret survive the poll');
|
||||
|
||||
// 5. A peer agent-send lands before the final answer. 2. The answer has sentinels past 240 and at the end.
|
||||
log.add(user(PEER), at(3));
|
||||
log.add(assistant(ANSWER), at(4));
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("END_SENTINEL")');
|
||||
// 4. Thread order: sent message, collapsed tool call and result, peer message, answer.
|
||||
assert.deepEqual(await turns(b), [
|
||||
['User', 'Start'], ['Assistant', 'Input needed: OLD_ANSWER'], ['User', sent], ['Assistant', 'Tool call: read'],
|
||||
['Tool', 'Tool result'], ['User', PEER], ['Assistant', ANSWER],
|
||||
]);
|
||||
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
|
||||
assert.equal(text.split('MID_SENTINEL').length, 2, 'answer shown once');
|
||||
assert.equal(text.includes('…'), false, 'nothing clipped');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log details[open]").length'), 0, 'tools stay collapsed');
|
||||
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
|
||||
|
||||
// 3. An answer long enough to split into continuation parts reassembles exactly and in order.
|
||||
const bigId = log.add(assistant(BIG), at(5));
|
||||
await wait('document.querySelector("#conv-log").textContent.endsWith("BIG_END")', 45000);
|
||||
const hash = await b.evaluate('crypto.subtle.digest("SHA-256", new TextEncoder().encode([...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent)).then(d => [...new Uint8Array(d)].map(x => x.toString(16).padStart(2, "0")).join(""))');
|
||||
assert.equal(hash, createHash('sha256').update(BIG).digest('hex'));
|
||||
const parts = [];
|
||||
let res = await (await fetch(`${f.base}/api/conversation?id=${opened}`)).json();
|
||||
for (;;) {
|
||||
parts.push(...res.page.entries.filter(e => e.nativeEntry === bigId));
|
||||
if (!res.cursor) break;
|
||||
res = await (await fetch(`${f.base}/api/conversation?id=${opened}&branch=${res.page.branch}&cursor=${res.cursor.id}`)).json();
|
||||
}
|
||||
assert.ok(parts.length >= 2, `continuation parts: ${parts.length}`);
|
||||
assert.deepEqual(parts.map(p => [p.part, p.lastPart]), parts.map((_, i) => [i, i === parts.length - 1]));
|
||||
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
|
||||
|
||||
// 6. Relaunch mid-turn: a new session file appears. The view keeps its file and says so.
|
||||
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(6) });
|
||||
log2.add(user('NEW_SESSION_MESSAGE'), at(7));
|
||||
await wait('document.querySelector("[data-marker=newer]")');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-marker=newer]").textContent'), /newer session started.*stays on the session you opened/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
|
||||
const after = await b.evaluate('document.querySelector("#conv-log").textContent');
|
||||
assert.ok(after.includes('END_SENTINEL') && !after.includes('NEW_SESSION_MESSAGE'), 'no silent switch');
|
||||
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
|
||||
// Opening the newest session is Jason's choice; the draft for this seat stays.
|
||||
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("NEW_SESSION_MESSAGE")');
|
||||
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
|
||||
} finally { if (b) await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -54,6 +54,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
|
||||
let body = ''; for await (const c of req) body += c;
|
||||
requests.push({ url: req.url, method: req.method, body });
|
||||
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
|
||||
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
|
||||
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
|
||||
});
|
||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
||||
@@ -73,6 +74,21 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal((await fetch(base + '/api/board')).status, 502);
|
||||
assert.equal(requests.length, 3);
|
||||
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
|
||||
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.status, 404, path);
|
||||
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
||||
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
||||
}
|
||||
const before = requests.length;
|
||||
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
||||
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
||||
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
assert.equal(requests.length, before, 'refused before the board');
|
||||
// Only the conversation routes carry a query upstream.
|
||||
await fetch(base + '/api/board?x=1');
|
||||
assert.equal(requests.at(-1).url, '/api/board');
|
||||
} finally { await close(web); await close(upstream); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user