Codex round 2 caught that _structuralCount() only enumerated .tool-call-group/ .tool-card-row/.tool-worklog-group but NOT the live Worklog shell (.live-worklog[data-live-worklog-shell=1]) or its .wl-reason content (built by _flushPendingSegmentRender / ensureLiveWorklogContainer, see messages.js:2386 + ui.js:6712/6655). So a turn whose live-ahead structure is a Worklog (not a bare tool-call-group) undercounted preserved structure → wrongly took the segment-swap path → detached the visible live Worklog container for a frame. Fix: add .live-worklog[data-live-worklog-shell=1] and .wl-reason to the structural count (mirrors the canonical 'has current worklog content' selector set at sessions.js:1125-1131). Added a structural-test assertion + verified live: a preserved turn with a live worklog .wl-reason the rebuild lacks now takes the whole-turn restore path and the worklog + parser both survive. Convergence: Codex findings narrowing each round (tie-guard → tail-segment → worklog-selector), all real, all fixed + empirically verified.
187 lines
10 KiB
Python
187 lines
10 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_swaps_when_preserved_ties_or_beats_rebuilt():
|
|
"""After the rebuild, the preserved (parser-referenced) node is swapped back in
|
|
when it carries AT LEAST AS MUCH streamed text as the rebuilt live turn
|
|
(`_rebuiltLen <= _preservedLen`).
|
|
|
|
The `<=` (not `<`) is the #3877-reopen fix: at the throttled-persist boundary the
|
|
rebuilt turn's live content can EQUAL the preserved length, and the old strict-`<`
|
|
guard then skipped the swap — leaving the smd parser writing into the detached
|
|
original node (the residual "disappears, then reappears" frame). On a tie the
|
|
preserved node wins because it holds the live parser reference and nothing is lost.
|
|
"""
|
|
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 still gates the swap...
|
|
assert "_liveAssistantSegmentTextLength" in reattach
|
|
# ...but now with `<=` so the equal-length tie also restores the parser node.
|
|
assert "_rebuiltLen<=_preservedLen" in reattach, (
|
|
"the swap must fire on a tie (<=), not only when preserved strictly wins (<) "
|
|
"— the strict-< guard left the parser node orphaned on the equal-length frame"
|
|
)
|
|
# The old strict-< form must be gone (it is the bug).
|
|
assert "_rebuiltLen<_preservedLen" not in reattach.replace("_rebuiltLen<=_preservedLen", "")
|
|
|
|
|
|
def test_reattach_swaps_at_segment_level_to_preserve_rebuilt_structure():
|
|
"""When the rebuild is the structural superset, the swap replaces only the rebuilt
|
|
LIVE SEGMENT with the preserved one, so a multi-segment turn (earlier settled
|
|
segments + tool/worklog groups built by the rebuild) keeps that rebuilt-only
|
|
structure. When the preserved (live) turn has MORE structural blocks than the
|
|
rebuild — a live-only tool/worklog group landed before the throttled persist
|
|
caught up — the whole preserved turn is restored so nothing the user saw vanishes
|
|
for a frame. Whole-turn replace is also the fallback when there's no live segment
|
|
to target."""
|
|
body = _function_body(UI_JS, "renderMessages")
|
|
reattach = body[body.find("Re-attach the preserved live turn (#3877)") :]
|
|
# Structural-count comparison routes segment-swap vs whole-turn restore.
|
|
assert "_structuralCount" in reattach
|
|
assert "_rebuiltStructure>=_preservedStructure" in reattach, (
|
|
"segment-level swap only when the rebuild is the structural superset; "
|
|
"otherwise restore the whole preserved turn so live-only tool cards are kept"
|
|
)
|
|
# The structural count must include the LIVE WORKLOG shell + reason content, not
|
|
# just .tool-call-group — a live worklog (data-live-worklog-shell) landing before
|
|
# the throttled persist is exactly the live-ahead structure a segment-only swap
|
|
# would detach (Codex round-2 CORE finding).
|
|
assert '.live-worklog[data-live-worklog-shell="1"]' in reattach, (
|
|
"structural count must include the live worklog shell so a worklog-only "
|
|
"live-ahead turn takes the whole-turn restore path"
|
|
)
|
|
assert ".wl-reason" in reattach
|
|
# Segment-level swap is the superset path.
|
|
assert "_rebuiltSeg.replaceWith(_preservedSeg)" in reattach, (
|
|
"the swap must be segment-level (replace the rebuilt live segment with the "
|
|
"preserved one) so rebuilt-only structure in a multi-segment turn is kept"
|
|
)
|
|
# Whole-turn replace remains for the live-ahead / no-live-segment cases.
|
|
assert "replaceWith(_preservedLiveTurn)" in reattach
|
|
# The preserved live segment is resolved for the swap.
|
|
assert "_preservedSeg" in reattach and "_rebuiltSeg" in reattach
|
|
|
|
|
|
def test_reattach_targets_the_parser_owned_tail_segment_not_the_first():
|
|
"""Multi-live-segment turns (reconnect / post-tool activity boundaries) can have
|
|
several [data-live-assistant="1"] segments; the smd parser writes into the LAST
|
|
(tail) one. The re-attach must select the preserved TAIL segment — preferring the
|
|
one whose data-live-segment-seq matches the rebuilt tail — not the first via a bare
|
|
querySelector(). Picking the first would move the wrong segment and leave the
|
|
parser-owned tail detached (Codex CORE finding on the #3877-reopen fix)."""
|
|
body = _function_body(UI_JS, "renderMessages")
|
|
reattach = body[body.find("Re-attach the preserved live turn (#3877)") :]
|
|
# Preserved segment is chosen from querySelectorAll (tail), not querySelector (first).
|
|
assert "_preservedSegs=_preservedLiveTurn.querySelectorAll('[data-live-assistant=\"1\"]')" in reattach
|
|
assert "_preservedSegs[_preservedSegs.length-1]" in reattach, (
|
|
"must default to the LAST preserved live segment (the parser-owned tail)"
|
|
)
|
|
# And prefer the segment whose live-segment-seq matches the rebuilt tail.
|
|
assert "data-live-segment-seq" in reattach and "_rebuiltSeq" in reattach, (
|
|
"must prefer the preserved segment matching the rebuilt tail's "
|
|
"data-live-segment-seq before falling back to the last segment"
|
|
)
|
|
# The first-only querySelector form must NOT be how _preservedSeg is derived.
|
|
assert "_preservedSeg=_preservedLiveTurn.querySelector(" not 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"
|