// Row 225 (C2-10b, design 7.4): replies, cites, write notices and the library status line in the // Console chat. Fixture only: the chat and library answers are stubbed; /render.mjs is the real one. import test from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { fixture, close } from './fixture.mjs'; import { browser } from './browser.mjs'; const P = '10000000-0000-4000-8000-000000000001'; const A = '20000000-0000-4000-8000-000000000002'; const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40), C3 = 'c'.repeat(40), C4 = 'd'.repeat(40); const HOSTILE = ''; const cite = (path, lines, quote, commit = C1, extra = {}) => ({ path, commit, lines, quote, ...extra }); const reply = (text, citations) => JSON.stringify({ text, citations }); const row = (id, body, check, from = 'assistant') => ({ id: `5000000${id}-0000-4000-8000-000000000000`, from_role: from, at: '2026-10-23T18:00:00.000Z', body, ...(check === undefined ? {} : { check }) }); const ROWS = [ row(1, 'Plain text answer.', undefined, 'jason'), row(2, reply("Mara's eyes are grey.", [ cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', C1, { as: 'canon' }), cite('Book One/Playground/What If.md', [5, 6], 'a storm'), cite('Notes/Eyes.md', [9, 10], 'green eyes'), { url: 'https://example.invalid/tides', quote: 'spring tides' }, { url: 'http://example.invalid/plain', quote: 'plain http' }, { url: 'javascript:window.injected=2', quote: 'script' }, ]), { citations: [ { scope: 'canon', agent: null, file: C1, last: C1 }, { scope: 'playground', agent: null, file: C2, last: C3 }, { scope: 'supporting', agent: 'acme/scribe', file: C2 }, { scope: 'web', agent: null }, { scope: 'web', agent: null }, { scope: 'web', agent: null }, ] }), row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])), row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'), row(5, JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }), { citations: [] }, 'forger'), ]; const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO', lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' }, problems: [{ code: 'R1', path: 'Notes/Eyes.md', message: 'no heading', line: 3 }, { code: 'R2', path: HOSTILE, message: 'bad name' }] }; const stub = `(() => { const real = fetch.bind(window); window.__reverts=[];window.__revertAnswers=[];window.__statusReads=[];window.__confirms=[]; window.__status={status:200,body:${JSON.stringify(STATUS)}}; window.confirm=text=>{window.__confirms.push(text);return window.__confirm!==false;}; window.fetch=async (url,init={})=>{ const path=String(url), method=init.method||'GET'; const answer=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'content-type':'application/json'}}); if(path==='/api/config')return answer({send:!location.search.includes('readonly')}); if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()}); if(path==='/api/business')return answer({projects:['books']}); if(path.startsWith('/api/library/status')){ window.__statusReads.push(path); const body={...window.__status.body}; if(body.lastFetch==='FIVE-MINUTES-AGO')body.lastFetch=new Date(Date.now()-330000).toISOString(); return answer(body,window.__status.status); } if(path==='/api/library/revert'){ window.__reverts.push({method,body:JSON.parse(init.body),headers:init.headers,redirect:init.redirect}); const a=window.__revertAnswers.shift();if(!a)throw new Error('No revert answer'); if(a.reject)throw new TypeError('network'); return answer(a.body,a.status||200); } if(!path.startsWith('/api/chat'))return real(url,init); const data=value=>answer({...value,at:new Date().toISOString()}); if(path==='/api/chat/instances')return data({rows:[{instance:'assistant',name:'assistant'}]}); if(path==='/api/chat/projects')return data({rows:[{id:'${P}',name:'Books',repo:window.__repo===undefined?'books':window.__repo}]}); if(path.endsWith('/sessions'))return data({rows:[{id:'${A}',name:'default',default:true}]}); if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}]}); return data({rows:${JSON.stringify(ROWS)},after:null,hasMore:false}); }; })();`; async function setup(t, { suffix = '', before = '' } = {}) { const f = await fixture(); t.after(() => f.close()); const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server)); const b = await browser(); t.after(() => b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + before }); await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`); const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`); const message = n => `.chat-log > li:nth-child(${n})`; await wait('document.querySelector(".chat-cites")'); return { b, wait, text, message }; } test('chat replies: free text, cites with badges, the agent label, changed since and safe links', async t => { const { b, text, message } = await setup(t); assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.'); assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey."); const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({ badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent, href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null, quote: li.querySelector('q').textContent, agent: li.querySelector('.chat-agent-written')?.textContent ?? null, changed: !!li.querySelector('.chat-changed') }))`); assert.deepEqual(cites, [ { badge: 'canon', ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', href: null, rel: null, quote: 'her grey eyes', agent: null, changed: false }, { badge: 'playground', ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa', href: null, rel: null, quote: 'a storm', agent: null, changed: true }, // `last` absent: nothing is marked changed (sage-3596). { badge: 'supporting', ref: 'Notes/Eyes.md:9-10 @aaaaaaa', href: null, rel: null, quote: 'green eyes', agent: 'agent-written: acme/scribe', changed: false }, { badge: 'web', ref: 'https://example.invalid/tides', href: 'https://example.invalid/tides', rel: 'noopener noreferrer', quote: 'spring tides', agent: null, changed: false }, { badge: 'web', ref: 'http://example.invalid/plain', href: null, rel: null, quote: 'plain http', agent: null, changed: false }, { badge: 'web', ref: 'javascript:window.injected=2', href: null, rel: null, quote: 'script', agent: null, changed: false }, ]); // A reply with no check result: no badge, no label, never changed; its quote is inert text. assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(3) + ' .chat-scope, ' + message(3) + ' .chat-label')}).length`), 0); assert.equal(await text(`${message(3)} q`), HOSTILE); assert.equal(await b.evaluate('!!(document.querySelector(".chat-log img") || window.injected)'), false); }); test('chat replies: a write notice reads as its line with a Revert; a refused revert shows its code', async t => { const { b, wait, text, message } = await setup(t); assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd'); assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).dataset.revert`), C4); assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1, 'only the notice has a Revert'); // Cancelled at the confirm: nothing is sent. await b.evaluate('window.__confirm=false;document.querySelector("[data-revert]").click();true'); assert.equal(await b.evaluate('window.__reverts.length'), 0); assert.match(await b.evaluate('window.__confirms[0]'), /^Revert ddddddd\?/); // Before C2-02 the bus refuses the verb; the page shows that code and doesn't retry. await b.evaluate(`window.__confirm=true;window.__revertAnswers.push({status:502,body:{error:'unknown-verb',message:'not reverted: the bus refused it (unknown-verb).',sent:false}});document.querySelector("[data-revert]").click();true`); await wait('document.querySelector(".chat-revert-receipt").textContent.startsWith("Not")'); assert.equal(await text('.chat-revert-receipt'), 'Not reverted (unknown-verb).'); assert.deepEqual(await b.evaluate('window.__reverts'), [{ method: 'POST', body: { project: 'books', commit: C4 }, headers: { 'content-type': 'application/json' }, redirect: 'manual' }]); for (const [answer, receipt] of [ [{ status: 409, body: { error: 'library-revert-not-agent', sent: false } }, 'Not reverted (library-revert-not-agent).'], [{ status: 409, body: { error: 'stale-base', sent: false } }, 'Not reverted (stale-base).'], [{ status: 503, body: { error: 'outcome-unknown', sent: 'unknown' } }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'], [{ reject: true }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'], [{ body: { project: 'books', path: 'Notes/Eyes.md', reverted: C4, commit: C1 } }, 'Reverted in aaaaaaa.'], ]) { const n = await b.evaluate('window.__reverts.length'); await b.evaluate(`window.__revertAnswers.push(${JSON.stringify(answer)});document.querySelector("[data-revert]").click();true`); await wait(`window.__reverts.length===${n + 1} && !document.querySelector("[data-revert]").disabled`); assert.equal(await text('.chat-revert-receipt'), receipt); } assert.ok(await b.evaluate('window.__statusReads.length') >= 2, 'the status line is read again after a revert'); }); test('chat replies: a read-only Console shows the notice with Revert disabled', async t => { const { b, message } = await setup(t, { suffix: '?readonly' }); assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).disabled`), true); await b.evaluate('document.querySelector("[data-revert]").click();true'); assert.equal(await b.evaluate('window.__reverts.length + window.__confirms.length'), 0); }); test('chat status line: served commit and age, divergence, fetch error and the problems one click away', async t => { const { b, wait, text } = await setup(t); await wait('document.querySelector(".chat-library")'); assert.equal(await b.evaluate('window.__statusReads[0]'), '/api/library/status?project=books'); assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · library-diverged at bbbbbbb · fetch error library-fetch-failed'); assert.equal(await text('.chat-library-problems summary'), '2 problems'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".chat-library-problems li")].map(li => li.textContent)'), ['R1 Notes/Eyes.md:3 no heading', `R2 ${HOSTILE} bad name`]); assert.equal(await b.evaluate('!!document.querySelector(".chat-library img")'), false); }); test('chat status line: limits when the status carries them, no problems, and refusals', async t => { const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: true, citationsPerReply: 4, fallback: ['citationsPerReply'], problem: null } }; const { b, wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` }); await wait('document.querySelector(".chat-library")'); assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems'); assert.equal(await b.evaluate('!!document.querySelector(".chat-library-problems")'), false); for (const [status, error, shown] of [[502, 'library-state-invalid', 'Library status unavailable (library-state-invalid).'], [502, 'Not A Code', 'Library status unavailable (invalid-response).'], [403, 'owner-only', null], [502, 'project-not-declared', null], [502, 'library-mapping-invalid', 'Library status unavailable (library-mapping-invalid).'], [503, 'project-not-configured', null], [503, 'not-configured', null]]) { await wait('!document.querySelector("#refresh").disabled'); await b.evaluate(`window.__status={status:${status},body:{error:${JSON.stringify(error)}}};document.querySelector("#refresh").click();true`); await wait(`window.__statusReads.length && document.querySelector(".chat-conversation") && ${shown === null ? '!document.querySelector(".chat-library")' : `document.querySelector(".chat-library")?.textContent===${JSON.stringify(shown)}`}`); } }); test('chat status line: a project with no repository link reads no library status', async t => { const { b } = await setup(t, { before: 'window.__repo=null;' }); assert.equal(await b.evaluate('window.__statusReads.length'), 0); assert.equal(await b.evaluate('!!document.querySelector(".chat-library")'), false); }); // Removing the nested problem display would hide the reason default limits are in force. test('chat status line: unreadable reply limits show their problem and both fallback keys', async t => { const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: false, citationsPerReply: 8, fallback: ['requireCitation', 'citationsPerReply'], problem: 'reply-limits-unreadable' } }; const { wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` }); await wait('document.querySelector(".chat-library")'); assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems'); }); // Comparing commit to last would incorrectly mark the first cite; trusting missing file would // incorrectly mark the second. The third ensures malformed last never becomes a changed marker. test('chat replies: unchanged file at a different library commit and incomplete checks stay unmarked', async t => { const { b, wait } = await setup(t); await b.evaluate(`window.__checkOverride={citations:[{scope:'canon',agent:null,file:'${C2}',last:'${C2}'}, {scope:'playground',agent:null,last:'${C3}'}, {scope:'supporting',agent:null,file:'${C2}',last:'bad'}]};true`); await b.evaluate(`const old=window.fetch;window.fetch=async(url,init)=>{const res=await old(url,init); if(!String(url).includes('/messages'))return res; const data=await res.json();data.rows[1].check=window.__checkOverride; return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}}); };true`); await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`); assert.equal(await b.evaluate('document.querySelectorAll(".chat-changed").length'), 0); }); test('library age changes without replacing the conversation', async t => { const { b, wait } = await setup(t); await b.evaluate(`window.__status.body.lastFetch=new Date(Date.now()-330000).toISOString();MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`); await wait(`window.__statusReads.length>=2`); await b.evaluate(`(()=>{const field=document.querySelector('#chat-body');field.focus();field.value='draft';field.dispatchEvent(new Event('input',{bubbles:true}));field.setSelectionRange(2,2);window.savedReplyField=field;window.savedReplyMessage=document.querySelector('[data-message]');const now=Date.now();Date.now=()=>now+120000;})()`); await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`); await wait(`document.querySelector('.chat-library')?.textContent.includes('7 min ago')`); assert.equal(await b.evaluate(`savedReplyMessage.isConnected && savedReplyField.isConnected && document.activeElement===savedReplyField && savedReplyField.selectionStart===2`), true); }); // Invalid configuration is a diagnostic, unlike an ordinary project with no library mapping. test('chat status line: an invalid library mapping remains visible for diagnosis', async t => { const { b, wait, text } = await setup(t, { before: "window.__status={status:502,body:{error:'library-mapping-invalid'}};" }); await wait('window.__statusReads.length===1'); assert.equal(await text('.chat-library [role=status]'), 'Library status unavailable (library-mapping-invalid).'); assert.equal(await b.evaluate('window.__reverts.length'), 0); }); // A guard-checked agent send cannot impersonate a broker-inserted write notice, even if it // names another agent's reachable commit and invents a different path (dewey-0937 B1). test('chat replies: a checked notice-shaped agent message is plain text with no Revert', async t => { const { b, text, message } = await setup(t); assert.equal(await text(`${message(5)} .chat-body`), JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 })); assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(5) + ' [data-revert], ' + message(5) + ' .chat-notice')}).length`), 0); assert.equal(await b.evaluate('window.__reverts.length'), 0); assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd', 'the unchecked broker notice still renders'); }); // The granted-user fence decides owner-only before calling any library transport (N1). test('chat replies: owner-only without a sent field is a definite Revert refusal', async t => { const { b, wait, text } = await setup(t); await b.evaluate(`window.__revertAnswers.push({status:403,body:{error:'owner-only'}});document.querySelector('[data-revert]').click();true`); await wait('!document.querySelector("[data-revert]").disabled'); assert.equal(await text('.chat-revert-receipt'), 'Not reverted (owner-only).'); assert.equal(await b.evaluate('window.__reverts.length'), 1); });