perf(stream): throttle live-turn snapshot and skip redundant per-token parse (#5455)

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 <noreply@anthropic.com>
This commit is contained in:
TARS 2026-07-03 18:02:43 +02:00
parent 304a82d9b4
commit ed922f4f18

View file

@ -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 <function_calls> 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=>{