fix(streaming): target the parser-owned TAIL live segment in the #3877 re-attach (Codex CORE)

Codex caught an asymmetry in the first cut: the rebuilt side selected the tail
live segment ([...].pop()) but the preserved side used querySelector() = the
FIRST [data-live-assistant=1] segment. In a multi-live-segment turn (reconnect /
post-tool activity boundaries — see messages.js ensureAssistantRow re-attaching
to the LAST live segment), the smd parser writes into the tail segment, so
swapping the first preserved segment would move the wrong node and leave the
parser-owned tail detached — re-introducing the flicker for multi-segment turns.

Fix: select the preserved segment from querySelectorAll, defaulting to the LAST
(tail), and prefer the one whose data-live-segment-seq matches the rebuilt tail;
compute _preservedLen from that segment. Added a structural regression test
pinning the tail selection.

Verified live: a 2-live-segment turn (seq 1 + seq 2, parser on seq 2) keeps the
parser-owned tail connected across a mid-stream rebuild (was orphaned with the
first-segment selection).
This commit is contained in:
Hermes Agent
2026-06-11 02:57:46 +00:00
parent 9803a037fe
commit 95e874d7be
2 changed files with 43 additions and 4 deletions
+20 -4
View File
@@ -8997,17 +8997,33 @@ function renderMessages(options){
// streaming.
if(_preservedLiveTurn){
const _rebuilt=document.getElementById('liveAssistantTurn');
const _preservedSeg=_preservedLiveTurn.querySelector('[data-live-assistant="1"]');
// Pick the PARSER-OWNED live segment, not just the first one. On reconnect /
// post-tool activity boundaries a live turn can carry MULTIPLE
// [data-live-assistant="1"] segments, and the smd parser writes into the
// LAST (tail) one (see ensureAssistantRow in messages.js — it re-attaches to
// the last live segment). Prefer the preserved segment whose
// data-live-segment-seq matches the rebuilt tail (same logical segment), then
// fall back to the last preserved live segment. Using querySelector() (first)
// here would move the wrong segment and leave the parser-owned tail detached
// in a multi-segment turn.
const _rebuiltSegs=_rebuilt?_rebuilt.querySelectorAll('[data-live-assistant="1"]'):null;
const _rebuiltSeg=(_rebuiltSegs&&_rebuiltSegs.length)?_rebuiltSegs[_rebuiltSegs.length-1]:null;
const _preservedSegs=_preservedLiveTurn.querySelectorAll('[data-live-assistant="1"]');
let _preservedSeg=_preservedSegs.length?_preservedSegs[_preservedSegs.length-1]:null;
const _rebuiltSeq=_rebuiltSeg?_rebuiltSeg.getAttribute('data-live-segment-seq'):null;
if(_rebuiltSeq){
for(const _seg of _preservedSegs){
if(_seg.getAttribute('data-live-segment-seq')===_rebuiltSeq){_preservedSeg=_seg;break;}
}
}
const _preservedLen=_liveAssistantSegmentTextLength(_preservedSeg||_preservedLiveTurn);
if(_preservedLen>0){
const _rebuiltSegs=_rebuilt?_rebuilt.querySelectorAll('[data-live-assistant="1"]'):null;
const _rebuiltSeg=(_rebuiltSegs&&_rebuiltSegs.length)?_rebuiltSegs[_rebuiltSegs.length-1]:null;
const _rebuiltLen=_rebuilt?_liveAssistantSegmentTextLength(_rebuiltSeg||_rebuilt):-1;
if(_rebuiltLen<=_preservedLen){
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
if(_rebuilt&&_rebuiltSeg&&_preservedSeg){
// Segment-level swap: keep the rebuilt turn (and its other segments /
// tool groups) but restore the parser-referenced live segment.
// tool groups) but restore the parser-referenced (tail) live segment.
_rebuiltSeg.replaceWith(_preservedSeg);
}else if(_rebuilt){
// Rebuilt turn has no live segment to target — replace the whole turn.
+23
View File
@@ -135,6 +135,29 @@ def test_reattach_swaps_at_segment_level_to_preserve_rebuilt_structure():
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."""