From 304a82d9b4ad3942516ac4a0c9d20dec819f1770 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 17:27:59 +0200 Subject: [PATCH 1/4] perf(stream): render anchor-scene live prose incrementally via smd (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In compact-worklog mode the anchor activity scene re-renders every frame, and its live prose row was rebuilt with renderMd(fullText) each time — re-parsing the entire growing answer per frame, which is O(n^2) over a long streamed response (the incremental smd parser that the main body already uses was bypassed and its segment hidden). Keep a persistent per-segment smd parser + DOM node (keyed by the prose row's local_id, using the same safe renderer as the live body) and feed it only the delta each frame; hand that persistent node back to the scene builder instead of re-parsing. Turns the per-frame prose cost from O(n) into O(delta), i.e. the whole answer from O(n^2) to O(n). Safe by construction: settled rows and any failure (no smd, no stable key, parser error, text divergence) fall through to the existing renderMd path, so worst case is identical to today. Cache is bounded (<=32 segments) and self-heals on non-append text changes, mirroring _smdWrite. Verified: node --check + ESLint runtime guard clean. Streaming render profiled in-browser via a per-frame timing probe. Co-Authored-By: Claude Opus 4.8 --- static/messages.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++ static/ui.js | 9 +++++++++ 2 files changed, 57 insertions(+) diff --git a/static/messages.js b/static/messages.js index 123696131..85087d64c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3508,6 +3508,54 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ },null); return _findAnchorActivityEventByLocalId(localId,'token'); } + // Persistent incremental renderer for anchor-scene live prose rows. The compact + // worklog re-renders the whole scene each frame; rendering the growing prose via + // renderMd(fullText) every frame is O(n^2) over a long answer. Instead keep a + // per-segment smd parser + node (the SAME safe renderer as the main live body) + // and feed only the delta, then hand the persistent node back to the ui.js scene + // builder. Returns null whenever smd or a stable key is unavailable so the caller + // falls back to the full renderMd path — identical structure, just not + // incremental. (#5455 WS2.1) + const _anchorProseSmdCache = new Map(); + function _anchorProseIncrementalNode(key, text){ + if(!window.smd || !key || typeof _safeSmdRenderer!=='function') return null; + const value=String(text||''); + try{ + let st=_anchorProseSmdCache.get(key); + // Self-heal desyncs (edit/sanitize made the text no longer a pure append): + // rebuild the parser+node from scratch, mirroring _smdWrite's guard. + if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null; + if(!st){ + const node=document.createElement('div'); + node.className='assistant-segment'; + node.setAttribute('data-anchor-scene-prose','1'); + const body=document.createElement('div'); + body.className='msg-body'; + node.appendChild(body); + const baseRenderer=_safeSmdRenderer(body); + const renderer=_smdRendererWithoutUnderscoreEmphasis(baseRenderer); + st={node,parser:window.smd.parser(renderer),writtenText:''}; + _anchorProseSmdCache.set(key,st); + // Bound memory across turns: keys embed the stream id, so stale entries + // from finished streams age out here. + if(_anchorProseSmdCache.size>32){ + const oldest=_anchorProseSmdCache.keys().next().value; + if(oldest!==key) _anchorProseSmdCache.delete(oldest); + } + } + const delta=value.slice(st.writtenText.length); + if(delta){ + window.smd.parser_write(st.parser,delta); + st.writtenText=value; + } + st.node.dataset.rawText=value; + return st.node; + }catch(_){ + _anchorProseSmdCache.delete(key); + return null; + } + } + window.__anchorProseIncrementalNode=_anchorProseIncrementalNode; function _anchorHasReasoningEvents(){ const events=_anchorActivityEvents(); return !!(events&&events.some(event=>event&&event.source_event_type==='reasoning')); diff --git a/static/ui.js b/static/ui.js index 83062af02..2c226772a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10531,6 +10531,15 @@ function _anchorSceneNodeForRow(row, opts){ if(row.role==='prose'){ const text=String(row.text||'').trim(); if(!text) return null; + // Incremental live rendering: reuse a persistent smd node fed only the delta + // instead of re-parsing the whole growing answer on every streamed frame + // (O(n^2) -> O(n)). Settled rows and any failure fall through to the full + // renderMd path below, which stays the source of truth for the final DOM. + const proseKey=row.local_id||row.row_id||''; + if(!settled && proseKey && typeof window.__anchorProseIncrementalNode==='function'){ + const inc=window.__anchorProseIncrementalNode(proseKey,text); + if(inc) return inc; + } node=document.createElement('div'); node.className='assistant-segment'; node.setAttribute('data-anchor-scene-prose','1'); From ed922f4f182c01c838d0efed7211f74ec4889aa6 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 18:02:43 +0200 Subject: [PATCH 2/4] perf(stream): throttle live-turn snapshot and skip redundant per-token parse (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two O(n^2) costs on the per-token/per-frame streaming path, both over the growing answer: WS2.2 — snapshotLiveTurn() serialized the whole live turn via turn.outerHTML on every render frame. That snapshot only backs mid-stream session-switch restore, and the switch path (sessions.js) plus tool/done event boundaries already capture synchronously, so the per-frame call is throttled to a coarse trailing snapshot instead of serializing the entire growing DOM each frame. WS2.3 — the token handler ran _parseStreamState() (strip tool-call XML + extract inline thinking over the FULL accumulated text) on every token, but _doRender re-parses once per throttled frame anyway and the only per-token use is the assistant-row creation gate. Skip the parse once the row exists (the common case); keep it only until the row is created. Also drop the full-string toLowerCase() allocation in _stripXmlToolCalls for a non-allocating regex test. Output-neutral: same rendered DOM; the row-creation gate and snapshot semantics are preserved (row appears within one 66ms frame; switch/settle snapshots stay synchronous). node --check + ESLint runtime guard clean. Co-Authored-By: Claude Opus 4.8 --- static/messages.js | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/static/messages.js b/static/messages.js index 85087d64c..69de54f12 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2122,6 +2122,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function snapshotLiveTurn(){ if(typeof snapshotLiveTurnHtmlForSession==='function') snapshotLiveTurnHtmlForSession(activeSid); } + // Throttled per-frame variant. snapshotLiveTurnHtmlForSession serializes the + // whole (growing) live turn via turn.outerHTML — O(n)/frame -> O(n^2) over a + // long answer, and a real GC-pressure source. The snapshot only backs + // mid-stream session-switch restore, and the switch path (sessions.js) plus + // the stream event boundaries (tool/done) already capture synchronously, so a + // coarse trailing snapshot during streaming is sufficient. (#5455 WS2.2) + let _snapshotLiveTurnTimer=null; + function _throttledSnapshotLiveTurn(){ + if(_snapshotLiveTurnTimer) return; + _snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700); + } // Throttled variant for token-by-token updates. persistInflightState() // calls saveInflightState() which does JSON.parse + JSON.stringify + write // on the entire inflight map every call. On a fast model at 60 tok/s with @@ -3789,8 +3800,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Also handles DSML-prefixed variants from DeepSeek/Bedrock, including // spacing variants like "<|DSML |function_calls" and truncated prefixes. if(!s) return s; - const lo=String(s).toLowerCase(); - if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s; + // Case-insensitive presence check without allocating a full lowercased copy + // of the (growing) text on every call — cuts per-token/per-frame GC pressure. + // Equivalent to the previous toLowerCase()+indexOf gate. (#5455 WS2.3) + if(!/function_calls|dsml/i.test(String(s))) return s; // Support both plain and DSML-prefixed variants. s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,''); // Also remove truncated opening tags (missing closing ">" at stream tail). @@ -4639,7 +4652,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText); } scrollIfPinned(); - snapshotLiveTurn(); + _throttledSnapshotLiveTurn(); }; const frameIntervalMs=_shouldUseStreamFade()?33:66; if(sinceLastMs>=frameIntervalMs){ @@ -4695,10 +4708,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ syncInflightAssistantMessage(); if(!S.session||S.session.session_id!==activeSid) return; _completeAutomaticCompressionOnLiveProgress(activeSid); - const parsed=_parseStreamState(); if(_freshSegment) appendThinking('', _liveThinkingPlacement()); - if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); - _scheduleRender(parsed); + // Once the assistant row exists its creation gate is already satisfied, and + // the throttled _doRender re-parses once per frame anyway — so the per-token + // full-text parse here is pure waste (O(n)/token -> O(n^2) over the answer). + // Still call ensureAssistantRow() every token exactly as before (cheap; it + // also starts a new segment on a post-tool _freshSegment). Only the parse is + // skipped, and only once the row exists. (#5455 WS2.3) + if(assistantRow){ + ensureAssistantRow(); + _scheduleRender(); + }else{ + const parsed=_parseStreamState(); + if(String((parsed&&parsed.displayText)||'').trim()) ensureAssistantRow(); + _scheduleRender(parsed); + } }); source.addEventListener('interim_assistant',e=>{ From 2f8e2ba547bf0c27cab1d4003acea420033faa61 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 19:55:33 +0200 Subject: [PATCH 3/4] test: match optimized live token render path --- tests/test_regressions.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index eaa19800a..a4a4d1582 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1030,7 +1030,14 @@ def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_tes "live answer content should be appended as a segment inside the live turn wrapper" assert "if(!force&&!assistantRow){" in src.replace(' ', ''), \ "ensureAssistantRow must still avoid creating the live answer segment when no display text exists yet" - assert "if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();" in src, \ + token_start = src.find("source.addEventListener('token'") + interim_start = src.find("source.addEventListener('interim_assistant'", token_start) + assert token_start >= 0 and interim_start > token_start + token_body = src[token_start:interim_start] + compact_token_body = token_body.replace(" ", "").replace("\n", "") + assert "if(assistantRow){ensureAssistantRow();_scheduleRender();}" in compact_token_body, \ + "token handler should skip the per-token full-text parse after the live answer segment exists" + assert "constparsed=_parseStreamState();if(String((parsed&&parsed.displayText)||'').trim())ensureAssistantRow();_scheduleRender(parsed);" in compact_token_body, \ "token handler must only create the live answer segment once visible answer text starts" From eedbf8b05fbd1bd5c1aa61f996248139710f8b18 Mon Sep 17 00:00:00 2001 From: TARS Date: Fri, 3 Jul 2026 20:10:40 +0200 Subject: [PATCH 4/4] fix: clear stream perf cleanup state --- static/messages.js | 11 +++++++++++ tests/test_regressions.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/static/messages.js b/static/messages.js index 69de54f12..2a2299d9b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2133,6 +2133,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(_snapshotLiveTurnTimer) return; _snapshotLiveTurnTimer=setTimeout(()=>{_snapshotLiveTurnTimer=null;snapshotLiveTurn();},700); } + function _cancelThrottledSnapshotTimer(){ + if(_snapshotLiveTurnTimer){clearTimeout(_snapshotLiveTurnTimer);_snapshotLiveTurnTimer=null;} + } // Throttled variant for token-by-token updates. persistInflightState() // calls saveInflightState() which does JSON.parse + JSON.stringify + write // on the entire inflight map every call. On a fast model at 60 tok/s with @@ -2178,6 +2181,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _finalizeStreamEndFallback(source){ _clearStreamEndRecovery(); if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _cancelThrottledSnapshotTimer(); _terminalStateReached=true; _streamFinalized=true; _cancelAnimationFramePendingStreamRender(); @@ -2189,6 +2193,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _clearStreamNotificationBackground(activeSid, streamId); _flushReasoningToAnchor(); _scheduleAnchorRegistryCleanup(); + _clearAnchorProseIncrementalNode(); _clearApprovalForOwner(); _clearClarifyForOwner('terminal'); if(_isActiveSession()){ @@ -3567,6 +3572,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } window.__anchorProseIncrementalNode=_anchorProseIncrementalNode; + function _clearAnchorProseIncrementalNode(){ + if(typeof window!=='undefined'&&window.__anchorProseIncrementalNode===_anchorProseIncrementalNode) window.__anchorProseIncrementalNode=null; + _anchorProseSmdCache.clear(); + } function _anchorHasReasoningEvents(){ const events=_anchorActivityEvents(); return !!(events&&events.some(event=>event&&event.source_event_type==='reasoning')); @@ -5084,6 +5093,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFinalized=true; _terminalStateReached=true; if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _cancelThrottledSnapshotTimer(); const _doneData=JSON.parse(e.data); const _doneEvent=e; const _finishDone=()=>{ @@ -5115,6 +5125,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ created_at:d.created_at||null, },_doneEvent); _scheduleAnchorRegistryCleanup(); + _clearAnchorProseIncrementalNode(); const isActiveSession=_isSessionCurrentPane(activeSid); const isSessionViewed=_isSessionActivelyViewed(activeSid); const completedSession=d.session||{session_id:activeSid}; diff --git a/tests/test_regressions.py b/tests/test_regressions.py index a4a4d1582..1722e853e 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1041,6 +1041,28 @@ def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_tes "token handler must only create the live answer segment once visible answer text starts" +def test_messages_js_stream_perf_cleanup_lifecycle(cleanup_test_sessions): + """#5455 review: throttled snapshot timers and incremental anchor caches tear down at terminal events.""" + src = (REPO_ROOT / "static/messages.js").read_text() + assert "function _cancelThrottledSnapshotTimer()" in src + assert "clearTimeout(_snapshotLiveTurnTimer)" in src + assert "function _clearAnchorProseIncrementalNode()" in src + assert "window.__anchorProseIncrementalNode===_anchorProseIncrementalNode" in src + assert "_anchorProseSmdCache.clear();" in src + fallback_start = src.find("function _finalizeStreamEndFallback") + recovery_start = src.find("async function _runStreamEndRecovery", fallback_start) + assert fallback_start >= 0 and recovery_start > fallback_start + fallback_body = src[fallback_start:recovery_start] + assert "_cancelThrottledSnapshotTimer();" in fallback_body + assert "_clearAnchorProseIncrementalNode();" in fallback_body + done_start = src.find("source.addEventListener('done'") + stream_end_start = src.find("source.addEventListener('stream_end'", done_start) + assert done_start >= 0 and stream_end_start > done_start + done_body = src[done_start:stream_end_start] + assert "_cancelThrottledSnapshotTimer();" in done_body + assert "_clearAnchorProseIncrementalNode();" in done_body + + def test_messages_js_finalizes_thinking_card_before_tool_card(cleanup_test_sessions): """R19e: later reasoning after a tool call must render in a fresh Worklog Thinking Card without discarding durable reasoning.