From 6ac5b9dbee41503879f9f776f4f30ee0839d948b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 30 Jun 2026 11:09:22 +0800 Subject: [PATCH] Fix transparent live anchor scene replay Co-Authored-By: Paperclip --- static/ui.js | 85 ++++- ...test_live_to_final_anchor_visible_order.py | 343 ++++++++++++++++++ 2 files changed, 425 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index c5064caa295..15b2b2ac414 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10028,6 +10028,7 @@ function _anchorSceneNodeForRow(row, opts){ } function _anchorSceneTransparentNodeForRow(row, opts){ const settled=!!(opts&&opts.settled); + const live=!!(opts&&opts.live); if(!row) return null; let node=null; const meta={ @@ -10079,10 +10080,14 @@ function _anchorSceneTransparentNodeForRow(row, opts){ } if(!node) return null; node.setAttribute('data-anchor-scene-row','1'); - node.setAttribute('data-anchor-settled-scene-row','1'); + if(settled) node.setAttribute('data-anchor-settled-scene-row','1'); + if(live) node.setAttribute('data-anchor-live-scene-row','1'); node.setAttribute('data-anchor-row-id',String(row.row_id||row.local_id||'')); node.setAttribute('data-anchor-row-role',String(row.role||'activity')); node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||'')); + if(opts&&opts.streamId) node.setAttribute('data-anchor-stream-id',String(opts.streamId)); + if(opts&&opts.sessionId) node.setAttribute('data-session-id',String(opts.sessionId)); + if(live) node.setAttribute('data-live-stream-owned','1'); return node; } // Whitespace-insensitive compare so a scene prose row that IS the final answer @@ -10259,6 +10264,9 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ } function renderLiveAnchorActivityScene(streamId, scene, opts){ opts=opts||{}; + if(typeof isTransparentStream==='function'&&isTransparentStream()){ + return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts); + } if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false; if(!S.session||!S.activeStreamId) return false; if(opts.sessionId&&S.session.session_id!==opts.sessionId) return false; @@ -10269,11 +10277,11 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ if(!turn){ turn=_createAssistantTurn(); turn.id='liveAssistantTurn'; - if(S.session) turn.dataset.sessionId=S.session.session_id; $('msgInner').appendChild(turn); } turn.setAttribute('data-anchor-scene-live-owner','1'); turn.setAttribute('data-anchor-stream-id',String(streamId||'')); + // Re-stamp when reusing a turn restored or previously rendered in another mode. if(S.session) turn.dataset.sessionId=S.session.session_id; const blocks=_assistantTurnBlocks(turn); if(!blocks) return false; @@ -10319,8 +10327,79 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } +function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ + opts=opts||{}; + if(!S.session||!S.activeStreamId) return false; + if(opts.sessionId&&S.session.session_id!==opts.sessionId) return false; + if(streamId&&S.activeStreamId!==streamId) return false; + const rows=_anchorSceneRowsForRendering(scene,{settled:false}); + if(!rows.length) return false; + $('emptyState').style.display='none'; + let turn=$('liveAssistantTurn'); + if(!turn){ + turn=_createAssistantTurn(); + turn.id='liveAssistantTurn'; + $('msgInner').appendChild(turn); + } + turn.setAttribute('data-anchor-scene-live-owner','1'); + turn.setAttribute('data-anchor-stream-id',String(streamId||'')); + turn.setAttribute('data-live-assistant-turn','1'); + if(S.session) turn.dataset.sessionId=S.session.session_id; + const blocks=_assistantTurnBlocks(turn); + if(!blocks) return false; + const scrollSnapshot=_captureMessageScrollSnapshot(); + const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot); + blocks.querySelectorAll('[data-anchor-scene-owner="1"],[data-anchor-scene-row="1"]').forEach(el=>el.remove()); + // Clear every legacy live activity surface this renderer can replace. The + // anchor-scene rows are now the source of truth for visible live activity. + blocks.querySelectorAll( + '.live-worklog[data-live-worklog-shell="1"],'+ + '.tool-worklog-group[data-live-tool-call-group="1"],'+ + '.tool-call-group[data-live-tool-call-group="1"],'+ + '.tool-card-row[data-live-tid],'+ + '.agent-activity-thinking[data-live-thinking="1"],'+ + '.transparent-event-row[data-live-tid],'+ + '[data-live-stream-owned="1"],'+ + '.interim-collapse-toggle' + ).forEach(el=>el.remove()); + // Match the compact path: keep legacy live segments as hidden anchors so + // stream-owned metadata survives while the anchor scene owns visible activity. + blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{ + el.classList.add('assistant-segment-worklog-source'); + el.setAttribute('aria-hidden','true'); + el.hidden=true; + }); + const liveFooter=blocks.querySelector('#liveRunStatus'); + let wrote=false; + for(const row of rows){ + const node=_anchorSceneTransparentNodeForRow(row,{ + live:true, + settled:false, + streamId:streamId||S.activeStreamId||'', + sessionId:S.session&&S.session.session_id, + }); + if(!node) continue; + if(liveFooter&&liveFooter.parentElement===blocks) blocks.insertBefore(node,liveFooter); + else blocks.appendChild(node); + wrote=true; + } + if(wrote) _syncTransparentEventControls(turn); + if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); + _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + if(scrollRebuildGuard&&scrollRebuildGuard.release){ + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); + } + if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); + return wrote; +} function _renderLiveAnchorActivitySceneForStream(streamId, sessionId, opts){ - const scene=_projectLiveAnchorActivitySceneForStream(streamId,(opts&&opts.mode)||'compact_worklog'); + const mode=(typeof isTransparentStream==='function'&&isTransparentStream()) + ? 'transparent_stream' + : ((opts&&opts.mode)||'compact_worklog'); + const scene=_projectLiveAnchorActivitySceneForStream(streamId,mode); if(!scene) return false; return renderLiveAnchorActivityScene(streamId,scene,{...(opts||{}),sessionId}); } diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 4d3d222e76f..8df7b084704 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -804,6 +804,349 @@ def test_transparent_stream_renders_persisted_anchor_scene_after_reload(): assert "if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;" in render +def test_live_anchor_scene_snapshot_renders_transparent_rows_before_compact_gate(): + live = _function_body(UI_JS, "renderLiveAnchorActivityScene") + transparent = _function_body(UI_JS, "_renderLiveAnchorActivitySceneTransparent") + stream = _function_body(UI_JS, "_renderLiveAnchorActivitySceneForStream") + row = _function_body(UI_JS, "_anchorSceneTransparentNodeForRow") + + transparent_gate = "return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts);" + compact_gate = "if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;" + assert transparent_gate in live + assert compact_gate in live + assert live.index(transparent_gate) < live.index(compact_gate), ( + "transparent live snapshots must render before the compact-only guard" + ) + + assert "_anchorSceneRowsForRendering(scene,{settled:false})" in transparent + assert "turn.id='liveAssistantTurn'" in transparent + assert "turn.dataset.sessionId=S.session.session_id" in transparent + assert "turn.setAttribute('data-anchor-scene-live-owner','1')" in transparent + assert "turn.setAttribute('data-anchor-stream-id',String(streamId||''))" in transparent + assert "turn.setAttribute('data-live-assistant-turn','1')" in transparent + assert "_anchorSceneTransparentNodeForRow(row,{" in transparent + assert "live:true" in transparent + assert "streamId:streamId||S.activeStreamId||''" in transparent + assert "sessionId:S.session&&S.session.session_id" in transparent + assert "_syncTransparentEventControls(turn)" in transparent + assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]').forEach" in transparent + assert ".transparent-event-row[data-live-tid]" in transparent + assert "[data-live-stream-owned=\"1\"]" in transparent + + assert "'transparent_stream'" in stream + assert "_projectLiveAnchorActivitySceneForStream(streamId,mode)" in stream + assert "data-anchor-live-scene-row" in row + assert "data-live-stream-owned" in row + assert "if(settled) node.setAttribute('data-anchor-settled-scene-row','1')" in row + + +@pytest.mark.skipif(NODE is None, reason="node is required for DOM-executed anchor render tests") +def test_live_anchor_scene_transparent_snapshot_render_is_idempotent_and_hides_legacy_rows(): + script = f""" +const assert = require('assert'); +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); +function extractFunc(name){{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if(start < 0) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; iself._classes.add(name)); }}, + remove(...names){{ names.forEach(name=>self._classes.delete(name)); }}, + contains(name){{ return self._classes.has(name); }}, + toggle(name, force){{ + const on=force===undefined?!self._classes.has(name):!!force; + if(on) self._classes.add(name); else self._classes.delete(name); + return on; + }}, + }}; + }} + get parentElement(){{ return this.parentNode; }} + get firstChild(){{ return this.children[0]||null; }} + setAttribute(name,value){{ + const str=String(value); + this.attributes[name]=str; + if(name==='id') this.id=str; + if(name.startsWith('data-')){{ + const key=name.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); + this.dataset[key]=str; + }} + }} + getAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes,name)?this.attributes[name]:null; }} + hasAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes,name); }} + removeAttribute(name){{ + delete this.attributes[name]; + if(name==='id') this.id=''; + if(name.startsWith('data-')){{ + const key=name.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); + delete this.dataset[key]; + }} + }} + appendChild(child){{ + if(child.parentNode) child.remove(); + child.parentNode=this; + this.children.push(child); + return child; + }} + insertBefore(child, ref){{ + if(child.parentNode) child.remove(); + child.parentNode=this; + const idx=this.children.indexOf(ref); + if(idx<0) this.children.push(child); + else this.children.splice(idx,0,child); + return child; + }} + remove(){{ + if(!this.parentNode) return; + const siblings=this.parentNode.children; + const idx=siblings.indexOf(this); + if(idx>=0) siblings.splice(idx,1); + this.parentNode=null; + }} + matches(selector){{ return matchesSelector(this, selector); }} + querySelector(selector){{ return this.querySelectorAll(selector)[0]||null; }} + querySelectorAll(selector){{ + const out=[]; + const walk=(node)=>{{ + for(const child of node.children){{ + if(matchesSelector(child, selector)) out.push(child); + walk(child); + }} + }}; + walk(this); + return out; + }} + closest(selector){{ + let node=this; + while(node){{ + if(matchesSelector(node, selector)) return node; + node=node.parentNode; + }} + return null; + }} +}} +function matchesSelector(el, selector){{ + return String(selector||'').split(',').some(part=>matchesSimple(el, part.trim())); +}} +function matchesSimple(el, selector){{ + if(!selector) return false; + selector=selector.replace(/^:scope\\s*>\\s*/, '').trim(); + if(selector.includes(' ')) selector=selector.split(/\\s+/).pop(); + const idMatch=selector.match(/#([A-Za-z0-9_-]+)/); + if(idMatch && el.id!==idMatch[1]) return false; + for(const match of selector.matchAll(/\\.([A-Za-z0-9_-]+)/g)){{ + if(!el._classes.has(match[1])) return false; + }} + for(const match of selector.matchAll(/\\[([^=\\]]+)(?:="([^"]*)")?\\]/g)){{ + const attr=match[1]; + const expected=match[2]; + if(!el.hasAttribute(attr)) return false; + if(expected!==undefined && el.getAttribute(attr)!==expected) return false; + }} + return !!(idMatch || selector.includes('.') || selector.includes('[')); +}} +function findById(root, id){{ + if(root.id===id) return root; + for(const child of root.children){{ + const found=findById(child,id); + if(found) return found; + }} + return null; +}} + +const emptyState=new FakeElement('div'); +const msgInner=new FakeElement('div'); +const messages=new FakeElement('div'); +messages.scrollHeight=1000; +messages.scrollTop=1000; +messages.clientHeight=500; +global.document={{createElement:(tag)=>new FakeElement(tag)}}; +global.window={{}}; +global.CSS={{escape:(value)=>String(value)}}; +global.requestAnimationFrame=(fn)=>fn(); +global.S={{session:{{session_id:'sid-1', pending_started_at:123}}, activeStreamId:'stream-1'}}; +global.$=(id)=>{{ + if(id==='emptyState') return emptyState; + if(id==='msgInner') return msgInner; + if(id==='messages') return messages; + return findById(msgInner,id); +}}; +let transparentMode=true; +global.isTransparentStream=()=>transparentMode; +global.isCompactWorklogMode=()=>!transparentMode; +global._anchorSceneRowsForRendering=(scene)=>scene.activity_rows||[]; +global._createAssistantTurn=()=>{{ const el=new FakeElement('div'); el.classList.add('assistant-turn'); return el; }}; +global._assistantTurnBlocks=(turn)=>turn; +global._captureMessageScrollSnapshot=()=>({{scrollHeight:1000}}); +global._prepareLiveAnchorScrollRebuildGuard=()=>({{readerAwayFromBottom:false, release:null}}); +global._restoreMessageScrollSnapshotSameFrame=()=>{{}}; +global.scrollIfPinned=()=>{{}}; +global._moveLiveRunStatusToTurnEnd=()=>{{}}; +global._messageUserUnpinned=false; +global._anchorSceneNodeForRow=(row)=>{{ const node=new FakeElement('div'); node.classList.add('assistant-segment'); node.textContent=row.text||''; return node; }}; +global._thinkingActivityNode=(text)=>{{ const node=new FakeElement('div'); node.classList.add('agent-activity-thinking'); node.textContent=text||''; return node; }}; +global._anchorSceneToolCallFromRow=(row)=>({{name:row.tool&&row.tool.name||row.tool_name||'tool', done:true}}); +global.buildToolCard=(toolCall)=>{{ const node=new FakeElement('div'); node.classList.add('tool-card-row'); node.setAttribute('data-tool-name',toolCall.name); return node; }}; +global._decorateTransparentEventRow=(node,opts)=>{{ + node.classList.add('transparent-event-row'); + node.setAttribute('data-transparent-event-row','1'); + node.setAttribute('data-event-type',opts.type); + if(opts.text) node.setAttribute('data-text',opts.text); + if(opts.name) node.setAttribute('data-tool-name',opts.name); + if(opts.status) node.setAttribute('data-event-status',opts.status); + return node; +}}; +global._transparentToolStatus=()=>'Completed'; +let syncCalls=0; +global._syncTransparentEventControls=(turn)=>{{ + syncCalls++; + turn.setAttribute('data-sync-count',String(syncCalls)); +}}; +let compactGroups=0; +let compactRenders=0; +global._captureWorklogDetailDisclosureState=()=>null; +global._restoreWorklogDetailDisclosureState=()=>{{}}; +global._startActivityElapsedTimer=()=>{{}}; +global._dedupeLiveProcessedWorklogAnchors=()=>{{}}; +global._anchorSceneWorklogGroup=(blocks, opts)=>{{ + compactGroups++; + const group=new FakeElement('div'); + group.classList.add('tool-worklog-group'); + group.setAttribute('data-anchor-scene-owner','1'); + group.setAttribute('data-anchor-stream-id',opts.streamId||''); + blocks.appendChild(group); + return group; +}}; +global._renderAnchorSceneRowsIntoWorklog=(group, rows)=>{{ + compactRenders++; + group.setAttribute('data-rendered-count',String(rows.length)); + return true; +}}; +global._syncToolCallGroupSummary=()=>{{}}; + +eval(extractFunc('_anchorSceneTransparentNodeForRow')); +eval(extractFunc('renderLiveAnchorActivityScene')); +eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); + +const existingTurn=global._createAssistantTurn(); +existingTurn.id='liveAssistantTurn'; +existingTurn.dataset.sessionId='sid-1'; +msgInner.appendChild(existingTurn); +const legacyOne=new FakeElement('div'); +legacyOne.setAttribute('data-live-assistant','1'); +legacyOne.textContent='legacy process one'; +existingTurn.appendChild(legacyOne); +const legacyTwo=new FakeElement('div'); +legacyTwo.setAttribute('data-live-assistant','1'); +legacyTwo.textContent='legacy process two'; +existingTurn.appendChild(legacyTwo); +const staleTool=new FakeElement('div'); +staleTool.classList.add('transparent-event-row'); +staleTool.classList.add('tool-card-row'); +staleTool.setAttribute('data-live-tid','legacy-tool'); +existingTurn.appendChild(staleTool); + +const scene={{ + version:'activity_scene_v1', + activity_rows:[ + {{row_id:'p1', role:'prose', source_event_type:'process_prose', text:'progress one'}}, + {{row_id:'t1', role:'tool', source_event_type:'tool_completed', tool:{{name:'read_file'}}, tool_call_id:'call-1'}}, + {{row_id:'r1', role:'thinking', source_event_type:'reasoning', text:'thinking'}}, + {{row_id:'p2', role:'prose', source_event_type:'process_prose', text:'progress two'}}, + ], +}}; + +assert.strictEqual(renderLiveAnchorActivityScene('stream-1', scene, {{sessionId:'sid-1'}}), true); +assert.strictEqual(renderLiveAnchorActivityScene('stream-1', scene, {{sessionId:'sid-1'}}), true); +const liveTurn=$('liveAssistantTurn'); +const rows=liveTurn.querySelectorAll('[data-anchor-live-scene-row="1"]'); +const staleRows=liveTurn.querySelectorAll('[data-live-tid]'); +const legacySegments=liveTurn.querySelectorAll('[data-live-assistant="1"]'); +const roles=rows.map(row=>row.getAttribute('data-anchor-row-role')); +const eventTypes=rows.map(row=>row.getAttribute('data-anchor-source-event-type')); +const hiddenLegacy=legacySegments.map(row=>({{ + hidden:row.hidden, + ariaHidden:row.getAttribute('aria-hidden'), + source:row.classList.contains('assistant-segment-worklog-source'), +}})); +const settledProbe=_anchorSceneTransparentNodeForRow( + {{row_id:'settled-p1', role:'prose', source_event_type:'process_prose', text:'settled progress'}}, + {{settled:true, finalAnswer:''}} +); + +transparentMode=false; +global.S.activeStreamId='stream-compact'; +delete liveTurn.dataset.sessionId; +assert.strictEqual(renderLiveAnchorActivityScene('stream-compact', scene, {{sessionId:'sid-1'}}), true); + +process.stdout.write(JSON.stringify({{ + rowCount:rows.length, + roles, + eventTypes, + staleRowCount:staleRows.length, + hiddenLegacy, + settledMarker:settledProbe&&settledProbe.getAttribute('data-anchor-settled-scene-row'), + settledLiveMarker:settledProbe&&settledProbe.getAttribute('data-anchor-live-scene-row'), + syncCalls, + compactGroups, + compactRenders, + compactSessionId:liveTurn.dataset.sessionId, + compactRenderedCount:liveTurn.querySelector('.tool-worklog-group').getAttribute('data-rendered-count'), +}})); +""" + result = _run_node_script(script) + + assert result["rowCount"] == 4 + assert result["roles"] == ["prose", "tool", "thinking", "prose"] + assert result["eventTypes"] == ["process_prose", "tool_completed", "reasoning", "process_prose"] + assert result["staleRowCount"] == 0 + assert result["hiddenLegacy"] == [ + {"hidden": True, "ariaHidden": "true", "source": True}, + {"hidden": True, "ariaHidden": "true", "source": True}, + ] + assert result["settledMarker"] == "1" + assert result["settledLiveMarker"] is None + assert result["syncCalls"] == 2 + assert result["compactGroups"] == 1 + assert result["compactRenders"] == 1 + assert result["compactSessionId"] == "sid-1" + assert result["compactRenderedCount"] == "4" + + def test_transparent_anchor_intermediate_prose_preserved_only_final_answer_suppressed(): """#4568 combined fix: intermediate between-tool progress prose must render in Transparent Stream reload (not be blanket-dropped like the first pass did);