From cabb467b36b23ffd21d9d2acf3b4c3b12f6fcbbe Mon Sep 17 00:00:00 2001 From: SilentReader <759313538@qq.com> Date: Sun, 9 Aug 2026 07:37:54 +0800 Subject: [PATCH] fix(stream): stop fade-text effect from re-fading the whole message on tool-call rewinds (#6783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stream): stop fade-text effect from re-fading whole message on tool-call rewinds When the fade-text effect is enabled, Hermes long tasks visibly blink the entire message on every tool call. Root cause: tool-call XML () appears inline in the assistant text stream, and the unclosed-tag stripping in _stripXmlToolCalls temporarily rewinds displayText to a strict prefix. That rewind zeroed the fade playout (_streamFadeNextText) and tripped _smdWrite's self-heal, which cleared assistantBody.innerHTML and re-created every word as a fresh is-new span — replaying the fade on all visible text at once. Fix: on a detected rewind, shrink the playout cursor to the common prefix instead of resetting to zero, and mute the fade renderer for that common prefix during any rebuild (both the main smd path and the anchor prose incremental node) so only the post-rewind tail animates. Adds regression tests: playout rewind keeps common prefix visible, rebuild mutes the common prefix, and _smdWrite's self-heal sets the silent-prefix window. * fix(#6783): compute rewind mute prefix in rendered-text space Review feedback (maintainer round on 56c9307f): the silent-prefix budget was computed in SOURCE-text space (charCodeAt over markdown source), but the fade add_text hook only receives RENDERED prose. Markdown delimiters, link destinations and MEDIA token bytes never reach add_text, so the source-space budget over-muted the first genuinely new word after a rewind (e.g. '**alpha** beta' -> '**alpha** gamma' muted gamma). Fix: snapshot the OLD rendered text before the rebuild (assistantBody textContent / anchor .msg-body textContent), then after parser_write compare it with the NEW rendered text and strip is-new only from spans inside the rendered common prefix (_streamFadeMuteRenderedPrefix). - Rendered-space boundary, scoped to the owning node (no stream-global counter that can survive a rebuild without a fade consumer) - No state left behind on reduced-motion / safe-renderer / empty-text / parser-finalization paths: _rewindPrevRendered is a local variable consumed right after the rebuild - Tests: plain-word mute, markdown/MEDIA byte regression (the blocker), per-root scoping (main vs anchor), updated _smdWrite self-heal test Gate: node -c OK; test_smooth_text_fade + test_smd_media_in_stream + test_svg_audio_video_rendering + test_insights -> 83 passed, 18 subtests. * fix(#6783): drop f-prefix from _fade_fake_node JS block (F541) CI lint failure on a6d37b22: tests/test_smooth_text_fade.py:723 F541 f-string without any placeholders. The f-prefix was only used to escape JS braces ({{ }}) — a plain string with literal braces is equivalent and passes the ruff E9+F+B gate. --------- Co-authored-by: silent-reader-cn Co-authored-by: nesquena-hermes --- static/messages.js | 140 ++++++++++++++- tests/test_smd_media_in_stream.py | 1 + tests/test_smooth_text_fade.py | 276 ++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+), 5 deletions(-) diff --git a/static/messages.js b/static/messages.js index 8142a714..02bfde2d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2601,6 +2601,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=620; let _streamFadeDomText=''; + let _streamFadeSilentPrefixChars=0; let _streamFadeReduceMotionMql=null; let _streamFadeReduceMotion=false; let _streamFadeReduceMotionOnChange=null; @@ -3877,11 +3878,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const value=String(text||''); const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade(); let st; + let _rewindPrevRendered=''; try{ st=_anchorProseSmdCache.get(key); // Self-heal desyncs (edit/sanitize made the text no longer a pure append): // rebuild the parser+node from scratch, mirroring the _smdWrite guard. - if(st && st.writtenText && !value.startsWith(st.writtenText)) st=null; + // Fade-flash guard: when the text REWINDS (tool-call XML stripped from the + // live prose), the rebuilt node would re-create every word as a new + // is-new span and replay the fade on ALL visible words at once. Mute the + // fade renderer for the common prefix so only the post-rewind tail fades. + if(st && st.writtenText && !value.startsWith(st.writtenText)){ + // Snapshot the OLD rendered text BEFORE clearing the node. The silent + // prefix is later recomputed in RENDERED-text space (old node text vs + // new node text) — source-space byte counts are wrong here because + // markdown delimiters, link destinations and MEDIA tokens never reach + // the fade add_text hook, so a source-space budget over-mutes the + // first genuinely new word after a rewind (#6783 review). + const oldBody=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body'); + _rewindPrevRendered=oldBody?(oldBody.textContent||''):''; + st=null; + } if(st && st.fade!==fade) st=null; if(st && st.finalized && st.writtenText!==value){ const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body'); @@ -3917,6 +3933,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ window.smd.parser_write(st.parser,delta); st.writtenText=value; } + // Rewind rebuild: mute the rendered common prefix (old node text vs new + // node text) so already-visible words do not replay their fade; only the + // post-rewind tail animates. Rendered-space compare, not source-space + // (#6783 review — markdown/MEDIA bytes never reach add_text). + if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){ + _streamFadeMuteRenderedPrefix(body,_rewindPrevRendered); + _rewindPrevRendered=''; + } if(finalize){ _finalizeAnchorProseIncrementalNode(st); } @@ -4335,10 +4359,24 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _smdWrite(displayText, fade=false){ if(!_smdParser||!window.smd) return; displayText=String(displayText||''); - // Self-heal desyncs: if displayText no longer starts with what we've already - // written (e.g. due to stream sanitization/tag stripping), incremental slicing + let _rewindPrevRendered=''; + // Self-heal desyncs: if displayText no longer starts with what we have + // already written (e.g. due to stream sanitization/tag stripping), incremental slicing // can skip characters. Rebuild parser from the full current displayText. if(_smdWrittenText && !displayText.startsWith(_smdWrittenText)){ + // Fade-flash fix: when the visible text REWINDS (tool-call XML stripping + // makes displayText a strict prefix of what was already written), the + // rebuild below would clear the body and re-create every word as a new + // `is-new` span — replaying the fade animation on ALL visible text at + // once (a full-message blink on every tool call). Instead, snapshot the + // OLD RENDERED text and mute the rebuild prefix spans AFTER the + // parser_write, in RENDERED-text space: source-space byte counts include + // markdown delimiters / link destinations / MEDIA token bytes that never + // reach the fade add_text hook, so a source-space budget over-mutes the + // first genuinely new word after a rewind (#6783 review). + if(assistantBody && typeof assistantBody.textContent==='string'){ + _rewindPrevRendered=assistantBody.textContent; + } _smdParser=null; _smdWrittenLen=0; _smdWrittenText=''; @@ -4351,6 +4389,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{window.smd.parser_write(_smdParser,delta);}catch(_){} _smdWrittenLen=displayText.length; _smdWrittenText=displayText; + // Rebuild after a rewind: strip is-new from spans covered by the + // RENDERED common prefix (old node text vs new node text), so already- + // visible words stay plain while only the post-rewind tail fades. + if(_rewindPrevRendered && typeof _streamFadeMuteRenderedPrefix==='function'){ + _streamFadeMuteRenderedPrefix(assistantBody,_rewindPrevRendered); + } // URL scheme safety is handled by the renderer's set_attr hook // (_safeSmdRenderer or _streamFadeRenderer), applied inline as smd // creates each DOM node — no post-hoc full-DOM scan needed. @@ -4427,6 +4471,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _streamFadeHoldUntilMs=0; _streamFadeCurrentMs=_STREAM_FADE_MS; _streamFadeDomText=''; + _streamFadeSilentPrefixChars=0; } function _cancelAnimationFramePendingStreamRender(){ if(_pendingRafHandle===null) return; @@ -4511,6 +4556,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const reduceMotion=_streamFadeReduceMotionEnabled(); const appendStartedAt=performance.now(); let last=0, match, changed=false; + // Silent-prefix window: after a rebuild caused by a REWIND (tool-call + // XML stripping), words that were already visible before the rewind + // point must NOT replay their fade animation. They are appended as + // plain text; only the tail beyond _streamFadeSilentPrefixChars fades. + let silentLeft=_streamFadeSilentPrefixChars||0; while((match=wordRe.exec(value))){ if(match.index>last) frag.appendChild(document.createTextNode(value.slice(last,match.index))); if(reduceMotion){ @@ -4520,6 +4570,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ changed=true; continue; } + if(silentLeft>0){ + frag.appendChild(document.createTextNode(match[1])); + if(match[2]) frag.appendChild(document.createTextNode(match[2])); + silentLeft-=match[0].length; + last=match.index+match[0].length; + changed=true; + continue; + } const span=document.createElement('span'); span.className='stream-fade-word is-new'; const fadeMs=_streamFadeCurrentMs||_STREAM_FADE_MS; @@ -4531,6 +4589,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ last=match.index+match[0].length; changed=true; } + if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft; + else _streamFadeSilentPrefixChars=0; if(!changed){baseAddText(data,text);return;} if(lastlast) frag.appendChild(document.createTextNode(value.slice(last,match.index))); if(reduceMotion){ frag.appendChild(document.createTextNode(match[1])); + }else if(silentLeft>0){ + frag.appendChild(document.createTextNode(match[1])); + silentLeft-=match[0].length; }else{ const span=document.createElement('span'); span.className='stream-fade-word is-new'; @@ -4838,6 +4905,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ last=match.index+match[0].length; changed=true; } + if(silentLeft>0) _streamFadeSilentPrefixChars=silentLeft; + else _streamFadeSilentPrefixChars=0; if(!changed){ frag.appendChild(document.createTextNode(value)); }else if(last{ + if(!node||consumed>_common) return; + const isText=node.nodeType===3||node.type==='text'; + if(isText){ + const len=(node.textContent||'').length; + const start=consumed; + consumed+=len; + // Text node inside a fade span that starts before the common-prefix + // boundary → mute the span (drop is-new, keeping the word visible + // without replaying its animation). + if(start<_common){ + const parent=node.parentNode; + if(parent&&/\bstream-fade-word\b/.test(parent.className||'')&&/\bis-new\b/.test(parent.className||'')){ + if(parent.classList&&typeof parent.classList.remove==='function'){ + parent.classList.remove('is-new'); + }else{ + parent.className=String(parent.className||'').replace(/\bis-new\b/g,'').replace(/\s{2,}/g,' ').trim(); + } + } + } + return; + } + const kids=node.childNodes||node.children; + if(kids){ for(let i=0;i=0) return 90; const trimmed=String(text||'').trimEnd(); @@ -4861,8 +4973,26 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return {text:'', caughtUp:true, changed:hadVisible}; } if(!_streamFadeVisibleText||!targetText.startsWith(_streamFadeVisibleText)){ - // Markdown/tool stripping can rewrite the visible prefix. Reset safely rather than - // trying to animate across incompatible strings or stale word birth timestamps. + // Markdown/tool stripping can rewrite the visible prefix. Shrink the + // playout cursor to the common prefix instead of resetting to zero — + // a full reset would replay the fade animation on every already-visible + // word whenever the display text briefly rewinds (e.g. tool-call XML + // being stripped mid-stream). Only when nothing overlaps does the playout + // need a true from-scratch start. + let _commonLen=0; + const _maxCommon=Math.min(_streamFadeVisibleText.length,targetText.length); + while(_commonLen<_maxCommon&&_streamFadeVisibleText.charCodeAt(_commonLen)===targetText.charCodeAt(_commonLen)) _commonLen+=1; + if(_commonLen>0){ + _streamFadeVisibleText=targetText.slice(0,_commonLen); + _streamFadeVisibleWords=_streamFadeWordCountOf(_streamFadeVisibleText); + _streamFadeWordCarry=0; + _streamFadeLastTickMs=0; + _streamFadeStartedAt=0; + // changed:true forces the DOM to sync to the shrunken prefix this + // frame (dropping the rewind tail). The rebuild mutes the common + // prefix so no fade animation is replayed. + return {text:_streamFadeVisibleText,caughtUp:_streamFadeVisibleText===targetText,changed:true}; + } _resetStreamFadeState(); } if(!_streamFadeLastTickMs){ diff --git a/tests/test_smd_media_in_stream.py b/tests/test_smd_media_in_stream.py index a58f8b67..d123ef12 100644 --- a/tests/test_smd_media_in_stream.py +++ b/tests/test_smd_media_in_stream.py @@ -100,6 +100,7 @@ def _run_real_smd_media_cases() -> dict: "const _STREAM_FADE_MS = 620;\n" "let _streamFadeCurrentMs = _STREAM_FADE_MS;\n" "let _streamFadeLatestAnimationEndAt = 0;\n" + "let _streamFadeSilentPrefixChars = 0;\n" "let _streamFadeReduceMotionMql = null;\n" "let _streamFadeReduceMotion = false;\n" "let _streamFadeReduceMotionOnChange = null;\n" diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index 6950936b..25a01231 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -1,5 +1,6 @@ import re import subprocess +import tempfile from pathlib import Path REPO = Path(__file__).resolve().parents[1] @@ -86,6 +87,7 @@ let _streamFadeVisibleWords=0; let _streamFadeHoldUntilMs=0; let _streamFadeCurrentMs=620; let _streamFadeDomText=''; +let _streamFadeSilentPrefixChars=0; const _STREAM_FADE_MS=620; const _STREAM_FADE_MAX_MS=900; const _STREAM_FADE_DONE_MAX_MS=1000; @@ -96,6 +98,25 @@ const performance={performance_stub}; def run_node(script: str) -> subprocess.CompletedProcess[str]: + # Windows cmdline length limit (~32K): long extracted function blocks + # (e.g. _smdWrite with its comment block) overflow `node -e`. Write long + # scripts to a temp file and run `node ` instead. + if len(script) > 24000: + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f: + f.write(script) + tmp_path = f.name + try: + result = subprocess.run( + ["node", tmp_path], + cwd=REPO, + text=True, + capture_output=True, + check=False, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + assert result.returncode == 0, result.stderr + return result result = subprocess.run( ["node", "-e", script], cwd=REPO, @@ -218,6 +239,7 @@ def test_stream_fade_appends_new_spans_without_replacing_existing_nodes(): const _STREAM_FADE_MS=620; let _streamFadeLatestAnimationEndAt=0; let _streamFadeCurrentMs=620; +let _streamFadeSilentPrefixChars=0; const performance={_t:0,now(){return this._t;}}; function _streamFadeReduceMotionEnabled(){ return false; } class FakeNode{ @@ -557,3 +579,257 @@ if(afterBreak.changed) throw new Error('expected paragraph pause to hold next re """ ) run_node(script) + + +def test_stream_fade_rewind_keeps_common_prefix_visible(): + """A display-text REWIND (tool-call XML stripped mid-stream) must not reset + the playout to zero — that would replay the fade on every already-visible + word and produce a full-message blink per tool call (#fade-flash).""" + script = ( + fade_helper_script() + + r""" +// Phase 1: play out a sentence normally. +let out=_streamFadeNextText('alpha beta gamma delta'); +for(let frame=0;frame<60&&!out.caughtUp;frame++){ + performance._t += 33; + out=_streamFadeNextText('alpha beta gamma delta'); +} +if(!out.caughtUp) throw new Error(`never caught up: ${JSON.stringify(out.text)}`); +const fullText=out.text; +// Phase 2: the stream text REWINDS (e.g. stripped): the +// target becomes a strict prefix of what was already shown. +performance._t += 33; +out=_streamFadeNextText('alpha beta'); +// The playout must stay at the common prefix, NOT reset to ''. +if(!out.text.startsWith('alpha beta')) throw new Error(`rewind dropped prefix: ${JSON.stringify(out.text)}`); +if(out.text.length>fullText.length) throw new Error(`rewind grew text: ${JSON.stringify(out.text)}`); +if(!out.caughtUp) throw new Error(`rewind to prefix should be caught up, got changed=${out.changed}`); +""" + ) + run_node(script) + + +def test_stream_fade_rewind_remount_mutes_common_prefix(): + """When the DOM must be rebuilt after a rewind (smd self-heal), words inside + the common prefix must be written as plain text so their fade animation is + not replayed; only the post-rewind tail may animate.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeAppendText") + + r""" +const _STREAM_FADE_MS=620; +let _streamFadeLatestAnimationEndAt=0; +let _streamFadeCurrentMs=620; +let _streamFadeSilentPrefixChars=0; +const performance={_t:0,now(){return this._t;}}; +function _streamFadeReduceMotionEnabled(){ return false; } +class FakeNode{ + constructor(type,text=''){ + this.type=type; + this.children=[]; + this.className=''; + this.textContent=text; + this.style={values:{},setProperty:(name,value)=>{this.style.values[name]=value;}}; + } + appendChild(child){ + if(child&&child.type==='fragment'){ + child.children.forEach(n=>this.children.push(n)); + }else{ + this.children.push(child); + } + return child; + } +} +global.document={ + createDocumentFragment(){ return new FakeNode('fragment'); }, + createTextNode(text){ return new FakeNode('text',String(text)); }, + createElement(tag){ const node=new FakeNode(tag); node.tagName=String(tag).toUpperCase(); return node; }, +}; +const body=new FakeNode('div'); +// Simulate a rebuild: the rewind-triggered self-heal mutes the common prefix +// ("alpha beta" = 10 chars) so those words must NOT become animated spans. +_streamFadeSilentPrefixChars=10; +_streamFadeAppendText(body,'alpha beta gamma'); +const spans=body.children.filter(node=>node.className==='stream-fade-word is-new'); +if(spans.length!==1) throw new Error(`expected exactly 1 animated span, got ${spans.length}`); +if(spans[0].textContent!=='gamma') throw new Error(`wrong animated word: ${spans[0].textContent}`); +const plain=body.children.filter(node=>node.type==='text').map(n=>n.textContent).join(''); +if(!plain.includes('alpha beta')) throw new Error(`common prefix not written as plain text: ${plain}`); +if(_streamFadeSilentPrefixChars!==0) throw new Error(`silent prefix not consumed: ${_streamFadeSilentPrefixChars}`); +""" + ) + run_node(script) + + +def test_stream_fade_smd_write_self_heal_sets_silent_prefix_on_rewind(): + """_smdWrite's self-heal rebuild (triggered when the display text REWINDS, + e.g. tool-call XML stripped mid-stream) must mute the rebuild's rendered + common prefix. Without this the cleared + rebuilt body would replay the + fade on every already-visible word — the full-message blink. The prefix is + computed in RENDERED-text space (old node text vs new node text), never in + source space (#6783 review).""" + write_block = function_block(MESSAGES_JS, "_smdWrite") + script = ( + write_block + + r""" +let _smdParser=null; +let _smdWrittenLen=0; +let _smdWrittenText=''; +let _streamFadeSilentPrefixChars=0; +let rebuildCalls=0; +let muteCalls=[]; +const writes=[]; +global.window={ + smd:{ + parser(renderer){ return { renderer }; }, + parser_write(parser,delta){ writes.push(String(delta)); }, + }, +}; +function _streamFadeMuteRenderedPrefix(el, prev){ muteCalls.push(prev); } +const assistantBody={ innerHTML:'', textContent:'' }; +function _smdNewParser(el, fade){ _smdParser={fresh:fade?true:false}; rebuildCalls+=1; } +function _scheduleStreamingKatex(){} +// Phase 0: parser already attached (as after _smdNewParser on stream start). +_smdParser={}; +// Phase 1: normal incremental write of the full text. +_smdWrite('alpha beta gamma', true); +if(_smdWrittenText!=='alpha beta gamma') throw new Error(`phase1 writtenText wrong: ${_smdWrittenText}`); +if(rebuildCalls!==0) throw new Error('phase1 must not rebuild'); +// Phase 2: the display text REWINDS to a strict prefix (tool-call XML tail +// stripped). Simulate the rendered body state (what smd already painted) +// before the rebuild; the self-heal must snapshot it and hand it to +// _streamFadeMuteRenderedPrefix (rendered-space mute). +assistantBody.textContent='alpha beta gamma'; +_smdWrite('alpha beta', true); +if(rebuildCalls!==1) throw new Error(`expected 1 rebuild, got ${rebuildCalls}`); +if(muteCalls.length!==1) throw new Error(`expected 1 mute call, got ${muteCalls.length}`); +if(muteCalls[0]!=='alpha beta gamma') throw new Error(`mute prev wrong: ${JSON.stringify(muteCalls[0])}`); +if(_smdWrittenText!=='alpha beta') throw new Error(`phase2 writtenText wrong: ${_smdWrittenText}`); +// Phase 3: after the tool call completes, new text continues past the rewind +// point; the written delta must start exactly at the rewind point (no replay +// of the prefix). No rewind now → no mute call. +writes.length=0; +muteCalls.length=0; +_smdWrite('alpha beta new tail', true); +if(writes.length!==1||writes[0]!==' new tail') throw new Error(`delta wrong: ${JSON.stringify(writes)}`); +if(muteCalls.length!==0) throw new Error(`phase3 must not mute, got ${muteCalls.length}`); +""" + ) + run_node(script) + + +def _fade_fake_node(tag='div', text=''): + """Fake DOM node with dynamic textContent (sums descendant text), parentNode + backlinks and classList — enough for _streamFadeMuteRenderedPrefix.""" + return """ +class FakeNode{ + constructor(tag='div',text=''){ + this.tagName=String(tag).toUpperCase(); + this.nodeType=(tag==='#text')?3:1; + this.type=(tag==='#text')?'text':undefined; + this.children=[]; + this.parentNode=null; + this.className=''; + this._text=text; + if(text!=='' && tag!=='#text'){ this.appendChild(new FakeNode('#text',text)); } + } + appendChild(child){ child.parentNode=this; this.children.push(child); return child; } + get childNodes(){ return this.children; } + get textContent(){ + if(this.nodeType===3) return this._text; + let s=''; + for(const c of this.children) s+=c.textContent; + return s; + } + set textContent(v){ this._text=String(v); } + get classList(){ + const self=this; + return { + contains(c){ return (' '+self.className+' ').indexOf(' '+c+' ')>-1; }, + remove(c){ self.className=(' '+self.className+' ').replace(' '+c+' ',' ').trim(); }, + }; + } +} +""" + + +def test_stream_fade_mute_rendered_prefix_plain_words(): + """Rendered-space mute: only spans fully inside the rendered common prefix + lose is-new; the first genuinely new word keeps its fade span. Old text + 'alpha beta' vs new 'alpha beta gamma' → alpha+beta plain, gamma animated.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +const body=new FakeNode('div'); +const a=new FakeNode('span','alpha'); a.className='stream-fade-word is-new'; +const sp1=new FakeNode('#text',' '); +const b=new FakeNode('span','beta'); b.className='stream-fade-word is-new'; +const sp2=new FakeNode('#text',' '); +const g=new FakeNode('span','gamma'); g.className='stream-fade-word is-new'; +body.appendChild(a); body.appendChild(sp1); body.appendChild(b); +body.appendChild(sp2); body.appendChild(g); +// Rebuild scenario: old rendered text was 'alpha beta' (rewind point), new +// rendered text is 'alpha beta gamma'. Common prefix = 'alpha beta ' (11). +_streamFadeMuteRenderedPrefix(body,'alpha beta'); +if(a.classList.contains('is-new')) throw new Error('alpha must be muted'); +if(b.classList.contains('is-new')) throw new Error('beta must be muted'); +if(!g.classList.contains('is-new')) throw new Error('gamma must stay animated'); +""" + ) + run_node(script) + + +def test_stream_fade_mute_rendered_prefix_markdown_and_media_bytes(): + """Regression for the #6783 blocker: source-space budgets over-mute because + markdown delimiters (`**alpha**`) and MEDIA tokens add bytes that never + reach the fade add_text hook. The mute must operate on RENDERED text only: + old rendered 'alpha beta' (from `**alpha** beta`) rewritten to `**alpha** + gamma` renders 'alpha gamma' → alpha muted, gamma animated.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +const body=new FakeNode('div'); +const a=new FakeNode('span','alpha'); a.className='stream-fade-word is-new'; +const sp=new FakeNode('#text',' '); +const g=new FakeNode('span','gamma'); g.className='stream-fade-word is-new'; +body.appendChild(a); body.appendChild(sp); body.appendChild(g); +// Rendered common prefix 'alpha ' = 6 chars. A SOURCE-space compare of +// `**alpha** beta` vs `**alpha** gamma` would count 10 (4 delimiters + +// 'alpha' + space) and mute gamma too — exactly the bug this test locks in. +_streamFadeMuteRenderedPrefix(body,'alpha beta'); +if(a.classList.contains('is-new')) throw new Error('alpha must be muted'); +if(!g.classList.contains('is-new')) throw new Error('gamma must stay animated (source-space budget bug)'); +""" + ) + run_node(script) + + +def test_stream_fade_mute_rendered_prefix_scoped_per_root(): + """The mute is scoped to the passed root: muting one parser's node must not + touch another parser's node (main vs anchor prose). Two concurrent roots + with identical text content stay independent.""" + script = ( + function_block(MESSAGES_JS, "_streamFadeMuteRenderedPrefix") + + _fade_fake_node() + + r""" +function buildBody(){ + const body=new FakeNode('div'); + const w1=new FakeNode('span','alpha'); w1.className='stream-fade-word is-new'; + const sp=new FakeNode('#text',' '); + const w2=new FakeNode('span','beta'); w2.className='stream-fade-word is-new'; + body.appendChild(w1); body.appendChild(sp); body.appendChild(w2); + return body; +} +const main=buildBody(); +const anchor=buildBody(); +_streamFadeMuteRenderedPrefix(main,'alpha'); // common prefix 'alpha' (5) +// main: alpha muted, beta animated. +if(main.children[0].classList.contains('is-new')) throw new Error('main alpha must be muted'); +if(!main.children[2].classList.contains('is-new')) throw new Error('main beta must stay animated'); +// anchor: untouched. +if(!anchor.children[0].classList.contains('is-new')) throw new Error('anchor must be untouched'); +if(!anchor.children[2].classList.contains('is-new')) throw new Error('anchor must be untouched'); +""" + ) + run_node(script)