import test from 'node:test'; import assert from 'node:assert/strict'; import { answerFromLedger, buildChatLedger, resolveAgentReply } from '../src/grounded-chat.js'; const DAY = 86400000; const NOW = Date.parse('2026-09-20T12:00:00.000Z'); const entry = (id, daysAgo, bristolType, extra = {}) => ({ id, occurredAt: new Date(NOW - daysAgo * DAY).toISOString(), bristolType, color: 'brown', urgency: 1, discomfort: 1, note: '', photoDataUrl: '', symptoms: {}, ...extra, }); test('a frequency question answers with the count from the window and cites exactly those logs', () => { const answer = answerFromLedger( 'How many times have I logged in the last four weeks?', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6), entry('d', 12, 4), entry('old', 40, 2)], { now: NOW }, ); assert.equal(answer.kind, 'frequency'); assert.equal(answer.grounded, true); assert.match(answer.text, /4 confirmed logs/); assert.match(answer.text, /last 28 days/); assert.deepEqual(answer.citations.map(citation => citation.id).sort(), ['a', 'b', 'c', 'd']); // The out-of-window log is never counted and never offered as a source. assert.equal(answer.citations.some(citation => citation.id === 'old'), false); }); test('a recency question cites the one log it names, even when the ledger is out of order', () => { const answer = answerFromLedger( 'When did I last log something?', [entry('middle', 6, 5), entry('newest', 1, 3), entry('older', 11, 2)], { now: NOW }, ); assert.equal(answer.kind, 'latest'); assert.equal(answer.grounded, true); assert.equal(answer.citations.length, 1); assert.equal(answer.citations[0].id, 'newest'); assert.equal(answer.citations[0].bristolType, 3); assert.match(answer.text, /most recent confirmed log/i); assert.match(answer.text, /Type 3/); }); test('a question the journal cannot answer refuses instead of reassuring', () => { const answer = answerFromLedger( 'Is it safe to eat tacos with this?', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)], { now: NOW }, ); assert.equal(answer.kind, 'unsupported'); assert.equal(answer.grounded, false); assert.deepEqual(answer.citations, []); assert.match(answer.text, /cannot (clear|decide|tell)/i); // Boundary copy may not invent a verdict about health or food. assert.doesNotMatch(answer.text, /safe to eat|it is fine|you should eat|diagnos/i); }); test('a pattern question on a sparse window refuses to describe a trend', () => { const answer = answerFromLedger( 'What pattern do you see?', [entry('a', 2, 4), entry('b', 5, 6)], { now: NOW }, ); assert.equal(answer.kind, 'insufficient'); assert.equal(answer.grounded, false); assert.deepEqual(answer.citations, []); assert.match(answer.text, /not enough confirmed logs/i); assert.match(answer.text, /2 of 3/); }); test('a pattern question cites every confirmed log the described pattern rests on', () => { const answer = answerFromLedger( 'What pattern do you see?', [entry('a', 2, 1), entry('b', 5, 4), entry('c', 9, 4), entry('d', 12, 4)], { now: NOW }, ); assert.equal(answer.kind, 'pattern'); assert.equal(answer.grounded, true); assert.match(answer.text, /4 confirmed logs/); assert.match(answer.text, /Firm side \(Type 1–2\): 1 of 4/); assert.deepEqual(answer.citations.map(citation => citation.id).sort(), ['a', 'b', 'c', 'd']); }); test('a Hermes reply resolves the record references it cites and never shows the raw marker', () => { const { chatRecords } = buildChatLedger([entry('a', 2, 4), entry('b', 5, 3)], { now: NOW }); const resolved = resolveAgentReply( 'Your newest Type 4 log is the one from this week [entry:L1], and the Type 3 before it [entry:L2].', chatRecords, ); assert.equal(resolved.text.includes('[entry:'), false); assert.match(resolved.text, /newest Type 4 log/); assert.deepEqual(resolved.citations.map(citation => citation.id), ['a', 'b']); assert.equal(resolved.unverified, false); }); test('a citation to a record that is not in the ledger is dropped instead of being shown as a source', () => { const { chatRecords } = buildChatLedger([entry('a', 2, 4)], { now: NOW }); const resolved = resolveAgentReply('You had 9 logs last month [entry:L99].', chatRecords); assert.equal(resolved.text.includes('[entry:'), false); assert.deepEqual(resolved.citations, []); assert.equal(resolved.unresolvedRefs, 1); assert.equal(resolved.unverified, true); }); test('an uncited claim about the journal is flagged as unverified rather than presented as fact', () => { const { chatRecords } = buildChatLedger([entry('a', 2, 4), entry('b', 5, 3)], { now: NOW }); const resolved = resolveAgentReply('You logged 7 times this week and you are improving.', chatRecords); assert.equal(resolved.unverified, true); assert.deepEqual(resolved.citations, []); assert.match(resolved.notice, /not verified against your journal/i); }); test('a privacy question answers with the stored-data boundary instead of the journal summary', () => { const answer = answerFromLedger('What happens to my photo?', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)], { now: NOW }); assert.equal(answer.kind, 'privacy'); assert.equal(answer.grounded, false); assert.deepEqual(answer.citations, []); assert.match(answer.text, /browser/i); assert.match(answer.text, /never receives photos/i); }); test('an unmatched question still gets a grounded answer instead of an empty bubble', () => { const answer = answerFromLedger('hello', [entry('a', 2, 4), entry('b', 5, 3), entry('c', 9, 6)], { now: NOW }); assert.equal(answer.kind, 'summary'); assert.equal(answer.grounded, true); assert.ok(answer.text.length > 20, 'a general question must not produce an empty reply'); assert.equal(answer.citations.length, 3); const empty = answerFromLedger('hello', [], { now: NOW }); assert.equal(empty.grounded, false); assert.deepEqual(empty.citations, []); assert.match(empty.text, /no confirmed logs|not enough/i); });