pediatric-ai-scribe-v3/test/assistant-streaming-blocks.test.js
Daniel cb5bce13e3
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 16s
fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale
The tail — the block still arriving — was shown as raw text until its blank
line came, which is the flash of asterisks, pipes and brackets the reader
saw on every paragraph. It is now parsed each frame like the finished part
(Open WebUI never shows the flash because it re-renders the whole message
per token; ours re-renders one block). Two exceptions stay text: a block
inside an unclosed code fence, so a half-written diagram is not handed to
its renderer every frame, and a table header with no body row yet.

JS, CSS and component HTML were cached for an hour, and the assistant's ES
module imports carry no version query, so every open browser kept the old
citation renderer for an hour after the deploy that replaced it. They now
revalidate on every load (no-cache with the ETag), which is a 304.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:10:12 +02:00

335 lines
16 KiB
JavaScript

// A markdown table only parses once its last row has arrived. Re-parsing the
// whole partial answer every 180ms therefore flashed between a broken parse and
// the real thing, and the old answer was to abandon markdown past a size or a
// pipe-row count and dump raw text into an unstyled <pre> — the black monospace
// block people saw while a table streamed.
//
// Now the text is split at the last finished block: everything before it is
// rendered once and appended, and only the unfinished tail is plain text.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>',
{ url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
window.AccountBoundary.enter({ id: 'synthetic-streaming-owner' }, true);
window.marked = marked;
window.markdownit = require('markdown-it');
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const context = {
window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true })
};
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js',
'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file)
.replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
return context;
}
const bubbleFor = ctx => ctx.document.createElement('div');
const TABLE = '| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mcg/kg |\n| Amiodarone | 5 mg/kg |';
// ---- where the cut falls ---------------------------------------------------
test('a finished paragraph settles, the one still arriving does not', (t) => {
const { settledMarkdownLength } = ui(t);
const text = 'First paragraph, complete.\n\nSecond one, still typ';
const cut = settledMarkdownLength(text, 0);
assert.equal(text.slice(0, cut).trim(), 'First paragraph, complete.');
assert.equal(text.slice(cut).trim(), 'Second one, still typ');
});
test('a half-written table stays in the tail rather than parsing as broken', (t) => {
const { settledMarkdownLength } = ui(t);
const text = 'Resuscitation doses:\n\n| Drug | Dose |\n| --- | --- |\n| Adrenaline | 10 mc';
const cut = settledMarkdownLength(text, 0);
assert.equal(text.slice(0, cut).trim(), 'Resuscitation doses:');
assert.ok(text.slice(cut).includes('| Adrenaline'));
});
test('a blank line inside an open code fence is not a boundary', (t) => {
const { settledMarkdownLength } = ui(t);
// Cutting here would render a stray ``` and leave the rest unfenced.
const text = 'Example:\n\n```js\nconst a = 1;\n\nconst b = 2;';
const cut = settledMarkdownLength(text, 0);
assert.equal(text.slice(0, cut).trim(), 'Example:');
});
test('a closed fence settles, and the fence state resets after it', (t) => {
const { settledMarkdownLength } = ui(t);
const text = '```js\nconst a = 1;\n```\n\nAfter the block.\n\nStill writ';
const cut = settledMarkdownLength(text, 0);
assert.ok(text.slice(0, cut).includes('After the block.'));
assert.equal(text.slice(cut).trim(), 'Still writ');
});
test('the blank line inside a loose list is not a boundary', (t) => {
const { settledMarkdownLength } = ui(t);
// Cutting there renders one list as two, each restarting at 1.
const text = 'Steps:\n\n1. Airway\n\n2. Breathing\n\n3. Circu';
const cut = settledMarkdownLength(text, 0);
assert.equal(text.slice(0, cut).trim(), 'Steps:');
assert.ok(text.slice(cut).startsWith('1. Airway'));
});
test('but the blank line before a list starts is a boundary', (t) => {
const { settledMarkdownLength } = ui(t);
// Rejecting on the following line alone would refuse this too, and a sentence
// introducing a list is most of what this assistant writes.
const text = 'Assess in this order:\n\n- Airway\n- Breath';
const cut = settledMarkdownLength(text, 0);
assert.equal(text.slice(0, cut).trim(), 'Assess in this order:');
});
test('an unbroken paragraph settles nothing, which is the old behaviour', (t) => {
const { settledMarkdownLength } = ui(t);
const text = 'One very long sentence that has not finished yet and contains no blank line';
assert.equal(settledMarkdownLength(text, 0), 0);
});
test('the search resumes from what was already consumed', (t) => {
const { settledMarkdownLength } = ui(t);
// A boundary already passed must never be returned again, or the same text
// would be appended twice.
const text = 'A.\n\nB.\n\nC still';
const first = settledMarkdownLength(text, 0);
assert.ok(settledMarkdownLength(text, first) >= first);
assert.equal(settledMarkdownLength('A.\n\nB still', 4), 4);
});
// ---- what lands in the bubble ----------------------------------------------
test('the settled part is real markdown and the tail is markdown too, short of a table row', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE + '\n\n| Half | tab', []);
const settled = bubble.querySelector('.assistant-stream-settled');
assert.ok(settled.querySelector('table'), 'the finished table should be a table');
assert.equal(settled.querySelectorAll('table tbody tr').length, 2);
const tail = bubble.querySelector('.assistant-stream-tail');
assert.equal(tail.textContent.trim(), '| Half | tab');
assert.equal(tail.querySelector('table'), null, 'the unfinished row must not be parsed');
});
test('no raw-text <pre> dump, however long or table-heavy the answer', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
// Both of the old bail-out conditions at once: over 3500 characters, and far
// more than eight pipe rows. This used to render as one black <pre>.
const long = 'Intro.\n\n' + (TABLE + '\n\n').repeat(60) + 'Trailing sen';
assert.ok(long.length > 3500);
ctx.renderStreamingInto(bubble, long, []);
assert.ok(bubble.querySelectorAll('table').length >= 20);
assert.equal(bubble.querySelector('pre'), null, 'nothing should fall back to a <pre> dump');
assert.equal(bubble.querySelector('.assistant-stream-tail').textContent.trim(), 'Trailing sen');
});
test('settled content is appended, never re-rendered', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
const settled = bubble.querySelector('.assistant-stream-settled');
const firstNode = settled.firstChild;
ctx.renderStreamingInto(bubble, 'First.\n\nSecond.\n\nThird stil', []);
assert.equal(settled.firstChild, firstNode, 'the first block should be the same node, untouched');
assert.equal(settled.children.length, 2);
assert.equal(bubble.querySelector('.assistant-stream-tail').textContent.trim(), 'Third stil');
});
test('a diagram that has drawn is not torn down by the next block', async (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
let renders = 0;
ctx.window.mermaid = {
initialize() {},
render(id, src) { renders++; return Promise.resolve({ svg: '<svg data-src="' + src + '"></svg>' }); }
};
ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext par', []);
const diagram = bubble.querySelector('[data-mermaid]');
assert.ok(diagram, 'the diagram placeholder should be in the settled part');
// Several more blocks arrive, each re-running renderEmbeddedBlocks.
ctx.renderStreamingInto(bubble, '```mermaid\ngraph TD; A-->B;\n```\n\nNext paragraph.\n\nAnd another.\n\nStill go', []);
await new Promise(resolve => setImmediate(resolve)); // ensureMermaid resolves on a microtask
assert.equal(bubble.querySelector('[data-mermaid]'), diagram, 'same element');
assert.equal(diagram.getAttribute('data-drawn'), '1');
assert.equal(renders, 1, 'mermaid.render should be called once per diagram, not once per block');
});
test('a chart canvas is constructed once — a second Chart on it throws', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
const built = [];
ctx.window.Chart = function (c, cfg) { built.push(cfg); };
const chart = '```chart\n{"type":"bar","data":{"labels":["a"],"datasets":[{"data":[1]}]}}\n```';
ctx.renderStreamingInto(bubble, chart + '\n\nA para', []);
ctx.renderStreamingInto(bubble, chart + '\n\nA paragraph.\n\nAnother.\n\nStill go', []);
if (bubble.querySelector('canvas[data-chart]')) assert.equal(built.length, 1);
});
test('an empty stream shows the placeholder and holds no stale state', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, '', []);
assert.match(bubble.innerHTML, /Generating answer/);
assert.equal(bubble.assistantStreamState, null);
});
test('the final render drops the streaming state, so no frame appends into a detached node', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\nSecond stil', []);
assert.ok(bubble.assistantStreamState);
ctx.fillMessageBubble(bubble, 'assistant', 'First.\n\nSecond, finished.', [], null, false);
assert.equal(bubble.assistantStreamState, null);
assert.equal(bubble.querySelector('.assistant-stream-settled'), null);
});
test('the tail is styled as prose, not left to the browser monospace default', (t) => {
const css = read('public/css/assistant.css');
assert.match(css, /\.assistant-streaming-text \{[^}]*white-space:pre-wrap/);
assert.match(css, /\.assistant-streaming-text \{[^}]*color:var\(--g700\)/);
});
// ---- a table, rendered while it is still arriving ---------------------------
//
// Measured against Open WebUI on the same question, sampling every 250ms: it
// never showed a raw pipe, because it rendered a real <table> with 2 rows at
// t+3.25s and grew it to 4 then 6 as they arrived.
//
// The blank-line rule alone cannot do that. A markdown table contains no blank
// line, so the whole of it stayed in the tail as plain text until the line
// *after* it landed — the markdown flash, on the content where it shows most.
const TABLE_HEAD = '| Drug | Dose |\n| --- | --- |';
test('a table renders from its first complete row, not when it finishes', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []);
const table = bubble.querySelector('table');
assert.ok(table, 'an unfinished table should already be a table');
assert.equal(bubble.querySelectorAll('tbody tr').length, 1);
assert.doesNotMatch(bubble.textContent, /\|\s*---/, 'no raw pipe syntax on screen');
});
test('rows appear as they arrive', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
const rows = ['| Adrenaline | 10 mcg/kg |', '| Amiodarone | 5 mg/kg |', '| Atropine | 20 mcg/kg |'];
const counts = [];
for (let i = 1; i <= rows.length; i++) {
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD + '\n' + rows.slice(0, i).join('\n'), []);
counts.push(bubble.querySelectorAll('tbody tr').length);
}
assert.deepEqual(counts, [1, 2, 3], 'the table should grow a row at a time');
});
test('a half-typed row waits rather than rendering as half a row', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n| Amiod', []);
assert.equal(bubble.querySelectorAll('tbody tr').length, 1, 'only the complete row is shown');
assert.doesNotMatch(bubble.textContent, /Amiod$/);
});
test('a header with no body row yet is not yet a table', (t) => {
// Two lines of pipes and nothing under them is not something to show.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'Doses:\n\n' + TABLE_HEAD, []);
assert.equal(bubble.querySelector('table'), null);
});
test('the finished table settles, and is not left rendered twice', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |', []);
ctx.renderStreamingInto(bubble, TABLE_HEAD + '\n| Adrenaline | 10 mcg/kg |\n\nAfter the table.\n\nStill go', []);
assert.equal(bubble.querySelectorAll('table').length, 1, 'one table, not one settled and one in the tail');
assert.equal(bubble.querySelector('.assistant-stream-settled').querySelectorAll('table').length, 1);
});
test('the unfinished paragraph is rendered, not shown as raw markdown', (t) => {
// The flash the reader used to see: "**Severe:** seizures [8]" as literal
// asterisks and brackets until the paragraph's blank line arrived.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\n**Severe:** a sentence [1] still bei', [{ number: 1, title: 'S' }]);
assert.equal(bubble.querySelector('table'), null);
const tail = bubble.querySelector('.assistant-stream-tail');
assert.ok(tail.querySelector('strong'), 'bold is bold while the paragraph is still arriving');
assert.ok(tail.querySelector('a.assistant-cite'), 'a citation is a chip while the paragraph is still arriving');
assert.equal(tail.textContent.trim(), 'Severe: a sentence 1 still bei');
});
test('a block inside an unclosed code fence stays text until the fence closes', (t) => {
// A half-written mermaid diagram handed to its renderer every frame would
// fail every frame; monospaced text is what the reader expects there anyway.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.renderStreamingInto(bubble, 'First.\n\n```mermaid\ngraph TD\n A --> B', []);
const tail = bubble.querySelector('.assistant-stream-tail');
assert.ok(tail.classList.contains('assistant-streaming-text'));
assert.equal(tail.textContent, '```mermaid\ngraph TD\n A --> B');
assert.equal(tail.querySelector('pre, .mermaid'), null);
});
// ---- what is still happening --------------------------------------------
test('the status stays above the answer while it streams', (t) => {
// It used to be removed the moment the first token landed, which is when it
// becomes most useful: the answer is arriving and the assistant is still
// working.
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.showStreamStatus(bubble, 'Generating image…');
ctx.renderStreamingInto(bubble, 'An answer in progress', []);
ctx.showStreamStatus(bubble, 'Generating image…');
const strip = bubble.querySelector('.assistant-stream-status');
assert.ok(strip);
assert.equal(strip.textContent, 'Generating image…');
assert.equal(bubble.firstChild, strip, 'it sits above the answer, not inside it');
});
test('every render re-asserts the status, so it survives the whole stream', (t) => {
// Calling showStreamStatus in a test proves the function works, not that the
// stream uses it. renderProvisional sets the bubble's contents each frame, so
// it has to put the strip back or the strip only ever lives for one frame.
const src = read('public/js/clinicalAssistant.js');
const fn = src.slice(src.indexOf('function renderProvisional'), src.indexOf('function handleEvent'));
assert.match(fn, /renderStreamingInto\(bubble, partial, streamSources\);[\s\S]{0,400}showStreamStatus\(bubble, streamStatus\)/,
'renderProvisional does not restore the status strip after rendering');
// And a status event updates it immediately rather than waiting for a token.
assert.match(src, /streamStatus = data\.message[\s\S]{0,160}showStreamStatus\(bubble, streamStatus\)/);
});
test('an empty status removes the strip rather than leaving a gap', (t) => {
const ctx = ui(t);
const bubble = bubbleFor(ctx);
ctx.showStreamStatus(bubble, 'Working…');
ctx.showStreamStatus(bubble, '');
assert.equal(bubble.querySelector('.assistant-stream-status'), null);
});
test('the wait names what is happening, not what the software is doing', (t) => {
const ui_ = read('public/js/clinicalAssistant.js');
assert.match(ui_, /appendLoadingMessage\('Searching the clinical library'/);
assert.doesNotMatch(ui_, /Retrieving and synthesizing references/);
});