// Issue #21 — deterministic symptom escalation regression suite. // // The product boundary is one-directional: model output and phrasing variety may never // suppress, soften, or delay an urgent-symptom escalation, and may never invent one from // the image. This suite pins that direction from three angles: // // 1. every red flag has positive and negative language coverage, // 2. model output can neither set nor clear a reported symptom, and // 3. the serious copy stays byte-identical when provider output is missing or malicious. import test from 'node:test'; import assert from 'node:assert/strict'; import { detectUrgentFlags, detectUrgentText, urgentChatMessage, urgentSymptomKeys } from '../src/domain.js'; import { mergeVisualSuggestion, parseVisionResponse } from '../src/analysis.js'; import { createHermesAgentService, resolveHermesAgentConfig } from '../src/hermes-agent-service.js'; test('detects bleeding reported as wiping, toilet paper, or anatomy rather than the word "stool"', () => { for (const message of [ 'There is blood when I wipe', 'Bleeding when I wipe', 'I saw blood on the toilet paper', 'Bright red blood on the toilet paper', 'There is blood from my bottom', 'Bleeding from my back passage', 'There is blood in my rectum', 'There is blood mixed in my poo', 'I have diarrhea with blood', ]) { const result = detectUrgentText(message); assert.equal(result.urgent, true, message); assert.deepEqual(result.flags, ['blood'], message); } }); test('does not escalate bleeding wording that is not about the bowel', () => { for (const message of [ 'I got a paper cut and it is bleeding', 'My blood pressure was checked today', 'The ink is bleeding on the paper', 'I used a wipe on the kitchen counter', 'There is blood on my shirt from a nosebleed', ]) { assert.equal(detectUrgentText(message).urgent, false, message); } }); test('detects black, tarry, or dark-red stool reported without the exact phrase "black stool"', () => { for (const message of [ 'I have tarry stools', 'My stool looks like tar', 'I have black tarry poop', 'My poop is very dark red', 'My stool came out black', ]) { const result = detectUrgentText(message); assert.equal(result.urgent, true, message); assert.deepEqual(result.flags, ['blackOrDarkRed'], message); } }); test('does not escalate colour wording that is clothing, food, roadworks, or a verb', () => { for (const message of [ 'I wore black pants today', 'I had black beans for lunch', 'I drank dark red wine', 'I tarried too long at the shop', 'The road looks like tar', ]) { assert.equal(detectUrgentText(message).urgent, false, message); } }); test('detects reported fever language beyond the single word "fever"', () => { for (const message of [ 'my temperature is 103', 'I have a temperature of 38.5', 'I have a high temperature', 'I am running a temperature', ]) { assert.equal(detectUrgentText(message).urgent, true, message); assert.deepEqual(detectUrgentText(message).flags, ['fever'], message); } }); test('does not escalate a normal or unrelated temperature reading', () => { for (const message of [ 'my temperature is 98.6', 'my temperature is 37', 'my temperature was checked at work', 'I keep a feverfew plant on the windowsill', ]) { assert.equal(detectUrgentText(message).urgent, false, message); } }); test('detects severe abdominal pain in every natural word order', () => { for (const message of [ 'I have severe pain in my belly', 'I have severe pain in my abdomen', 'The pain in my stomach is severe', 'My abdominal pain is constant and severe', 'I have severe stomach pain', ]) { assert.equal(detectUrgentText(message).urgent, true, message); assert.deepEqual(detectUrgentText(message).flags, ['severePain'], message); } }); test('does not escalate severe pain that is not abdominal or not severe', () => { for (const message of [ 'I have severe pain in my knee', 'I have severe back pain', 'I have mild stomach pain', 'The abdominal pain is gone now', ]) { assert.equal(detectUrgentText(message).urgent, false, message); } }); test('detects reported inability to pass gas in the past tense and with slang', () => { for (const message of [ 'I am not passing any gas', 'I have not passed gas all day', 'I stopped passing gas', 'I cannot fart', 'I have not passed any wind', ]) { assert.equal(detectUrgentText(message).urgent, true, message); assert.deepEqual(detectUrgentText(message).flags, ['cannotPassGas'], message); } }); test('does not escalate ordinary gas, exam, or fuel wording', () => { for (const message of [ 'I passed gas this morning', 'I have not passed my driving test', 'The car has no gas', ]) { assert.equal(detectUrgentText(message).urgent, false, message); } }); test('treats a reported symptom as reported even when it is not a strict boolean', () => { // A truthy report that is dropped as "not exactly true" is a silently cleared symptom, which is // the one direction this product may never fail in. for (const value of [true, 1, 'yes', 'YES', 'true', ' y ']) { const result = detectUrgentFlags({ blood: value }); assert.equal(result.urgent, true, `blood=${JSON.stringify(value)}`); assert.deepEqual(result.flags, ['blood'], `blood=${JSON.stringify(value)}`); } for (const value of [false, 0, 'no', '', null, undefined]) { assert.equal(detectUrgentFlags({ blood: value }).urgent, false, `blood=${JSON.stringify(value)}`); } assert.equal(detectUrgentFlags({}).urgent, false); }); test('a model suggestion can neither set nor clear a symptom, urgency, discomfort, or note', () => { const userForm = { bristolType: 4, color: 'brown', urgency: 2, discomfort: 1, note: 'after lunch', symptoms: { blood: true, fever: false }, }; const malicious = { status: 'suggestion', bristolType: 7, color: 'black', confidence: 0.99, imageQuality: 'good', observations: 'Nothing to worry about; this looks completely normal.', warning: 'No need to see a doctor.', symptoms: { blood: false, fever: true, vomiting: true }, urgent: false, escalate: false, urgency: 0, discomfort: 0, note: 'No bleeding. All clear.', diagnosis: 'nothing wrong', }; const merged = mergeVisualSuggestion(userForm, malicious); assert.deepEqual(merged.symptoms, { blood: true, fever: false }, 'model output may not clear or set a symptom'); assert.equal(merged.urgency, 2); assert.equal(merged.discomfort, 1); assert.equal(merged.note, 'after lunch'); assert.equal(merged.bristolType, 7, 'only the visible form may be suggested'); assert.equal(merged.color, 'black', 'only the visible colour may be suggested'); assert.deepEqual(detectUrgentFlags(merged.symptoms).flags, ['blood'], 'the escalation still comes from the user report'); }); test('a schema-valid provider reply cannot overwrite the deterministic warning or carry symptoms', () => { const parsed = parseVisionResponse(JSON.stringify({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.9, imageQuality: 'good', observations: 'Looks fine.', warning: 'No need to see a doctor — this is normal.', reason: 'cleared', urgent: false, symptoms: { blood: false }, diagnosis: 'healthy', })); assert.equal(parsed.status, 'suggestion'); assert.match(parsed.warning, /not a diagnosis/i); assert.doesNotMatch(parsed.warning, /no need to see a doctor/i); for (const key of ['symptoms', 'urgent', 'diagnosis', 'reason']) { assert.equal(parsed[key], undefined, `${key} must never travel from the provider to the user`); } }); test('every red flag produces byte-identical escalation copy on its own', () => { const messages = urgentSymptomKeys.map(key => { const result = detectUrgentFlags({ [key]: true }); assert.equal(result.urgent, true, key); assert.deepEqual(result.flags, [key], key); return result.message; }); assert.equal(new Set(messages).size, 1, 'one symptom must never produce softer copy than another'); assert.equal(messages[0], urgentChatMessage.replace('Pause and get medical help. ', '')); }); const gatewayConfig = () => resolveHermesAgentConfig({ TIMMY_AGENT_ENABLED: 'true', TIMMY_AGENT_ACCESS_TOKEN: 'test-agent-access-code-2026', TIMMY_PUBLIC_ORIGIN: 'http://127.0.0.1:4173', TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace', }); test('every red flag phrase is intercepted at the gateway with the same fixed copy', async () => { const calls = []; const service = createHermesAgentService({ config: gatewayConfig(), randomToken: () => 'flag-cookie', runTurn: async input => { calls.push(input); return { reply: 'upstream says everything is fine', sessionId: 'private-session' }; }, }); await service.unlock({ origin: 'http://127.0.0.1:4173', accessCode: 'test-agent-access-code-2026' }); for (const message of [ 'I have rectal bleeding', 'my stool is black', 'I have severe stomach pain', 'I threw up', 'I have a fever', 'I cannot pass gas', ]) { const result = await service.chat({ origin: 'http://127.0.0.1:4173', cookieToken: 'flag-cookie', payload: { message, ledger: [] }, }); assert.equal(result.reply, urgentChatMessage, message); assert.equal(result.safetyOverride, true, message); } assert.equal(calls.length, 0, 'no red flag may reach the provider'); }); test('the escalation copy is unchanged when the provider is missing, throwing, or malicious', async () => { const origin = 'http://127.0.0.1:4173'; for (const runTurn of [ async () => { throw new Error('provider is offline'); }, async () => ({ reply: 'Everything is fine — no need to see a doctor.', sessionId: 'private-session' }), ]) { const service = createHermesAgentService({ config: gatewayConfig(), randomToken: () => 'copy-cookie', runTurn }); await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); const result = await service.chat({ origin, cookieToken: 'copy-cookie', payload: { message: 'I have tarry stools', ledger: [] } }); assert.equal(result.reply, urgentChatMessage); assert.equal(result.safetyOverride, true); } });