diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index ba0179832..435d8fa2d 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -11,0 +12,2 @@ +// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof +// or transport change. @@ -32 +34 @@ const baselines = { - "packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c", + "packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658", diff --git a/packages/library/tests/render.test.mjs b/packages/library/tests/render.test.mjs index b07e26d25..9c861bfe9 100644 --- a/packages/library/tests/render.test.mjs +++ b/packages/library/tests/render.test.mjs @@ -3 +3 @@ import assert from 'node:assert/strict'; -import { renderItems, UNREADABLE } from '../src/index.mjs'; +import { renderReply } from '../src/index.mjs'; @@ -4,0 +5 @@ import { renderItems, UNREADABLE } from '../src/index.mjs'; +// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2). @@ -6,6 +7,12 @@ const A = 'a'.repeat(40), B = 'b'.repeat(40); -const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote }); -const options = { - scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined, - changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A, -}; -const render = items => renderItems(JSON.stringify({ items }), options); +const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra }); +const web = (url, quote = 'spring tides') => ({ url, quote }); +const reply = (text, citations) => JSON.stringify({ text, citations }); +const CITED = reply("Mara's eyes are grey in chapter 3.", [ + cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }), + cite('Book One/Playground/What If.md', [5, 6], 'a storm', B), + cite('Notes/Eyes.md', [9, 10], 'green eyes'), + web('https://example.invalid/tides'), +]); +const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null }, + { scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] }; +const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A; @@ -13,21 +20,4 @@ const render = items => renderItems(JSON.stringify({ items }), options); -// One snapshot per item kind: the plain text both channels show. -const SNAPSHOTS = { - fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }], - finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'), - cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }], - correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes', - cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }], - question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }], - idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B), - { url: 'https://example.invalid/tides', quote: 'spring tides' }] }], -}; -const EXPECTED = { - fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"', - finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n' - + ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)', - correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n' - + ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"', - question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"', - idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n' - + ' [web] https://example.invalid/tides "spring tides"', -}; +test('free text renders as itself with no citations', () => { + for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null']) + assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }); +}); @@ -35,8 +25,15 @@ const EXPECTED = { -for (const kind of Object.keys(SNAPSHOTS)) { - test(`renderItems snapshot: ${kind}`, () => { - const out = render(SNAPSHOTS[kind]); - assert.equal(out.kind, 'items'); - assert.equal(out.text, EXPECTED[kind]); - assert.equal(out.items[0].kind, kind); - }); -} +test('a cited reply renders its text, then each cite with the badge from the check result', () => { + const out = renderReply(CITED, { check: CHECK, changed }); + assert.equal(out.kind, 'reply'); + assert.equal(out.text, "Mara's eyes are grey in chapter 3."); + assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']); + assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n" + + ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n' + + ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n' + + ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n' + + ' [web] https://example.invalid/tides "spring tides"'); + assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41], + ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false }); + assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides', + ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false }); +}); @@ -44,6 +41,2 @@ for (const kind of Object.keys(SNAPSHOTS)) { -test('renderItems numbers several items and takes a parsed body as well as a string', () => { - const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question]; - const out = renderItems({ items }, options); - assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`); - assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4], - ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false }); +test('a parsed body renders as its string does', () => { + assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed })); @@ -52,4 +45,4 @@ test('renderItems numbers several items and takes a parsed body as well as a str -test('without options a path cite has no badge and is never marked changed', () => { - assert.equal(renderItems({ items: SNAPSHOTS.finding }).text, - '1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n' - + ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"'); +test('without a check result a path cite has no badge, no label and is never changed', () => { + const out = renderReply(CITED); + assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]), + [[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]); @@ -58,6 +51,6 @@ test('without options a path cite has no badge and is never marked changed', () -const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' }; -for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) { - test(`renderItems snapshot: notice ${notice}`, () => { - assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text }); - }); -} +test('the label and the badge come from the check result only, never from a bad entry', () => { + const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] }; + const out = renderReply(CITED, { check: bad }); + // A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written. + assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]); +}); @@ -65,14 +58,8 @@ for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) { -test('a body without the reply shape shows a fixed line and none of its text', () => { - const secret = 'free prose the guard would refuse'; - const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }), - JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }), - JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }), - JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }), - null, 42, '[]']; - for (const body of bodies) { - const out = renderItems(body, options); - assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body)); - assert.ok(!JSON.stringify(out).includes(secret)); +test('a web cite is a link only for https', () => { + const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null, + 'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null }; + for (const [url, link] of Object.entries(urls)) { + const out = renderReply(reply('t', [web(url)])); + assert.equal(out.kind, 'reply', url); + assert.equal(out.citations[0].link, link, url); + assert.equal(out.citations[0].ref, url); @@ -80,0 +68,27 @@ test('a body without the reply shape shows a fixed line and none of its text', ( + +test('a write notice renders its fixed line', () => { + const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B }); + assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }), + { kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' }); + assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb'); + assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb'); +}); + +test('a body without the reply or notice shape is free text: the whole body, no citations', () => { + const bodies = [ + JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }), + JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }), + reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]), + reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]), + reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]), + reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]), + JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }), + JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }), + JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }), + ]; + for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body); +}); + +test('an empty citations list is a reply with no cites', () => { + assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' }); +});