pediatric-ai-scribe-v3/test/empty-answer.test.js
Daniel 94f320f140
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: an empty answer from the model was the one failure never caught
The Clinical Assistant answered "image summary" with a zero-length string, saved
that as the turn, and drew it as an empty bubble. No error was raised, nothing
was logged, and the empty turn stayed in the conversation history where it
degrades every answer after it. Confirmed by decrypting the saved chat: the
final assistant turn is content length 0.

The cause is one line. finalizeAssistantAnswer only regenerates an answer that
shouldRegenerateTruncatedAnswer flags, and that function opens with
`if (!answer) return false` — there is no dangling conjunction to detect in a
zero-length string, so empty was classified as "not truncated" and returned as a
result. Every other failure mode had a path; this one had none.

An empty answer is now asked for once more — a model returning nothing is
usually transient — and if it comes back empty again it raises 502
`empty_answer`, which both callers already turn into a visible error. Whitespace
counts as empty, and so does an answer that strips to nothing. Without a callAI
to retry with it still raises rather than returning empty.

Verified against a mutation: removing the new branch fails five of the six
tests.

This is separate from the image question in the same request. No image job was
created, because "image summary" does not match the text fallback pattern —
which requires a verb (create/generate/draw/…) before the noun — and the model
did not call the tool. That is left alone for now: the request is genuinely
ambiguous, and guessing at it is how an assistant starts making pictures nobody
asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 07:41:04 +02:00

70 lines
2.7 KiB
JavaScript

// An empty answer from the model is a failure. It used to be the only failure
// that was never caught: the truncation check returns false for a zero-length
// string, so nothing regenerated it and nothing raised — the empty turn was
// saved into the chat and drawn as an empty bubble, then stayed in the history
// where it degrades every answer after it.
const test = require('node:test');
const assert = require('node:assert/strict');
const { finalizeAssistantAnswer } = require('../src/utils/clinicalAnswer');
function options(replies) {
const calls = [];
const queue = replies.slice();
return {
calls,
messages: [{ role: 'user', content: 'q' }],
chatModel: 'synthetic',
generationOptions: {},
callAI: async (messages, opts) => {
calls.push({ messages, opts });
return { content: queue.length > 1 ? queue.shift() : queue[0], model: 'synthetic' };
}
};
}
test('an empty answer is asked for once more rather than returned', async () => {
const o = options(['A real answer about croup.']);
const out = await finalizeAssistantAnswer({ content: '' }, o);
assert.equal(o.calls.length, 1, 'one retry, not none and not a loop');
assert.equal(out.answer, 'A real answer about croup.');
});
test('an answer that is empty twice raises instead of being presented as one', async () => {
const o = options(['']);
await assert.rejects(
() => finalizeAssistantAnswer({ content: '' }, o),
err => {
assert.equal(err.code, 'empty_answer');
assert.equal(err.statusCode, 502);
assert.match(err.message, /empty answer/i);
return true;
});
assert.equal(o.calls.length, 1, 'it does not keep paying for the same empty reply');
});
test('whitespace-only counts as empty — it renders as an empty bubble just the same', async () => {
const o = options([' \n\n ']);
await assert.rejects(() => finalizeAssistantAnswer({ content: '\n \t' }, o),
err => err.code === 'empty_answer');
});
test('an answer that survives stripping is returned untouched and costs no retry', async () => {
const o = options(['should not be called']);
const out = await finalizeAssistantAnswer({ content: 'Croup is a viral illness.' }, o);
assert.equal(out.answer, 'Croup is a viral illness.');
assert.equal(o.calls.length, 0);
});
test('the caller is told to try again, because a repeat usually works', async () => {
const o = options(['']);
await assert.rejects(() => finalizeAssistantAnswer({ content: '' }, o),
err => /try asking again|rephrase/i.test(err.message));
});
test('with no callAI to retry with, an empty answer still raises rather than returning empty', async () => {
await assert.rejects(
() => finalizeAssistantAnswer({ content: '' }, { messages: [] }),
err => err.code === 'empty_answer');
});