From 89b0c8eb41da506c7502849afa7a0aff2669f4b7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 23 Apr 2026 23:09:08 +0000 Subject: [PATCH] feat: incremental streaming markdown via streaming-markdown (v0.50.180, #917) Co-authored-by: bsgdigital --- CHANGELOG.md | 5 + static/index.html | 7 + static/messages.js | 70 ++++- tests/test_regressions.py | 2 +- tests/test_streaming_markdown.py | 451 +++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 7 deletions(-) create mode 100644 tests/test_streaming_markdown.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f4662..a484183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ workspace subtree) and never enumerate blocked system roots. (`api/routes.py`, `api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616) +## [v0.50.180] — 2026-04-23 + +### Added +- **Incremental streaming markdown via `streaming-markdown`** — replaces the per-animation-frame full `innerHTML` re-render with an incremental DOM-building parser. During streaming, only new character deltas are fed to the parser per frame (`_smdWrite()`), eliminating DOM thrashing and improving rendering smoothness. Prism.js / KaTeX state no longer gets reset mid-stream. Falls back to the existing `renderMd()` path when the library is unavailable. (`static/messages.js`, `static/index.html`) Co-authored by @bsgdigital. + ## [v0.50.179] — 2026-04-23 ### Fixed diff --git a/static/index.html b/static/index.html index 873fcea..fbbd5b0 100644 --- a/static/index.html +++ b/static/index.html @@ -21,6 +21,13 @@ + + diff --git a/static/messages.js b/static/messages.js index 276ab0e..4c8c93b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -189,6 +189,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let assistantBody=null; let segmentStart=0; // char offset in assistantText where current segment begins let _freshSegment=false; // true after a tool call — forces a new DOM segment + // streaming-markdown state: incremental DOM-building parser per segment + let _smdParser=null; // current smd parser instance (null until first content) + let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser + // On reconnect, the assistantBody already has partial smd-rendered content. + // We clear it on first new token and restart the parser from the reconnect point. + let _smdReconnect=reconnecting; // Thinking tag patterns for streaming display const _thinkPairs=[ {open:'',close:''}, @@ -366,6 +372,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // removeThinking() won't find it anyway, but guard explicitly. if(!reasoningText) removeThinking(); } + // Helper: create (or recreate) the smd parser bound to a given DOM element. + // Called when assistantBody is first created and after each tool-call segment reset. + function _smdNewParser(el){ + _smdWrittenLen=0; + if(!window.smd){_smdParser=null;return;} + const renderer=window.smd.default_renderer(el); + _smdParser=window.smd.parser(renderer); + } + // Helper: end the current smd parser (flushes remaining state) and null it out. + function _smdEndParser(){ + if(_smdParser&&window.smd){ + try{window.smd.parser_end(_smdParser);}catch(_){} + } + _smdParser=null; + _smdWrittenLen=0; + } + // Helper: feed new displayText delta to the smd parser. + // Only feeds chars beyond what has already been written (_smdWrittenLen). + function _smdWrite(displayText){ + if(!_smdParser||!window.smd) return; + const delta=displayText.slice(_smdWrittenLen); + if(!delta) return; + try{window.smd.parser_write(_smdParser,delta);}catch(_){} + _smdWrittenLen=displayText.length; + } function _scheduleRender(){ if(_renderPending) return; if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized @@ -376,12 +407,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const parsed=_parseStreamState(); _renderLiveThinking(parsed); if(assistantBody){ - // Render only the text belonging to the current segment (after the last tool call). - // segmentStart=0 for the first segment, or assistantText.length-at-last-tool for later ones. - const segText = segmentStart===0 - ? parsed.displayText // first segment: use full display (handles think-tag stripping) - : renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart); - assistantBody.innerHTML = segText || ''; + const displayText = segmentStart===0 + ? parsed.displayText // first segment: uses think-tag stripping + : _stripXmlToolCalls(assistantText.slice(segmentStart)); + if(!_smdParser&&window.smd){ + // On reconnect: prior content in assistantBody came from a different smd parser run. + // Clear it and start fresh — renderMessages() on done will restore the full content. + if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;} + _smdNewParser(assistantBody); + } + if(_smdParser){ + _smdWrite(displayText); + } else { + // Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd + assistantBody.innerHTML = (segmentStart===0 + ? parsed.displayText + : renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart)) || ''; + } } scrollIfPinned(); }); @@ -461,6 +503,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ assistantBody=null; segmentStart=assistantText.length; // new segment starts at current text length _freshSegment=true; // prevent reuse of old DOM node + _smdEndParser(); // finalize current smd parser; new one created on next token scrollIfPinned(); }); @@ -551,6 +594,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFinalized=true; if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;} if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); + // Finalize smd parser — flushes any remaining buffered markdown state + // and runs Prism + copy buttons on the live segment before the DOM is replaced + if(assistantBody){ + const _finBody=assistantBody; + _smdEndParser(); + requestAnimationFrame(()=>{ + if(typeof highlightCode==='function') highlightCode(_finBody); + if(typeof addCopyButtons==='function') addCopyButtons(_finBody); + if(typeof renderKatexBlocks==='function') renderKatexBlocks(); + }); + } else { + _smdEndParser(); + } const d=JSON.parse(e.data); delete INFLIGHT[activeSid]; clearInflight();clearInflightState(activeSid); @@ -617,6 +673,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _terminalStateReached=true; _streamFinalized=true; if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;} + _smdEndParser(); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); // Application-level error sent explicitly by the server (rate limit, crash, etc.) // This is distinct from the SSE network 'error' event below. @@ -694,6 +751,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _terminalStateReached=true; _streamFinalized=true; if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;} + _smdEndParser(); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); source.close(); delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling(); diff --git a/tests/test_regressions.py b/tests/test_regressions.py index b503b1a..915591b 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -433,7 +433,7 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session if done_idx < 0: done_idx = src.find("es.addEventListener('done'") assert done_idx >= 0 - done_block = src[done_idx:done_idx+2900] + done_block = src[done_idx:done_idx+3300] # S.busy=false must appear before renderMessages() within the done handler busy_pos = done_block.find("S.busy=false;") render_pos = done_block.find("renderMessages()") diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py new file mode 100644 index 0000000..0e8aac0 --- /dev/null +++ b/tests/test_streaming_markdown.py @@ -0,0 +1,451 @@ +"""Tests for incremental streaming-markdown (smd) integration in messages.js. + +PR: feat: use streaming-markdown for incremental live rendering + +The change replaces the per-rAF `assistantBody.innerHTML = renderMd(...)` call +with an incremental DOM-building approach powered by the streaming-markdown +library (https://github.com/nicholasgasior/streaming-markdown): + + - During streaming: smd.parser_write() feeds new text deltas into a live DOM + tree — no full re-render per frame, no innerHTML thrash. + - On done/apperror/cancel: smd.parser_end() flushes remaining parser state, + then Prism / copy buttons / KaTeX are run on the live segment. + - On tool event: smd.parser_end() finalises the current segment; the next + token after the tool creates a fresh parser bound to the new assistantBody. + - Fallback: when window.smd is not yet loaded, the old renderMd path is used. + - Reconnect: _smdReconnect flag clears stale DOM from the previous parser run + and restarts the smd parser from the reconnect point. + +Tests are static (regex / AST-level) — no browser required. +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8") + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def extract_fn(src, name, *, brace_depth=1): + """Return the text of a JS function starting from `function ` to its + closing brace. Works for both standalone and closure-local functions. + Does a simple brace-counting walk so it handles nested blocks correctly. + """ + pattern = rf"function {re.escape(name)}\s*\(" + m = re.search(pattern, src) + if not m: + return None + start = m.start() + # Find the opening brace + brace_pos = src.index("{", m.end()) + depth = 1 + pos = brace_pos + 1 + while pos < len(src) and depth > 0: + ch = src[pos] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + pos += 1 + return src[start:pos] + + +def extract_event_handler(src, event_name): + """Return the text of a source.addEventListener('', ...) block.""" + pattern = rf"source\.addEventListener\('{re.escape(event_name)}'" + m = re.search(pattern, src) + if not m: + return None + # Walk forward to collect the matching parenthesis + paren_depth = 0 + start = m.start() + pos = m.end() + # Count back to the opening paren + paren_depth = 1 + while pos < len(src) and paren_depth > 0: + ch = src[pos] + if ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth -= 1 + pos += 1 + return src[start:pos] + + +def extract_attach_live_stream_prelude(src): + """Return the text from attachLiveStream opening to the first nested fn.""" + m = re.search(r"function attachLiveStream\(", src) + if not m: + return None + # Find the first nested function definition inside the closure + inner = re.search(r"\bfunction _isActiveSession\b", src[m.start():]) + if not inner: + return src[m.start(): m.start() + 5000] + return src[m.start(): m.start() + inner.start()] + + +# ── 1. index.html: smd script tag ───────────────────────────────────────────── + +class TestIndexHtmlSmdScript: + """streaming-markdown must be loaded in index.html before messages.js uses it.""" + + def test_smd_cdn_url_present(self): + assert "streaming-markdown" in INDEX_HTML, ( + "index.html must include a