// 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
— 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('' + read('public/components/assistant.html') + '',
{ 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.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 while the tail is still plain text', (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-streaming-text');
assert.equal(tail.textContent.trim(), '| Half | tab');
assert.equal(tail.querySelector('table'), null, 'the unfinished row must not be parsed');
});
test('no raw-text 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 .
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 dump');
assert.equal(bubble.querySelector('.assistant-streaming-text').textContent, '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-streaming-text').textContent, '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: '' }); }
};
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\)/);
});