Files
hermes-webui/tests/test_issue3877_midstream_flicker.py
T
nesquena-hermesandnesquena-hermes e8d71a2fb8 Release v0.51.347 — Release LK (streaming & render reliability cluster #3892 #3898 #3885 #3882 #3868) (#3902)
* stage v0.51.347: render/stream cluster (#3892 #3898 #3885 #3882 #3868) + 2 Opus SHOULD-FIX

* stage v0.51.347: trim #3885 error-guard comment to fit diagnostic-test window

* Stamp v0.51.347 — Release LK (streaming & render reliability cluster)

* Remove stray uv.lock accidentally staged (not part of any cluster PR)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-09 17:02:35 -07:00

118 lines
6.1 KiB
Python

"""Regression coverage for #3877 — mid-stream transcript flicker (content disappears/reappears).
#3877: in long sessions, during streaming, the latest assistant reply content flickers —
disappears and then reappears moments later.
Root cause: the live streaming text is written by the ``smd`` parser into a DOM node
*inside* ``#liveAssistantTurn``. When ``renderMessages()`` is reached mid-stream — e.g.
the clarify-response echo (``messages.js``) or a CLI-import refresh push a render while a
stream is live — its unconditional ``inner.innerHTML=''`` rebuild DETACHES that node. The
parser keeps writing into the now-orphaned element, so the visible content vanishes until
the next stream event rebuilds ``#liveAssistantTurn`` from scratch (the "disappears, then
reappears" frame).
Fix: before the ``inner.innerHTML=''`` rebuild, capture the live ``#liveAssistantTurn``
DOM node (the actual node — the parser holds a live reference into it, so serialising to
HTML would not help). After the rebuild, if the freshly-rebuilt live turn has LESS
streamed text than the preserved node (because the live assistant message's content is
still empty in ``S.messages`` until the stream settles), swap the preserved node back in
via ``_mergeRestoredLiveAssistantSegment`` so the parser target stays connected and the
visible text never blanks. Only ever runs for the streaming session's own live turn
(``INFLIGHT[sid]`` gate); a settled transcript with no live turn is untouched.
Verified RED→GREEN in an isolated browser against the real shipped ``renderMessages``:
- RED (master): after a mid-stream ``renderMessages({preserveScroll:true})``, the parser
node is detached (``isConnected === false``), the rebuilt live turn shows only
"Running" with no streamed text, and further tokens write into an orphaned node.
- GREEN (fix): the parser node stays connected, the same live node is preserved across
the rebuild, the streamed text remains visible, and tokens written after the rebuild
still land in the visible node. A settled (non-streaming) session renders normally
(2 assistant turns + 2 user rows, both answers visible) — the fix is a no-op there.
These are static source-structure assertions over the shipped ``renderMessages`` so the
invariant cannot silently regress.
"""
from __future__ import annotations
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}("
start = src.find(marker)
assert start != -1, f"{name} not found"
brace = src.find("{", start)
assert brace != -1, f"{name} body not found"
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not closed")
def test_render_messages_captures_live_turn_before_rebuild():
"""#3877: renderMessages must capture the live turn node before innerHTML=''."""
body = _function_body(UI_JS, "renderMessages")
# The capture is anchored by its issue tag so it is greppable + intentional.
assert "Mid-stream flicker fix (#3877)" in body, (
"the #3877 live-turn preservation is missing from renderMessages"
)
# The capture must happen BEFORE the destructive rebuild. renderMessages has
# several `inner.innerHTML=''` sites; the relevant one is the rebuild that
# immediately follows the capture, so assert on the FIRST rebuild at/after the
# capture index.
capture_idx = body.find("_preservedLiveTurn=null")
assert capture_idx != -1, "_preservedLiveTurn capture not found"
rebuild_idx = body.find("inner.innerHTML=''", capture_idx)
assert rebuild_idx != -1, "inner.innerHTML='' rebuild after capture not found"
assert capture_idx < rebuild_idx, (
"the live-turn capture must run BEFORE inner.innerHTML='' or the node is "
"already detached when captured"
)
def test_capture_is_gated_on_streaming_session():
"""The capture only runs for the streaming session's own live turn — never for a
settled transcript (INFLIGHT[sid] gate + session-id match)."""
body = _function_body(UI_JS, "renderMessages")
failsafe = body[body.find("Mid-stream flicker fix (#3877)") :]
# Gated on an in-flight stream for this session.
assert "INFLIGHT[sid]" in failsafe
# The captured turn must belong to the current session (no cross-session revive).
assert "liveAssistantTurn" in failsafe
assert "dataset.sessionId" in failsafe
def test_reattach_keeps_longer_live_segment_via_length_gated_swap():
"""After the rebuild, the preserved node is swapped back only when it carries MORE
streamed text than the rebuilt live turn. The length guard establishes the preserved
(parser) node strictly wins, so a plain replaceWith is sufficient — no segment merge
is needed (a merge would be a no-op under this guard), and the in-progress reply is
never blanked."""
body = _function_body(UI_JS, "renderMessages")
reattach = body[body.find("Re-attach the preserved live turn (#3877)") :]
assert reattach, "the #3877 re-attach block is missing"
# Length comparison gates the swap (only restore when preserved has more text).
assert "_liveAssistantSegmentTextLength" in reattach
assert "_rebuiltLen<_preservedLen" in reattach
# The swap replaces the rebuilt node with the preserved (parser-referenced) node.
assert "replaceWith(_preservedLiveTurn)" in reattach
def test_reattach_runs_after_rebuild_loop():
"""The re-attach must run AFTER the rebuild (so a freshly-rebuilt live turn exists to
compare against / replace) but is still inside renderMessages."""
body = _function_body(UI_JS, "renderMessages")
capture_idx = body.find("_preservedLiveTurn=null")
reattach_idx = body.find("Re-attach the preserved live turn (#3877)")
assert capture_idx != -1 and reattach_idx != -1
assert reattach_idx > capture_idx, "re-attach must come after the capture/rebuild"