Fix transparent live anchor scene replay

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank Song 2026-06-30 11:09:22 +08:00
parent 95f1486567
commit 6ac5b9dbee
2 changed files with 425 additions and 3 deletions

View file

@ -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});
}

View file

@ -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; i<src.length; i++){{
if(src[i] === '(') depth++;
else if(src[i] === ')'){{
depth--;
if(depth === 0){{ close = i; break; }}
}}
}}
const brace = src.indexOf('{{', close);
depth = 0;
for(let i=brace; i<src.length; i++){{
if(src[i] === '{{') depth++;
else if(src[i] === '}}'){{
depth--;
if(depth === 0) return src.slice(start, i + 1);
}}
}}
throw new Error(name + ' body did not close');
}}
class FakeElement {{
constructor(tag='div'){{
this.tagName=tag.toUpperCase();
this.children=[];
this.parentNode=null;
this.attributes={{}};
this.dataset={{}};
this.style={{}};
this.hidden=false;
this.id='';
this.textContent='';
this._classes=new Set();
const self=this;
this.classList={{
add(...names){{ names.forEach(name=>self._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);