diff --git a/static/messages.js b/static/messages.js index 123696131..2a2299d9b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2122,6 +2122,20 @@ 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); + } + 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 @@ -2167,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(); @@ -2178,6 +2193,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _clearStreamNotificationBackground(activeSid, streamId); _flushReasoningToAnchor(); _scheduleAnchorRegistryCleanup(); + _clearAnchorProseIncrementalNode(); _clearApprovalForOwner(); _clearClarifyForOwner('terminal'); if(_isActiveSession()){ @@ -3508,6 +3524,58 @@ 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 _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')); @@ -3741,8 +3809,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). @@ -4591,7 +4661,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){ @@ -4647,10 +4717,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=>{ @@ -5012,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=()=>{ @@ -5043,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/static/ui.js b/static/ui.js index 5e5da1243..5342cc881 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'); diff --git a/tests/test_regressions.py b/tests/test_regressions.py index eaa19800a..1722e853e 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1030,10 +1030,39 @@ 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" +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.