From 8d3c405d68207b5d13dd0ab89f086cb2b2caa63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thireus=20=E2=98=A0?= Date: Sun, 30 Aug 2026 20:47:11 +0100 Subject: [PATCH 1/6] fix(ui): stop rendering a settled assistant answer twice (#2051) Two independent paths could put a second copy of an already-settled assistant answer on screen; the stored data always held exactly one row. 1. Dead live-turn node. The assistant row is persisted a few ms before the stream's terminal event clears S.activeStreamId, so the #6948 preserve guard still treats a finished #liveAssistantTurn node as live. Both live-turn insertion paths (renderMessages()'s re-attach and restoreLiveTurnHtmlForSession()) have one branch that APPENDS a turn the settled rebuild did not produce; the leftover node keeps id=liveAssistantTurn, is re-preserved on every later render, and the duplicate survives until a reload. 2. Settled activity scene. A process_prose row whose text IS the final answer is suppressed by the transparent-stream renderer but rebuilt as a second .assistant-segment by the compact-worklog row builder. The existing near-equality matcher never fires for short answers because of its absolute shorter>=80 floor (the reported reproduction is a 35-char greeting). Dropping a live node is now an OWNERSHIP proof, never a text comparison: the transcript must end with a settled assistant message carrying this stream's id (_anchor_stream_id / scene identity), and the node must contain nothing the settled rebuild could not have produced (no unpersisted tool card, reasoning row, or second live segment). Unknown node shapes fail closed and keep the turn, so a genuinely live turn - including one whose text matches an earlier answer - is never dropped. The settled-scene half compares raw markdown at the single chokepoint both settled renderers share. Adds tests/test_issue2051_duplicate_assistant_answer.py: four tests that fail on the pristine tree and pass with this change, including a wiring test that fails loudly if either append branch loses its guard. Fixes #2051 --- static/ui.js | 151 +++++++- ...st_issue2051_duplicate_assistant_answer.py | 338 ++++++++++++++++++ 2 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue2051_duplicate_assistant_answer.py diff --git a/static/ui.js b/static/ui.js index 37742c5a..ca691f42 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9918,6 +9918,99 @@ function _liveAssistantSegmentTextLength(seg){ return String(body.textContent||'').trim().length; } +// ── #6948 follow-up: settled-transcript ownership of a live-turn node ────── +// (duplicate assistant answer; upstream symptom report #2051) +// +// Both live-turn insertion paths — the renderMessages() re-attach and +// restoreLiveTurnHtmlForSession() — have one branch that ADDS a turn the +// settled rebuild did not produce. The assistant row is persisted a few ms +// BEFORE the stream's terminal event clears S.activeStreamId, so a dead live +// node left behind by an INFLIGHT entry that outlived its stream is re-attached +// on top of the settled transcript: the same answer twice, self-sustaining +// across later renders (the node keeps id=liveAssistantTurn and is re-preserved +// by the #6948 guard), healed only by a reload. +// +// Dropping a live node must be an OWNERSHIP proof, never "the rendered text +// looks the same": +// * the live body is built by the streaming smd parser and the settled body +// by renderMd + post-processing, so their textContent differs for anything +// past single-paragraph plain text (lists, fenced code with its language +// label, tables, `_underscore_` emphasis, the injected copy button) — a +// text comparison would miss most real answers; and +// * an identical answer earlier in the history (a repeated "Done.", a +// greeting, a same-prompt resend) would delete a GENUINELY LIVE turn: while +// the current turn is unpersisted, the last settled assistant message IS the +// previous turn's answer. +// So ownership is proved from state: the transcript must END with a settled +// assistant message that THIS stream produced, and the node must carry nothing +// the settled rebuild could not have produced. + +// Stream identity persisted on a settled assistant message. The server stamps +// _anchor_stream_id from the anchor-scene sidecar record (api/routes.py); the +// client stamps _anchor_stream_id and scene.identity.stream_id at stream end +// (_attachProjectedAnchorSceneToLastAssistant, static/messages.js). +function _settledAssistantStreamId(message){ + if(!message) return ''; + const scene=message._anchor_activity_scene||null; + const identity=(scene&&scene.identity)||null; + return String(message._anchor_stream_id||(scene&&scene.stream_id)||(identity&&identity.stream_id)||''); +} +// The live-projection markers the #6948 preserve guard already treats as proof +// of a live owner. A message the client still considers live is never evidence +// that the turn has settled. +function _messageHasLiveAssistantProjection(m){ + return !!(m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined)); +} +// True when the live turn holds something the settled rebuild cannot have +// produced from S.messages — an unpersisted tool card, reasoning/thinking row, +// transparent-stream row, or a second live segment. Such a node is never +// dropped: #3714's whole premise is that the live DOM can be AHEAD of +// S.messages, and a tail-segment match must not discard the tool card or the +// earlier segment above it. An unknown node shape fails closed (keep the turn). +function _liveTurnCarriesUnsettledContent(turn){ + if(!turn||typeof turn.querySelectorAll!=='function') return true; + if(turn.querySelectorAll( + '.tool-card-row,.wl-reason,.agent-activity-thinking,.thinking-card-row,.transparent-event-row' + ).length) return true; + return turn.querySelectorAll('[data-live-assistant="1"]').length>1; +} +function _settledTranscriptOwnsLiveTurn(sid, turn){ + if(!turn) return false; + const msgs=(typeof S!=='undefined'&&S&&Array.isArray(S.messages))?S.messages:null; + if(!msgs||!msgs.length) return false; + // The transcript must END with a settled assistant answer. A trailing user + // turn means the live turn IS the current turn — nothing of it is persisted + // yet, so it can never be a leftover (this is the case a text comparison gets + // wrong when the previous answer happens to read the same), and a live + // projection anywhere means the rebuild still owns a live turn of its own. + const last=msgs[msgs.length-1]; + if(!last||last.role!=='assistant') return false; + if(msgs.some(_messageHasLiveAssistantProjection)) return false; + const inflight=(typeof INFLIGHT!=='undefined'&&INFLIGHT)?INFLIGHT[sid]:null; + // Owner of the live DOM: INFLIGHT carries the id of the stream that built it + // (attachLiveStream), and S.activeStreamId is that same id until the terminal + // event clears it. + const liveStreamId=String((inflight&&inflight.streamId)||(typeof S!=='undefined'&&S&&S.activeStreamId)||''); + const settledStreamId=_settledAssistantStreamId(last); + if(settledStreamId){ + // Identity recorded on both sides decides — renderer- and text-independent, + // so code blocks, lists and tables are handled like plain prose. + if(!liveStreamId||settledStreamId!==liveStreamId) return false; + }else{ + // No persisted identity on the tail (a turn whose scene was not worklog + // worthy). Compare SOURCE with SOURCE — the markdown this stream produced + // (INFLIGHT.lastAssistantText) against the persisted message content — + // never two independently rendered DOM trees. Regeneration cannot reach + // this branch: startRegeneration() truncates S.messages at the user turn, + // so the tail is a user message while a regenerated answer streams. + const streamed=String((inflight&&inflight.lastAssistantText)||'').replace(/\s+/g,' ').trim(); + if(!streamed) return false; + const settled=String((typeof msgContent==='function'?msgContent(last):last.content)||'').replace(/\s+/g,' ').trim(); + if(!settled||streamed!==settled) return false; + } + return !_liveTurnCarriesUnsettledContent(turn); +} + function _mergeRestoredLiveAssistantSegment(restored, existing){ if(!restored||!existing) return; const existingLive=existing.querySelector('[data-live-assistant="1"]'); @@ -9954,6 +10047,18 @@ function restoreLiveTurnHtmlForSession(sid){ const existing=$('liveAssistantTurn'); _mergeRestoredLiveAssistantSegment(restored, existing); if(existing) existing.replaceWith(restored); + // #6948 follow-up (duplicate assistant answer; #2051): only this branch ADDS a + // turn — replacing an existing live turn cannot duplicate. When the settled + // transcript already owns the stream this snapshot belongs to, appending it + // pins a SECOND copy of the same answer under the settled one. Release the + // stale snapshot (no other consumer can use it — it would be re-refused on + // every later restore) and report "nothing restored", so loadSession takes the + // same path it already takes for an INFLIGHT entry that never carried one. + else if(typeof _settledTranscriptOwnsLiveTurn==='function' + &&_settledTranscriptOwnsLiveTurn(sid, restored)){ + inflight.liveTurnHtml=null; + return false; + } else inner.appendChild(restored); // Transparent Stream: liveTurnHtml is restored via template.innerHTML, which // drops the property-bound onclick/onkeydown handlers wired by @@ -13141,6 +13246,17 @@ function _anchorSceneRowsForRendering(scene, opts){ const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[]; const settled=!!(opts&&opts.settled); const live=!settled; + // #6948 follow-up (duplicate assistant answer; #2051): on a SETTLED turn the + // assistant segment owns the final answer, so a process_prose row carrying + // that same text must not also be rebuilt into the activity scene. The + // transparent-stream renderer suppresses it per row + // (_anchorSceneTransparentNodeForRow), but the compact-worklog row builder + // (_anchorSceneNodeForRow) has no such guard and renders the row as a second + // .assistant-segment above the settled one. Drop it here — the single place + // BOTH settled renderers (and the #5839 deferred-rows path) get their rows. + // LIVE rendering is untouched: while streaming, the inline live segment is + // hidden and the prose row IS the visible answer. + const settledFinalAnswer=settled?String((scene&&scene.final_answer)||'').trim():''; const out=[]; const byKey=new Map(); const liveProseTextKeys=new Map(); @@ -13163,6 +13279,9 @@ function _anchorSceneRowsForRendering(scene, opts){ if(_anchorSceneIsSettledSuccessfulCompression(row,settled)) continue; const text=String(row.text||'').trim(); if((row.role==='prose'||row.role==='thinking')&&!text) continue; + if(settledFinalAnswer&&row.role==='prose' + &&typeof _anchorSceneProseDuplicatesFinalAnswer==='function' + &&_anchorSceneProseDuplicatesFinalAnswer(text,settledFinalAnswer)) continue; // #6948 follow-up const key=keyFor(row); if(byKey.has(key)){ const index=byKey.get(key); @@ -13437,6 +13556,26 @@ function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){ const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length); return shorter>=80 && (shorter/longer)>=0.9; } +// #6948 follow-up (duplicate assistant answer; #2051): the same near-equality +// test as _anchorSceneProseMatchesFinalAnswer, minus its absolute `shorter>=80` +// floor. That floor makes the matcher a no-op for every SHORT final answer +// (greetings, "Done.", one-liners), so a prose row that is the answer minus its +// last streamed token — e.g. "Hey! What can I help you with today" vs +// "...today?" — was rebuilt as a SECOND assistant-segment beside the settled +// one. The >=0.9 length ratio is what actually keeps a distinct short +// intermediate sentence from being swallowed by a long final answer (Codex +// #4568), so it alone is kept. Deliberately a separate helper: the per-row +// render-time matcher above, and the tests pinning its exact predicate, stay +// untouched. +function _anchorSceneProseDuplicatesFinalAnswer(proseText, finalAnswer){ + if(_anchorSceneProseMatchesFinalAnswer(proseText,finalAnswer)) return true; + const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim(); + const a=norm(proseText), b=norm(finalAnswer); + if(!a||!b) return false; + if(!(a.startsWith(b)||b.startsWith(a))) return false; + const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length); + return (shorter/longer)>=0.9; +} function _anchorSceneWorklogGroup(blocks, opts){ if(!blocks) return null; const live=!!(opts&&opts.live); @@ -18593,7 +18732,17 @@ function renderMessages(options){ // restore the whole preserved turn so nothing the user saw vanishes. if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id; _rebuilt.replaceWith(_preservedLiveTurn); - }else{ + }else if(!(typeof _settledTranscriptOwnsLiveTurn==='function' + &&_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn))){ + // #6948 follow-up (duplicate assistant answer; #2051): this is the + // only branch that ADDS a turn — the rebuild produced no live turn of + // its own. When the settled transcript already ends with THIS stream's + // own answer and the preserved node carries nothing unpersisted, the + // node is a dead leftover (the row persisted and the turn settled while + // INFLIGHT[sid] was not yet cleaned) and appending pins a SECOND copy + // that re-preserves itself on every later render until a reload. + // Mid-stream the transcript ends with the user turn (or still carries a + // live projection), so #3877 preservation is untouched. if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id; inner.appendChild(_preservedLiveTurn); } diff --git a/tests/test_issue2051_duplicate_assistant_answer.py b/tests/test_issue2051_duplicate_assistant_answer.py new file mode 100644 index 00000000..6493bf16 --- /dev/null +++ b/tests/test_issue2051_duplicate_assistant_answer.py @@ -0,0 +1,338 @@ +"""Regression: the assistant answer is displayed TWICE (#6948 follow-up; +upstream symptom report #2051). + +Two independent renderers can put a second copy of the same answer on screen +after a turn settles. Both are covered here. + +1. The live-turn node (static/ui.js) +------------------------------------ +`renderMessages()` re-attaches the preserved `#liveAssistantTurn` node after the +transcript wipe, and `restoreLiveTurnHtmlForSession()` re-inserts the +`INFLIGHT[sid].liveTurnHtml` snapshot on a session switch / reconnect. Each has +ONE branch that *appends* — i.e. adds a turn the settled rebuild did not +produce. The assistant row is persisted a few ms BEFORE the stream's terminal +event clears `S.activeStreamId`, so a dead live node left behind by an INFLIGHT +entry that outlived its stream is appended under the settled copy: the same +answer twice, self-sustaining across later renders (the node keeps +id=liveAssistantTurn and is re-preserved by the #6948 guard), healed only by a +reload. + +The contract (`_settledTranscriptOwnsLiveTurn`) is an OWNERSHIP proof, never a +rendered-text comparison: + + * the live body comes from the streaming smd parser and the settled body from + renderMd + post-processing, so comparing textContent would only ever match + single-paragraph plain text — lists, fenced code, tables and `_emphasis_` + render differently in the two pipelines; + * comparing text would DELETE a genuinely live turn whenever the previous + answer reads the same ("Done.", a greeting, a same-prompt resend), because + while the current turn is unpersisted the last settled assistant message is + the PREVIOUS turn's answer. + +So the append is skipped only when the transcript ENDS with a settled assistant +message that the SAME stream produced (persisted anchor identity, or a +source-vs-source text match when the turn has no persisted scene), and the +preserved node carries nothing the settled rebuild could not have produced (no +unpersisted tool card / reasoning row, no second live segment — #3714). + +2. The settled activity scene (static/ui.js) +-------------------------------------------- +`_anchorSceneRowsForRendering(scene,{settled:true})` feeds both settled +renderers. A `process_prose` row whose text IS the final answer is suppressed +per-row by the transparent-stream renderer but NOT by the compact-worklog row +builder, which rebuilds it as a second `.assistant-segment` above the settled +answer. The row is now dropped in the shared filter, using a near-equality test +without the absolute `shorter>=80` floor that made the existing matcher a no-op +for short answers (the reported case: the row is the answer minus its last +streamed token, 35 vs 36 chars). LIVE rendering is untouched — while streaming, +the inline live segment is hidden and the prose row IS the visible answer. +""" +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.js_source_extract import extract_function + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def js(*names): + """Real source of the named ui.js functions — never a re-implementation.""" + return "\n".join(extract_function(UI_JS, name) for name in names) + + +def run_node(script): + out = subprocess.run([NODE, "-e", script], capture_output=True, text=True) + assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}" + assert "OK" in out.stdout, out.stdout + return out.stdout + + +# Minimal DOM stand-in: class/attribute selector matching over a node tree. +DOM_STUB = """ +const assert=require('assert'); +class El{ + constructor(spec){ + spec=spec||{}; + this.classes=new Set(spec.classes||[]); + this.attrs=Object.assign({},spec.attrs||{}); + this.children=spec.children||[]; + } + _matchesToken(tok){ + tok=String(tok).trim(); + if(tok.startsWith('.')) return this.classes.has(tok.slice(1)); + const m=/^\\[([a-zA-Z0-9-]+)="([^"]*)"\\]$/.exec(tok); + if(m) return String(this.attrs[m[1]]===undefined?'':this.attrs[m[1]])===m[2]; + return false; + } + querySelectorAll(sel){ + const toks=String(sel).split(','); + const out=[]; + const walk=(node)=>{ + for(const child of node.children){ + if(toks.some(t=>child._matchesToken(t))) out.push(child); + walk(child); + } + }; + walk(this); + return out; + } +} +const seg=(text)=>new El({attrs:{'data-live-assistant':'1'},classes:['assistant-segment'], + children:[new El({classes:['msg-body'],children:[]})]}); +const turnWith=(...kids)=>new El({classes:['assistant-turn'],children:kids}); +const toolCard=()=>new El({classes:['tool-card-row']}); +const emptyWorklogShell=()=>new El({classes:['live-worklog'],attrs:{'data-live-worklog-shell':'1'}}); +var S={messages:[],activeStreamId:null,session:{session_id:'s1'}}; +var INFLIGHT={}; +function setState(messages,inflight,activeStreamId){ + S.messages=messages; + S.activeStreamId=activeStreamId===undefined?null:activeStreamId; + for(const k of Object.keys(INFLIGHT)) delete INFLIGHT[k]; + if(inflight) INFLIGHT['s1']=inflight; +} +const user=(content)=>({role:'user',content}); +const settledAnswer=(content,streamId)=>{ + const m={role:'assistant',content}; + if(streamId) m._anchor_stream_id=streamId; + return m; +}; +const sceneIdentityAnswer=(content,streamId)=>({ + role:'assistant',content, + _anchor_activity_scene:{identity:{stream_id:streamId},final_answer:content}, +}); +""" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_dead_live_turn_is_dropped_only_when_the_stream_is_provably_settled(): + """The #2051 duplicate is suppressed; a live turn is never deleted.""" + script = DOM_STUB + js( + "msgContent", + "_settledAssistantStreamId", + "_messageHasLiveAssistantProjection", + "_liveTurnCarriesUnsettledContent", + "_settledTranscriptOwnsLiveTurn", + ) + """ +const ANSWER='Hey! What can I help you with today?'; + +// ── the bug: stream settled, INFLIGHT not yet cleaned → dead leftover ── +setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1'); +assert.strictEqual( + _settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER),emptyWorklogShell())),true, + 'dead live node whose stream the settled transcript owns must be dropped (#2051)'); + +// Identity may live only on the persisted scene. +setState([user('Hi'),sceneIdentityAnswer(ANSWER,'st1')],{streamId:'st1'},null); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true, + 'scene identity.stream_id is accepted as ownership proof'); + +// Shape independence: the decision must not depend on rendered text at all, so +// a multi-block answer (fenced code / list / table), which the smd parser and +// renderMd render to DIFFERENT textContent, is handled like plain prose. +setState([user('Hi'),settledAnswer('Here you go:\\n\\n```python\\nprint(1)\\n```','st1')], + {streamId:'st1'},'st1'); +assert.strictEqual( + _settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Here you go:print(1)'))),true, + 'a code-block answer must be de-duplicated too (renderer-independent)'); + +// ── false positives that a text comparison would cause ── +// The current turn is unpersisted: the transcript ends with the USER turn while +// the live turn streams text identical to the PREVIOUS answer. +setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{streamId:'st2'},'st2'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false, + 'a genuinely live turn must never be dropped because an earlier answer matches'); +// markInflight() shape at send time (no snapshot yet, stream just started). +setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{sid:'s1',streamId:'st2',ts:1},'st2'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Do'))),false, + 'partial live text under a fresh stream must never be dropped'); +// A settled tail from a DIFFERENT stream never owns this live turn. +setState([user('Hi'),settledAnswer('Done.','st1')],{streamId:'st2'},'st2'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false, + 'a tail from another stream must not authorize dropping the live turn'); +// Reconnect/terminal projection: S.messages still carries a live marker. +setState([user('Hi'),Object.assign(settledAnswer('Done.','st1'),{_live:true})], + {streamId:'st1'},null); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false, + 'a live projection in S.messages means the turn has not settled'); +// Ownership unknown (no INFLIGHT stream id, no active stream) → fail closed. +setState([user('Hi'),settledAnswer('Done.','st1')],{},null); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false, + 'unknown live owner must fail closed'); +setState([],{streamId:'st1'},'st1'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false, + 'an empty transcript owns nothing'); + +// ── #3714: live-only structure is never discarded ── +setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1'); +assert.strictEqual( + _settledTranscriptOwnsLiveTurn('s1',turnWith(toolCard(),seg(ANSWER))),false, + 'a preserved turn carrying an unpersisted tool card must be kept'); +assert.strictEqual( + _settledTranscriptOwnsLiveTurn('s1',turnWith(seg('first'),seg(ANSWER))),false, + 'a multi-segment live turn must not be dropped on a tail-segment match'); +assert.strictEqual( + _settledTranscriptOwnsLiveTurn('s1',turnWith(new El({classes:['wl-reason']}),seg(ANSWER))),false, + 'a preserved turn carrying an unpersisted reasoning row must be kept'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',{}),false, + 'an unknown node shape must fail closed'); + +// ── no persisted scene identity: SOURCE text vs SOURCE text ── +setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1',lastAssistantText:ANSWER},'st1'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true, + 'streamed markdown identical to the persisted answer proves ownership'); +setState([user('Hi'),settledAnswer(ANSWER,null)], + {streamId:'st1',lastAssistantText:'Hey! What can I help'},'st1'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Hey! What can I help'))),false, + 'a still-growing stream must not be dropped against a longer settled answer'); +setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1'},'st1'); +assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),false, + 'no identity and no streamed source text → fail closed'); +console.log('OK'); +""" + run_node(script) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_append_decision_renders_the_answer_exactly_once(): + """Observable outcome of the two append branches, driven by the real + predicate: one copy for a settled leftover, and the live turn is still + attached in every case where the turn may still be running.""" + script = DOM_STUB + js( + "msgContent", + "_settledAssistantStreamId", + "_messageHasLiveAssistantProjection", + "_liveTurnCarriesUnsettledContent", + "_settledTranscriptOwnsLiveTurn", + ) + """ +// Mirrors the two call sites: the branch appends unless the settled transcript +// provably owns the node (static/ui.js renderMessages re-attach and +// restoreLiveTurnHtmlForSession). +function visibleAnswers(messages,inflight,activeStreamId,turn){ + setState(messages,inflight,activeStreamId); + const settledCopies=messages.filter(m=>m.role==='assistant'&&!m._live).length; + const appended=!_settledTranscriptOwnsLiveTurn('s1',turn)?1:0; + return {settledCopies,appended,total:settledCopies+appended}; +} +const ANSWER='Hey! What can I help you with today?'; +// #2051: exactly one copy of the answer. +assert.deepStrictEqual( + visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1', + turnWith(seg(ANSWER),emptyWorklogShell())), + {settledCopies:1,appended:0,total:1}, + 'the settled answer must render exactly once'); +// A live turn is still attached while the turn is running (#3877). +assert.deepStrictEqual( + visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1'),user('Hi again')], + {streamId:'st2'},'st2',turnWith(seg(ANSWER))), + {settledCopies:1,appended:1,total:2}, + 'a running turn must stay on screen even when it repeats an earlier answer'); +// A leftover carrying unpersisted structure is kept whole (#3714). +assert.deepStrictEqual( + visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1', + turnWith(toolCard(),seg(ANSWER))).appended,1, + 'never discard live-only blocks the rebuild did not produce'); +console.log('OK'); +""" + run_node(script) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_settled_scene_drops_the_prose_row_that_is_the_final_answer(): + script = DOM_STUB + js( + "_anchorSceneIsSettledSuccessfulCompression", + "_anchorSceneProseMatchesFinalAnswer", + "_anchorSceneProseDuplicatesFinalAnswer", + "_anchorSceneRowsForRendering", + ) + """ +function _anchorSceneToolRowLogicalKey(row){ return (row&&row.row_id)||''; } +function _anchorSceneMergeToolRows(_prev,row){ return row; } +const FINAL='Hey! What can I help you with today?'; +const scene={ + final_answer:FINAL, + activity_rows:[ + {role:'thinking',row_id:'t1',text:'thinking out loud'}, + {role:'prose',row_id:'p1',text:'Let me check the config first.'}, + // the reported shape: the answer minus its last streamed token (35 vs 36) + {role:'prose',row_id:'p2',text:'Hey! What can I help you with today'}, + {role:'terminal',row_id:'d1',source_event_type:'done'}, + ], +}; +const texts=(opts)=>_anchorSceneRowsForRendering(scene,opts).map(r=>r.text); +assert.deepStrictEqual(texts({settled:true}),['thinking out loud','Let me check the config first.'], + 'the settled scene must not rebuild the final answer as a second segment (#2051)'); +assert.deepStrictEqual(texts({settled:false}), + ['thinking out loud','Let me check the config first.','Hey! What can I help you with today'], + 'LIVE rendering must keep the prose row — it IS the visible answer while streaming'); +// Exact repeats and multi-block answers are dropped too; source text is compared, +// so the markdown pipeline never matters here. +const code={final_answer:'Here you go:\\n\\n```python\\nprint(1)\\n```',activity_rows:[ + {role:'prose',row_id:'p1',text:'Here you go:\\n\\n```python\\nprint(1)\\n```'}, + {role:'prose',row_id:'p2',text:'Working on it.'}]}; +assert.deepStrictEqual(_anchorSceneRowsForRendering(code,{settled:true}).map(r=>r.text), + ['Working on it.'],'an exact fenced-code duplicate must be dropped, intermediates kept'); +// Codex #4568: a SHORT distinct intermediate that merely prefixes a LONG answer +// must survive — the >=0.9 length ratio, not the removed absolute floor, is what +// protects it. +const long_={final_answer:'Hey! What can I help you with today? I can read files, run commands and edit code.', + activity_rows:[{role:'prose',row_id:'p1',text:'Hey! What can'}]}; +assert.deepStrictEqual(_anchorSceneRowsForRendering(long_,{settled:true}).map(r=>r.text), + ['Hey! What can'],'a distinct short intermediate must not be swallowed by a long answer'); +// The pinned per-row matcher is untouched: its absolute floor still rejects the +// short near-miss that the new helper accepts. +assert.strictEqual(_anchorSceneProseMatchesFinalAnswer('Hey! What can I help you with today',FINAL),false, + '_anchorSceneProseMatchesFinalAnswer must keep its >=80 char floor'); +assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can I help you with today',FINAL),true, + 'the new helper must catch the short near-miss'); +assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can',FINAL),false, + 'the new helper must keep the >=0.9 ratio guard'); +console.log('OK'); +""" + run_node(script) + + +def test_both_append_branches_are_gated_by_the_ownership_check(): + """Wiring: the predicate only helps if it guards the two branches that ADD a + turn (the replace branches cannot duplicate and must stay unguarded).""" + restore = UI_JS[UI_JS.index("function restoreLiveTurnHtmlForSession"):] + restore = restore[:restore.index("\nfunction markInflight")] + assert "if(existing) existing.replaceWith(restored);" in restore + assert "_settledTranscriptOwnsLiveTurn(sid, restored)" in restore, ( + "the restore append branch must be gated by the ownership check" + ) + assert "inflight.liveTurnHtml=null;" in restore, ( + "a refused snapshot must be released, or it is re-refused on every restore" + ) + reattach = UI_JS[UI_JS.index(" const _rebuilt=document.getElementById('liveAssistantTurn');"):] + reattach = reattach[:reattach.index(" // Only force-scroll when not actively streaming")] + assert "_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn)" in reattach, ( + "the renderMessages append branch must be gated by the ownership check" + ) + assert reattach.count("_settledTranscriptOwnsLiveTurn(") == 1, ( + "the segment-swap and whole-turn replace branches must stay unguarded — " + "they replace a node instead of adding one and cannot duplicate" + ) From 166ca0ec05485e640a32018d3a183fc0151b845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thireus=20=E2=98=A0?= Date: Tue, 1 Sep 2026 12:07:15 +0100 Subject: [PATCH 2/6] fix(ui): build message blocks as nodes, not from an HTML string (#2051) A settled assistant answer could render TWICE inside a single `.assistant-segment` -- `msg-body, msg-foot, msg-body, msg-foot`, the two copies byte-identical -- while the session data, the server responses and the emitted markup were all correct. It survived reloads, was unreproducible on a clean machine, and could not be explained by any render path. The cause is outside the app. Privacy / anti-fingerprinting extensions wrap the DOM insertion APIs so they can re-scan the document after every mutation. The reporter identified CanvasBlocker 1.12 in their own profile; its `lib/iframeProtection.js` -> `protectDOMModifications()` wraps `appendChild`, `insertBefore` and `replaceChild` on Node.prototype, `append`, `prepend`, `insertAdjacentElement`, `insertAdjacentHTML`, `insertAdjacentText` and `replaceWith` on Element.prototype, and the `innerHTML` and `outerHTML` setters, and installs them with `exportFunction`, so they still stringify as `[native code]` and cannot be told apart from the real thing by the page. A wrapper that ends up executing the wrapped call twice is harmless for node-based insertion but not for string-based insertion. Measured on the reporter's exact 2342-char emit string, in Chromium 152 and Firefox 154: a doubled `insertAdjacentHTML` rendered 2 bodies before this change and 1 after, while a doubled `appendChild`, `insertBefore`, `append` or `innerHTML` setter rendered 1 body both before and after. Only the HTML-string path multiplies: `insertAdjacentHTML` parses and appends a fresh copy every time it runs, whereas appending a node MOVES it (a second call is a no-op) and an `innerHTML` setter REPLACES (a second call is idempotent). That is why exactly this one construct, of all the wrapped APIs, produced the duplicate -- and why switching to a different insertion API is a real fix rather than a workaround for one extension. Parse into a `