import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { chromium } from 'playwright'; let browser; const source = await readFile(new URL('../static/js/turnRendering.js', import.meta.url), 'utf8'); before(async () => { browser = await chromium.launch({ headless: true }); }); after(async () => { await browser?.close(); }); async function run(html, action) { const page = await browser.newPage(); try { await page.setContent(`
${html}
`); await page.evaluate(async source => { const { createTurnRendering, startsContinuationRound } = await import('data:text/javascript;base64,' + btoa(source)); window.createTurnRendering = createTurnRendering; window.startsContinuationRound = startsContinuationRound; const root = document.querySelector('#history'); const start = document.createComment('live-stream-turn'); root.prepend(start); window.owner = createTurnRendering({ root, start }); }, source); return await page.evaluate(action); } finally { await page.close(); } } test('round one reuses the initial bubble and later rounds start continuations', async () => { const result = await run('
', () => ({ first: startsContinuationRound({ type: 'agent_step', round: 1 }), second: startsContinuationRound({ type: 'agent_step', round: 2 }), nonStep: startsContinuationRound({ type: 'tool_start', round: 2 }), })); assert.deepEqual(result, { first: false, second: true, nonStep: false }); }); test('plain streamed prose preserves node identity, selection and focus', async () => { const result = await run('

Hello world.

', () => { const body = document.querySelector('.body'); const strong = body.querySelector('strong'); const button = document.querySelector('button'); button.focus(); const selection = getSelection(); selection.selectAllChildren(strong); const rendered = owner.render({ body, html: '

Hello world.

', raw: 'Hello **world**.', messageId: '42' }); return { ...rendered, same: strong === body.querySelector('strong'), selection: selection.toString(), focused: document.activeElement === button, id: body.parentElement.dataset.dbId }; }); assert.deepEqual(result, { accepted: true, changed: false, same: true, selection: 'world', focused: true, id: '42' }); }); test('matching raw accumulator does not excuse an empty visible answer', async () => { const result = await run('
', () => { const body = document.querySelector('.body'); const rendered = owner.render({ body, html: '

Notes

', raw: 'Notes' }); return { ...rendered, visible: body.innerText }; }); assert.deepEqual(result, { accepted: true, changed: true, visible: 'Notes' }); }); test('hidden or collapsed note text is not evidence of a visible answer', async () => { for (const content of ['', 'Notes', 'Notes', '
Output

Notes

']) { const result = await run(`
${content}
`, () => { const body = document.querySelector('.body'); const before = owner.isVisible(body, '

Notes

'); owner.render({ body, html: '

Notes

', raw: 'Notes' }); return { before, after: owner.isVisible(body, '

Notes

'), visible: body.innerText }; }); assert.deepEqual(result, { before: false, after: true, visible: 'Notes' }); } }); test('structured note list renders once across final response and saved completion', async () => { const result = await run('
Raw evidence
Draft
', () => { const body = document.querySelector('.body'); const thread = document.querySelector('.agent-thread'); const button = thread.querySelector('button'); button.focus(); const html = ''; const first = owner.render({ body, html, raw: 'canonical notes' }); const list = body.firstChild; const second = owner.render({ body, html, raw: 'canonical notes', messageId: '99' }); return { first: first.changed, second: second.changed, same: list === body.firstChild, count: body.querySelectorAll('li').length, thread: thread === document.querySelector('.agent-thread'), open: thread.querySelector('.agent-thread-node').classList.contains('open'), focus: document.activeElement === button, visible: body.innerText }; }); assert.equal(result.first, true); assert.equal(result.second, false); assert.equal(result.same, true); assert.equal(result.count, 2); assert.equal(result.thread, true); assert.equal(result.open, true); assert.equal(result.focus, true); assert.match(result.visible, /First[\s\S]*Second/); }); test('same titles with missing or wrong note links must be repaired', async () => { for (const content of ['

First

', '

First

']) { const result = await run(`
${content}
`, () => { const body = document.querySelector('.body'); const result = owner.render({ body, html: '

First

', raw: '[First](#note-1)' }); return { ...result, href: body.querySelector('a').getAttribute('href') }; }); assert.deepEqual(result, { changed: true, accepted: true, href: '#note-1' }); } }); test('structured list must have list semantics, not just matching title text', async () => { const result = await run('
First
', () => { const body = document.querySelector('.body'); const rendered = owner.render({ body, html: '', raw: '- [First](#note-1)' }); return { ...rendered, items: body.querySelectorAll('ul > li').length }; }); assert.deepEqual(result, { changed: true, accepted: true, items: 1 }); }); test('a hidden turn cannot claim that its canonical answer is visible', async () => { const result = await run('', () => { const body = document.querySelector('.body'); return owner.render({ body, html: '

Notes

', raw: 'Notes' }); }); assert.deepEqual(result, { changed: true, accepted: false }); }); test('deduplicates answer bodies only, preserving preamble and tool evidence', async () => { const result = await run('
Looking up notes.

Notes

OutputNotes
', () => { owner.render({ body: document.querySelector('#final'), html: '

Notes

', raw: 'Notes' }); return { answers: [...document.querySelectorAll('.body')].map(el => el.innerText), evidence: document.querySelector('details').open }; }); assert.deepEqual(result, { answers: ['Looking up notes.', 'Notes'], evidence: true }); }); test('completion never consumes the next user turn or another stream', async () => { const result = await run('
Notes
Next question
', () => { owner.render({ body: document.querySelector('#own'), html: '

Notes

', raw: 'Notes' }); const rejected = owner.render({ body: document.querySelector('#next'), html: '

Overwrite

', raw: 'Overwrite' }); return { ...rejected, next: document.querySelector('#next').innerText }; }); assert.deepEqual(result, { changed: false, accepted: false, next: 'Notes' }); }); test('deduplication retains an earlier round’s thinking node without a second answer', async () => { const result = await run('
Reasoning

Notes

', () => { const thinking = document.querySelector('.thinking-section'); owner.render({ body: document.querySelector('#final'), html: '

Notes

', raw: 'Notes' }); return { sameThinking: thinking === document.querySelector('.thinking-section'), answers: [...document.querySelectorAll('.body p')].map(el => el.innerText) }; }); assert.deepEqual(result, { sameThinking: true, answers: ['Notes'] }); }); test('detached turn rejects late saved responses', async () => { const result = await run('
Original
', () => { const body = document.querySelector('.body'); document.querySelector('#history').firstChild.remove(); const result = owner.render({ body, html: '

Stale

', raw: 'Stale' }); return { ...result, text: body.innerText }; }); assert.deepEqual(result, { changed: false, accepted: false, text: 'Original' }); }); test('a changed saved answer patches only the answer and preserves viewport anchor', async () => { const result = await run('
Earlier history
Tools
Wrong draft
', () => { scrollTo(0, 200); const scroll = scrollY; const thread = document.querySelector('.agent-thread'); const body = document.querySelector('.body'); owner.render({ body, html: '

Corrected answer

', raw: 'Corrected answer' }); return { sameThread: thread === document.querySelector('.agent-thread'), sameBody: body === document.querySelector('.body'), sameScroll: scrollY === scroll, visible: body.innerText }; }); assert.deepEqual(result, { sameThread: true, sameBody: true, sameScroll: true, visible: 'Corrected answer' }); }); test('structured ownership replaces all turn prose but preserves other turns and tool nodes', async () => { const result = await run('
Reasoning

Draft list

Tool evidence
Different draft
Next question
', () => { const root = document.querySelector('#history'); root.insertBefore(root.firstChild, document.querySelector('#draft')); const thread = document.querySelector('.agent-thread'); const thinking = document.querySelector('.thinking-section'); const body = document.querySelector('#answer'); const event = { type: 'final_response', render_owner: 'structured' }; const accepted = owner.accepts(event); owner.render({ body, html: '', raw: 'Canonical notes', render_owner: event.render_owner }); return { accepted, text: root.innerText, sameThread: thread === document.querySelector('.agent-thread'), sameThinking: thinking === document.querySelector('.thinking-section'), visible: owner.isVisible(body, '') }; }); assert.equal(result.accepted, true); assert.equal(result.visible, true); assert.equal(result.sameThread, true); assert.equal(result.sameThinking, true); assert.doesNotMatch(result.text, /Draft list|Different draft/); assert.match(result.text, /Previous turn[\s\S]*Canonical notes[\s\S]*Next answer/); }); test('structured ownership rejects late prose regardless of delta owner and streamed final conflicts', async () => { const result = await run('
', () => { const body = document.querySelector('.body'); owner.render({ body, html: '

Canonical

', raw: 'Canonical', render_owner: 'structured' }); const paragraph = body.firstChild; const events = [ { delta: 'Late prose', render_owner: 'streamed' }, { delta: 'Late prose', render_owner: 'structured' }, { delta: 'Legacy prose' }, { type: 'final_response', content: 'Conflicting answer', render_owner: 'streamed' }, { type: 'final_response', content: 'Legacy answer' }, { type: 'agent_step', round: 2 }, { type: 'teacher_takeover' }, ]; const decisions = events.map(event => { const accepted = owner.accepts(event); if (accepted) body.append(event.delta || event.content || 'New round'); return accepted; }); const conflictingRender = owner.render({ body, html: '

Overwrite

', raw: 'Overwrite', render_owner: 'streamed' }); return { decisions, conflictingRender, same: paragraph === body.firstChild, text: body.innerText }; }); assert.deepEqual(result, { decisions: Array(7).fill(false), conflictingRender: { changed: false, accepted: false }, same: true, text: 'Canonical' }); }); test('repeated structured snapshots are idempotent and a newer snapshot can correct the answer', async () => { const result = await run('
Draft
', () => { const body = document.querySelector('.body'); const first = { body, html: '

Canonical

', raw: 'Canonical', render_owner: 'structured' }; owner.render(first); const paragraph = body.firstChild; const repeated = owner.render(first); const same = paragraph === body.firstChild; const corrected = owner.render({ ...first, html: '

Corrected

', raw: 'Corrected' }); const saved = owner.render({ body, html: '

Corrected

', raw: 'Corrected', messageId: '42' }); return { repeated, same, corrected, saved, text: body.innerText, id: body.parentElement.dataset.dbId }; }); assert.deepEqual(result, { repeated: { changed: false, accepted: true }, same: true, corrected: { changed: true, accepted: true }, saved: { changed: false, accepted: true }, text: 'Corrected', id: '42' }); }); test('streamed ownership preserves distinct rounds and structured ownership does not leak to another turn', async () => { const result = await run('
Preamble
Answer
', () => { const root = document.querySelector('#history'); const body = document.querySelector('#answer'); const streamed = owner.accepts({ delta: 'Answer', render_owner: 'streamed' }); owner.render({ body, html: '

Answer

', raw: 'Answer', render_owner: 'streamed' }); const preamble = root.innerText.includes('Preamble'); owner.render({ body, html: '

Canonical

', raw: 'Canonical', render_owner: 'structured' }); const start = document.createComment('live-stream-turn'); root.append(start); const next = createTurnRendering({ root, start }); return { streamed, preamble, old: owner.accepts({ delta: 'old' }), next: next.accepts({ delta: 'New turn', render_owner: 'streamed' }) }; }); assert.deepEqual(result, { streamed: true, preamble: true, old: false, next: true }); }); test('explicit turn replacement resumes legitimate synthesis after an intermediate structured answer', async () => { const result = await run('
Tools
', () => { const body = document.querySelector('.body'); const thread = document.querySelector('.agent-thread'); owner.render({ body, html: '

Intermediate notes

', raw: 'Intermediate notes', render_owner: 'structured' }); const first = { delta: 'Synthesis ', render_owner: 'streamed', replacement_scope: 'turn' }; const resumed = owner.accepts(first); if (resumed) { owner.render({ body, html: '', raw: '', render_owner: first.render_owner, replacement_scope: first.replacement_scope }); body.append(first.delta); } const second = { delta: 'continues', render_owner: 'streamed' }; const appended = owner.accepts(second); if (appended) body.append(second.delta); return { resumed, appended, text: body.innerText, sameThread: thread === document.querySelector('.agent-thread') }; }); assert.deepEqual(result, { resumed: true, appended: true, text: 'Synthesis continues', sameThread: true }); }); test('a scoped streamed final can replace structured content but thinking cannot transfer ownership', async () => { const result = await run('
', () => { const body = document.querySelector('.body'); owner.render({ body, html: '

Notes

', raw: 'Notes', render_owner: 'structured' }); const thinking = owner.accepts({ delta: 'Reasoning', thinking: true, render_owner: 'streamed', replacement_scope: 'turn' }); const rendered = owner.render({ body, html: '

Synthesis

', raw: 'Synthesis', render_owner: 'streamed', replacement_scope: 'turn' }); return { thinking, rendered, text: body.innerText }; }); assert.deepEqual(result, { thinking: false, rendered: { changed: true, accepted: true }, text: 'Synthesis' }); }); test('settling ordinary prose clears every turn marker without replacing streamed nodes or touching the next turn', async () => { const result = await run('

First round

tail
Tools

Final round

Next turn
', () => { const paragraphs = [...document.querySelectorAll('.body p')]; const thread = document.querySelector('.agent-thread'); owner.settle(); owner.settle(); return { same: paragraphs.every(p => p.isConnected), sameThread: thread === document.querySelector('.agent-thread'), remaining: [...document.querySelectorAll('.streaming')].map(node => node.id), prose: paragraphs.map(p => p.innerText), }; }); assert.deepEqual(result, { same: true, sameThread: true, remaining: ['next'], prose: ['First round', 'Final round'] }); }); test('structured stable render removes hidden drafts and streaming markers even when canonical DOM already matches', async () => { const result = await run('
Reasoning

Old draft

Tools

Canonical

', () => { const body = document.querySelector('#final'); const paragraph = body.firstChild; const rendered = owner.render({ body, html: '

Canonical

', raw: 'Canonical', render_owner: 'structured' }); return { rendered, same: paragraph === body.firstChild, markers: document.querySelectorAll('.streaming').length, text: document.querySelector('#history').textContent }; }); assert.deepEqual(result, { rendered: { changed: false, accepted: true }, same: true, markers: 0, text: 'ReasoningToolsCanonical' }); }); test('terminal cleanup removes transient rounds introduced after a structured snapshot', async () => { const result = await run('
Draft
', () => { const body = document.querySelector('.body'); owner.render({ body, html: '

Canonical

', raw: 'Canonical', render_owner: 'structured' }); const canonical = body.firstChild; const transient = document.createElement('div'); transient.className = 'msg msg-ai streaming'; transient.innerHTML = '
Late transient draft
'; document.querySelector('#history').append(transient); owner.settle(); return { same: canonical === body.firstChild, transient: transient.isConnected, markers: document.querySelectorAll('.streaming').length }; }); assert.deepEqual(result, { same: true, transient: false, markers: 0 }); });