* 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 (<function_calls>) 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 <silent-reader-cn@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
836 lines
33 KiB
Python
836 lines
33 KiB
Python
import re
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
|
|
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
|
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
|
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
|
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
|
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
|
|
|
FADE_SETTING = "fade_text_effect"
|
|
FADE_CHECKBOX_ID = "settingsFadeTextEffect"
|
|
FADE_RUNTIME_FLAG = "window._fadeTextEffect"
|
|
FADE_LABEL_KEY = "settings_label_fade_text_effect"
|
|
FADE_DESC_KEY = "settings_desc_fade_text_effect"
|
|
|
|
|
|
def function_block(src: str, name: str) -> str:
|
|
marker = re.search(rf"(^|\n)\s*(?:async\s+)?function\s+{re.escape(name)}\(", src)
|
|
assert marker is not None, f"{name}() not found"
|
|
start = marker.start()
|
|
brace = src.find("{", marker.end())
|
|
assert brace != -1, f"{name}() opening brace not found"
|
|
|
|
depth = 0
|
|
in_string = None
|
|
escape = False
|
|
for i in range(brace, len(src)):
|
|
ch = src[i]
|
|
if in_string:
|
|
if escape:
|
|
escape = False
|
|
elif ch == "\\":
|
|
escape = True
|
|
elif ch == in_string:
|
|
in_string = None
|
|
continue
|
|
if ch in "'`\"":
|
|
in_string = ch
|
|
continue
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start : i + 1]
|
|
raise AssertionError(f"{name}() closing brace not found")
|
|
|
|
|
|
def assert_contains_all(src: str, snippets: list[str]) -> None:
|
|
for snippet in snippets:
|
|
assert snippet in src
|
|
|
|
|
|
def slice_between(src: str, start_anchor: str, end_anchor: str) -> str:
|
|
start = src.find(start_anchor)
|
|
assert start != -1, f"start anchor not found: {start_anchor!r}"
|
|
end = src.find(end_anchor, start + len(start_anchor))
|
|
assert end != -1, f"end anchor not found after {start_anchor!r}: {end_anchor!r}"
|
|
return src[start:end]
|
|
|
|
|
|
def fade_helper_script(performance_stub: str = "{_t:0,now(){return this._t;}}") -> str:
|
|
helpers = "\n".join(
|
|
function_block(MESSAGES_JS, name)
|
|
for name in [
|
|
"_streamFadeWordCountOf",
|
|
"_streamFadePauseAfter",
|
|
"_resetStreamFadeState",
|
|
"_streamFadeNextText",
|
|
]
|
|
)
|
|
return f"""
|
|
let _streamFadeVisibleText='';
|
|
let _streamFadeLastTickMs=0;
|
|
let _streamFadeWordCarry=0;
|
|
let _streamFadeStartedAt=0;
|
|
let _streamFadeLastTargetWords=0;
|
|
let _streamFadeLastArrivalMs=0;
|
|
let _streamFadeArrivalWps=0;
|
|
let _streamFadeLatestAnimationEndAt=0;
|
|
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;
|
|
const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400;
|
|
const performance={performance_stub};
|
|
{helpers}
|
|
"""
|
|
|
|
|
|
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 <file>` 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,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return result
|
|
|
|
|
|
def test_fade_text_effect_setting_is_wired_through_backend_and_startup():
|
|
bool_keys = CONFIG_PY[CONFIG_PY.index("_SETTINGS_BOOL_KEYS") : CONFIG_PY.index("# Language codes")]
|
|
assert f'"{FADE_SETTING}": False' in CONFIG_PY
|
|
assert f'"{FADE_SETTING}"' in bool_keys
|
|
assert f"{FADE_RUNTIME_FLAG}=!!s.{FADE_SETTING}" in BOOT_JS
|
|
assert f"{FADE_RUNTIME_FLAG}=false" in BOOT_JS
|
|
|
|
|
|
def test_preferences_ui_exposes_and_saves_fade_text_effect():
|
|
assert f'id="{FADE_CHECKBOX_ID}"' in INDEX_HTML
|
|
assert f'data-i18n="{FADE_LABEL_KEY}"' in INDEX_HTML
|
|
assert f'data-i18n="{FADE_DESC_KEY}"' in INDEX_HTML
|
|
assert FADE_LABEL_KEY in I18N_JS
|
|
assert FADE_DESC_KEY in I18N_JS
|
|
|
|
payload_block = function_block(PANELS_JS, "_preferencesPayloadFromUi")
|
|
assert_contains_all(payload_block, [f"$('{FADE_CHECKBOX_ID}')", f"payload.{FADE_SETTING}="])
|
|
|
|
load_block = function_block(PANELS_JS, "loadSettingsPanel")
|
|
fade_load = load_block[load_block.index(f"$('{FADE_CHECKBOX_ID}')") :]
|
|
assert_contains_all(
|
|
fade_load[:700],
|
|
[f"settings.{FADE_SETTING}", FADE_RUNTIME_FLAG, "addEventListener('change',_schedulePreferencesAutosave"],
|
|
)
|
|
|
|
autosave_block = function_block(PANELS_JS, "_autosavePreferencesSettings")
|
|
assert_contains_all(autosave_block, [FADE_SETTING, f"{FADE_RUNTIME_FLAG}=!!payload.{FADE_SETTING}"])
|
|
|
|
save_block = function_block(PANELS_JS, "saveSettings")
|
|
assert_contains_all(save_block, [FADE_CHECKBOX_ID, f"body.{FADE_SETTING}", "fadeTextEffect"])
|
|
|
|
apply_block = function_block(PANELS_JS, "_applySavedSettingsUi")
|
|
assert_contains_all(apply_block, ["fadeTextEffect", f"{FADE_RUNTIME_FLAG}=!!fadeTextEffect"])
|
|
|
|
|
|
def test_stream_fade_uses_incremental_renderer_without_changing_default_path():
|
|
# _scheduleRender is deeply nested inside attachLiveStream; the simple
|
|
# brace-counting function_block parser can't handle template literals
|
|
# with ${...} that contain braces. Use the full file for assertions
|
|
# instead — the checked strings are unique enough.
|
|
assert re.search(r"function\s+_scheduleRender\(", MESSAGES_JS)
|
|
render_block = function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown")
|
|
renderer_block = function_block(MESSAGES_JS, "_streamFadeRenderer")
|
|
cleanup_block = function_block(MESSAGES_JS, "_streamFadeBindCleanup")
|
|
|
|
assert_contains_all(
|
|
MESSAGES_JS,
|
|
[
|
|
"_renderStreamingFadeMarkdown(displayText)",
|
|
"_smdWrite(displayText)",
|
|
"?33:66",
|
|
],
|
|
)
|
|
assert_contains_all(
|
|
render_block,
|
|
[
|
|
"_streamFadeNextText(displayText)",
|
|
"if(!next.changed) return next.caughtUp",
|
|
"if(!_shouldUseTransparentStreamFade())",
|
|
"_smdNewParser(assistantBody,true)",
|
|
"_smdWrite(next.text,true)",
|
|
"_sanitizeSmdLinks(assistantBody)",
|
|
"assistantBody.appendChild(document.createTextNode(delta))",
|
|
"_streamFadeDomText=String(next.text||'')",
|
|
"stream-fade-active",
|
|
],
|
|
)
|
|
assert render_block.index("_smdWrite(next.text,true)") < render_block.index(
|
|
"assistantBody.appendChild(document.createTextNode(delta))"
|
|
)
|
|
assert "_streamFadeAppendText(assistantBody,delta)" not in render_block
|
|
assert "_streamFadeBindCleanup(assistantBody)" not in render_block
|
|
append_block = function_block(MESSAGES_JS, "_streamFadeAppendText")
|
|
assert_contains_all(
|
|
append_block,
|
|
[
|
|
"document.createDocumentFragment()",
|
|
"span.className='stream-fade-word is-new'",
|
|
"el.appendChild(frag)",
|
|
"_streamFadeLatestAnimationEndAt",
|
|
],
|
|
)
|
|
assert_contains_all(
|
|
renderer_block,
|
|
[
|
|
"span.className='stream-fade-word is-new'",
|
|
"_streamFadeReduceMotionEnabled()",
|
|
"const appendStartedAt=performance.now()",
|
|
"--stream-fade-ms",
|
|
"renderer.set_attr",
|
|
"data-blocked-scheme",
|
|
"_streamFadeLatestAnimationEndAt",
|
|
],
|
|
)
|
|
assert_contains_all(
|
|
cleanup_block,
|
|
["animationend", "span.replaceWith(document.createTextNode"],
|
|
)
|
|
assert "_wrapStreamingFadeWords" not in MESSAGES_JS
|
|
assert "animationDelay" not in renderer_block
|
|
assert "_STREAM_FADE_STAGGER_MS" not in MESSAGES_JS
|
|
assert "_streamFadeAppendOffset" not in MESSAGES_JS
|
|
|
|
|
|
def test_stream_fade_appends_new_spans_without_replacing_existing_nodes():
|
|
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');
|
|
_streamFadeAppendText(body,'alpha beta ');
|
|
const firstSpan=body.children.find(node=>node.className==='stream-fade-word is-new');
|
|
if(!firstSpan) throw new Error('missing first fade span');
|
|
_streamFadeAppendText(body,'gamma');
|
|
if(body.children.find(node=>node.className==='stream-fade-word is-new')!==firstSpan){
|
|
throw new Error('first span was replaced');
|
|
}
|
|
const spans=body.children.filter(node=>node.className==='stream-fade-word is-new');
|
|
if(spans.length!==3) throw new Error(`expected three animated spans, got ${spans.length}`);
|
|
if(spans.map(node=>node.textContent).join('|')!=='alpha|beta|gamma'){
|
|
throw new Error(`wrong span text: ${spans.map(node=>node.textContent).join('|')}`);
|
|
}
|
|
"""
|
|
)
|
|
run_node(script)
|
|
|
|
|
|
def test_transparent_anchor_prose_uses_fade_renderer_when_enabled():
|
|
anchor_block = function_block(MESSAGES_JS, "_anchorProseIncrementalNode")
|
|
predicate_block = function_block(MESSAGES_JS, "_shouldUseLiveProseFade")
|
|
assert_contains_all(
|
|
anchor_block,
|
|
[
|
|
"const fade=typeof _shouldUseLiveProseFade==='function'&&_shouldUseLiveProseFade()",
|
|
"if(st && st.fade!==fade) st=null",
|
|
"if(body.classList) body.classList.toggle('stream-fade-active',fade)",
|
|
"const baseRenderer=fade?_streamFadeRenderer(body):_safeSmdRenderer(body)",
|
|
"st={node,parser:window.smd.parser(renderer),writtenText:'',fade}",
|
|
"const body=st.node&&st.node.querySelector&&st.node.querySelector('.msg-body')",
|
|
],
|
|
)
|
|
assert_contains_all(
|
|
predicate_block,
|
|
[
|
|
"!_streamFadeReduceMotionEnabled()",
|
|
"_shouldUseStreamFade()",
|
|
"_shouldUseTransparentStreamFade()",
|
|
],
|
|
)
|
|
assert "function _shouldUseTransparentStreamFade()" in MESSAGES_JS
|
|
assert "typeof isTransparentStream==='function'&&isTransparentStream()" in MESSAGES_JS
|
|
|
|
|
|
def test_reduced_motion_disables_live_prose_fade_predicate():
|
|
script = (
|
|
"\n".join(
|
|
function_block(MESSAGES_JS, name)
|
|
for name in [
|
|
"_shouldUseStreamFade",
|
|
"_shouldUseTransparentStreamFade",
|
|
"_streamFadeReduceMotionEnabled",
|
|
"_shouldUseLiveProseFade",
|
|
]
|
|
)
|
|
+ r"""
|
|
let _streamFadeReduceMotionMql=null;
|
|
let _streamFadeReduceMotion=false;
|
|
let _streamFadeReduceMotionOnChange=null;
|
|
let transparent=true;
|
|
let reduceMotion=true;
|
|
global.window={
|
|
_fadeTextEffect:true,
|
|
matchMedia(){
|
|
return {
|
|
get matches(){ return reduceMotion; },
|
|
addEventListener(){},
|
|
removeEventListener(){},
|
|
};
|
|
},
|
|
};
|
|
function isTransparentStream(){ return transparent; }
|
|
if(_shouldUseLiveProseFade()) throw new Error('reduced motion allowed live prose fade');
|
|
_streamFadeReduceMotionMql=null;
|
|
reduceMotion=false;
|
|
window._fadeTextEffect=false;
|
|
if(!_shouldUseLiveProseFade()) throw new Error('transparent stream fade should work when motion is allowed');
|
|
_streamFadeReduceMotionMql=null;
|
|
transparent=false;
|
|
window._fadeTextEffect=true;
|
|
if(!_shouldUseLiveProseFade()) throw new Error('regular fade preference should work when motion is allowed');
|
|
"""
|
|
)
|
|
run_node(script)
|
|
|
|
|
|
def test_transparent_stream_hidden_body_appends_plain_text_only():
|
|
script = (
|
|
function_block(MESSAGES_JS, "_renderStreamingFadeMarkdown")
|
|
+ r"""
|
|
let _streamFadeDomText='';
|
|
let _smdParser=null;
|
|
let _smdReconnect=false;
|
|
let parserEnded=false;
|
|
function _streamFadeNextText(){ return {changed:true,caughtUp:false,text:'alpha beta'}; }
|
|
function _shouldUseTransparentStreamFade(){ return true; }
|
|
function _smdEndParser(){ parserEnded=true; }
|
|
const assistantBody={
|
|
textContent:'',
|
|
innerHTML:'',
|
|
children:[],
|
|
classList:{added:[],add(name){ this.added.push(name); }},
|
|
appendChild(node){
|
|
this.children.push(node);
|
|
this.textContent += String(node.textContent || '');
|
|
return node;
|
|
},
|
|
};
|
|
global.document={
|
|
createTextNode(text){ return {type:'text',textContent:String(text)}; },
|
|
};
|
|
const caughtUp=_renderStreamingFadeMarkdown('alpha beta');
|
|
if(caughtUp) throw new Error('expected fade playout to remain catching up');
|
|
if(assistantBody.textContent!=='alpha beta') throw new Error(`wrong hidden text: ${assistantBody.textContent}`);
|
|
if(_streamFadeDomText!=='alpha beta') throw new Error(`wrong dom text: ${_streamFadeDomText}`);
|
|
if(assistantBody.children.some(node=>node.className==='stream-fade-word is-new')){
|
|
throw new Error('hidden body received fade span');
|
|
}
|
|
if(!assistantBody.classList.added.includes('stream-fade-active')) throw new Error('missing stream fade active marker');
|
|
"""
|
|
)
|
|
run_node(script)
|
|
|
|
|
|
def test_transparent_anchor_prose_receives_revealed_fade_text():
|
|
render_section = slice_between(
|
|
MESSAGES_JS,
|
|
"const displayText = segmentStart===0",
|
|
"scrollIfPinned();",
|
|
)
|
|
assert_contains_all(
|
|
render_section,
|
|
[
|
|
"let anchorProcessText=displayText",
|
|
"if(assistantBody){",
|
|
"const caughtUp=_renderStreamingFadeMarkdown(displayText)",
|
|
"if(_shouldUseLiveProseFade())",
|
|
"anchorProcessText=_streamFadeDomText||''",
|
|
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)",
|
|
],
|
|
)
|
|
assert render_section.index("let anchorProcessText=displayText") < render_section.index("if(assistantBody){")
|
|
assert render_section.index("anchorProcessText=_streamFadeDomText||''") < render_section.index(
|
|
"_upsertAnchorProcessProse(anchorProcessText)"
|
|
)
|
|
assert render_section.index("if(assistantBody){") < render_section.rindex(
|
|
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText)"
|
|
)
|
|
|
|
|
|
def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses():
|
|
drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone")
|
|
assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=1400" in MESSAGES_JS
|
|
assert_contains_all(
|
|
drain_block,
|
|
[
|
|
"const drainStartedAt=performance.now();",
|
|
"const target=_streamFadeCurrentDisplayText();",
|
|
"const caughtUp=_renderStreamingFadeMarkdown(target);",
|
|
"const anchorProcessText=_streamFadeDomText||target;",
|
|
"if(anchorProcessText) _upsertAnchorProcessProse(anchorProcessText);",
|
|
"performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS",
|
|
"if(_smdParser) _smdEndParser();",
|
|
"onDone();",
|
|
],
|
|
)
|
|
assert drain_block.index("_renderStreamingFadeMarkdown(target)") < drain_block.index(
|
|
"_upsertAnchorProcessProse(anchorProcessText)"
|
|
)
|
|
|
|
|
|
def test_live_streaming_assistant_content_opts_out_of_global_theme_transitions():
|
|
"""Per-token markdown rewrites must not inherit global div color/background fades.
|
|
|
|
The global theme transition is useful for dark/light switches, but live
|
|
assistant DOM updates happen for every streamed token. If those live nodes
|
|
inherit color/background transitions, light themes visibly flash/fade on
|
|
each word.
|
|
"""
|
|
live_transition_guard = slice_between(
|
|
STYLE_CSS,
|
|
"Live assistant content is updated token-by-token",
|
|
":root{--app-titlebar-safe-top",
|
|
)
|
|
assert_contains_all(
|
|
live_transition_guard,
|
|
[
|
|
"#liveAssistantTurn *",
|
|
"#thinkingRow *",
|
|
'.assistant-segment[data-live-assistant="1"] *',
|
|
'.agent-activity-thinking[data-thinking-active="1"] *',
|
|
'.agent-activity-thinking[data-live-thinking="1"] *',
|
|
'.live-worklog[data-live-worklog-shell="1"] *',
|
|
"transition-property:none!important",
|
|
"transition-duration:0s!important",
|
|
"transition-delay:0s!important",
|
|
],
|
|
)
|
|
|
|
|
|
def test_stream_fade_css_is_opacity_only_and_hides_live_cursor():
|
|
fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :]
|
|
assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
|
|
"[data-live-assistant", 1
|
|
)[0]
|
|
assert "translateY" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split(
|
|
"[data-live-assistant", 1
|
|
)[0]
|
|
assert_contains_all(
|
|
fade_css,
|
|
[
|
|
"@keyframes stream-fade-word-in",
|
|
".stream-fade-word.is-new",
|
|
"var(--stream-fade-ms,620ms) cubic-bezier(.16,.84,.32,1)",
|
|
"35%{opacity:.18;}",
|
|
"70%{opacity:.72;}",
|
|
"prefers-reduced-motion: reduce",
|
|
".msg-body.stream-fade-active > :last-child::after",
|
|
"display:none",
|
|
"content:none",
|
|
],
|
|
)
|
|
assert ".stream-fade-active .stream-fade-word{display:inline;}" in fade_css
|
|
|
|
|
|
def test_stream_fade_reduced_motion_listener_is_cleaned_up_on_terminal_paths():
|
|
assert "_streamFadeReduceMotionOnChange" in MESSAGES_JS
|
|
assert "function _streamFadeCleanupReduceMotionListener()" in MESSAGES_JS
|
|
assert "removeEventListener('change',_streamFadeReduceMotionOnChange)" in MESSAGES_JS
|
|
assert "removeListener(_streamFadeReduceMotionOnChange)" in MESSAGES_JS
|
|
assert MESSAGES_JS.count("_streamFadeCleanupReduceMotionListener();") >= 4
|
|
|
|
|
|
def test_stream_fade_duration_scales_up_with_playback_speed():
|
|
script = (
|
|
fade_helper_script()
|
|
+ r"""
|
|
const words=Array.from({length:260},(_,i)=>'w'+i).join(' ');
|
|
performance._t += 33;
|
|
let out=_streamFadeNextText('slow start');
|
|
if(!out.changed) throw new Error('expected initial reveal');
|
|
if(_streamFadeCurrentMs !== 620) throw new Error(`expected base fade 620ms, got ${_streamFadeCurrentMs}`);
|
|
for(let frame=0;frame<20&&_streamFadeCurrentMs<900;frame++){
|
|
performance._t += 120;
|
|
out=_streamFadeNextText(words);
|
|
}
|
|
if(_streamFadeCurrentMs !== 900) throw new Error(`expected max fade 900ms, got ${_streamFadeCurrentMs}`);
|
|
"""
|
|
)
|
|
run_node(script)
|
|
|
|
|
|
def test_stream_fade_playout_handles_fast_models_without_paragraph_pops():
|
|
script = (
|
|
fade_helper_script()
|
|
+ r"""
|
|
const words=Array.from({length:240},(_,i)=>'w'+i);
|
|
let shown=0;
|
|
let targetCount=0;
|
|
for(let frame=0;frame<240;frame++){
|
|
performance._t += 16;
|
|
// Simulate sustained fast generation: ~40 words/sec arriving.
|
|
targetCount = Math.min(words.length, Math.floor(performance._t/1000*40));
|
|
const out=_streamFadeNextText(words.slice(0,targetCount).join(' '));
|
|
shown=(out.text.match(/\S+/g)||[]).length;
|
|
}
|
|
const backlog=targetCount-shown;
|
|
if(shown < 145) throw new Error(`too slow: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
|
|
if(backlog > 15) throw new Error(`did not catch up: shown=${shown} target=${targetCount} backlog=${backlog} arrivalWps=${_streamFadeArrivalWps}`);
|
|
const huge=Array.from({length:500},(_,i)=>'b'+i).join(' ');
|
|
let previous=0;
|
|
for(let frame=0;frame<40;frame++){
|
|
performance._t += 16;
|
|
const out=_streamFadeNextText(huge);
|
|
const shown=(out.text.match(/\S+/g)||[]).length;
|
|
const revealed=shown-previous;
|
|
previous=shown;
|
|
if(revealed>3) throw new Error(`revealed too much in one frame: ${revealed}`);
|
|
}
|
|
if(previous<50) throw new Error(`too slow under large backlog: ${previous}`);
|
|
"""
|
|
)
|
|
run_node(script)
|
|
|
|
|
|
def test_stream_fade_respects_sentence_and_paragraph_boundaries():
|
|
script = (
|
|
fade_helper_script()
|
|
+ r"""
|
|
const target='alpha beta gamma\n\nsecond paragraph starts here\n\nthird paragraph starts here';
|
|
performance._t += 200;
|
|
let out=_streamFadeNextText(target);
|
|
const breaks=(out.text.match(/\n\s*\n/g)||[]).length;
|
|
if(breaks>1) throw new Error(`revealed multiple paragraph breaks: ${JSON.stringify(out.text)}`);
|
|
_resetStreamFadeState();
|
|
const pausedTarget='alpha beta.\n\nsecond paragraph starts here';
|
|
out={text:''};
|
|
for(let frame=0;frame<8&&!out.text.includes('.');frame++){
|
|
performance._t += 33;
|
|
out=_streamFadeNextText(pausedTarget);
|
|
}
|
|
if(!out.text.includes('.')) throw new Error(`expected first sentence: ${JSON.stringify(out.text)}`);
|
|
const held=_streamFadeNextText(pausedTarget);
|
|
if(held.changed) throw new Error('expected sentence pause to hold next reveal');
|
|
performance._t += 50;
|
|
for(let frame=0;frame<8&&!out.text.includes('\n\n');frame++){
|
|
performance._t += 33;
|
|
out=_streamFadeNextText(pausedTarget);
|
|
}
|
|
if(!out.text.includes('\n\n')) throw new Error(`expected paragraph break: ${JSON.stringify(out.text)}`);
|
|
const afterBreak=_streamFadeNextText(pausedTarget);
|
|
if(afterBreak.changed) throw new Error('expected paragraph pause to hold next reveal');
|
|
"""
|
|
)
|
|
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. <function_calls> 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)
|