diff --git a/static/ui.js b/static/ui.js index df380b466..c6a629ca5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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. diff --git a/tests/test_issue3877_midstream_flicker.py b/tests/test_issue3877_midstream_flicker.py index 0b60a42f6..e5d67b4db 100644 --- a/tests/test_issue3877_midstream_flicker.py +++ b/tests/test_issue3877_midstream_flicker.py @@ -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."""