fix: dedupe live compact worklog process prose

This commit is contained in:
Frank Song
2026-06-23 22:58:25 +08:00
parent 8a98626967
commit 4c8e88ab2d
6 changed files with 158 additions and 9 deletions
+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Fixed
- **Compact Worklog no longer shows duplicate live process prose while a response is still streaming.** When the same progress sentence arrives through both interim assistant text and Anchor activity rows, the live scene now keeps one visible Process row, removes matching live reasoning echoes from the Worklog DOM, and hides legacy source assistant segments so they cannot appear as duplicate transcript prose.
## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template)
### Changed
+34 -4
View File
@@ -2831,6 +2831,34 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
return false;
}
function _removeLiveReasoningEchoRows(visible){
const turn=$('liveAssistantTurn');
const blocks=turn&&typeof _assistantTurnBlocks==='function'?_assistantTurnBlocks(turn):null;
if(!blocks||!visible) return false;
let removed=false;
const selector=[
'.agent-activity-thinking[data-anchor-scene-row="1"]',
'.agent-activity-thinking[data-live-thinking="1"]',
'.wl-reason[data-worklog-anchor-reason="1"]',
'.wl-reason[data-worklog-reason-source="reasoning"]'
].join(',');
blocks.querySelectorAll(selector).forEach(row=>{
const textNode=row.querySelector&&(
row.querySelector('.thinking-card-body pre') ||
row.querySelector('.thinking-card-body')
);
const text=String((textNode&&textNode.textContent)||row.textContent||'');
if(!_stripCompactEchoSuffix(text, visible).removed) return;
row.remove();
removed=true;
});
if(removed&&typeof _syncToolCallGroupSummary==='function'){
blocks.querySelectorAll('.tool-worklog-group,.tool-call-group').forEach(group=>{
_syncToolCallGroupSummary(group);
});
}
return removed;
}
function _stripLiveReasoningEcho(visible){
let removed=false;
const durable=_stripCompactEchoSuffix(reasoningText, visible);
@@ -2844,11 +2872,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
removed=true;
}
const anchorRemoved=_stripAnchorReasoningEcho(visible);
const domRemoved=_removeLiveReasoningEchoRows(visible);
if(removed) syncInflightAssistantMessage();
if((removed||anchorRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){
if((removed||anchorRemoved||domRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){
removeThinking();
}
return removed||anchorRemoved;
return removed||anchorRemoved||domRemoved;
}
function _flushReasoningToAnchor(){
if(_anchorReasoningFlushed||!reasoningText) return;
@@ -3404,6 +3433,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _flushPendingSegmentRender(options={}){
const force=!!(options&&options.force);
const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);
if(!assistantBody||(!force&&!_renderPending)) return;
if(_renderPending) _cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
@@ -3428,7 +3458,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
} else {
assistantBody.innerHTML=esc(displayText);
}
_upsertAnchorProcessProse(displayText,{sealed:force});
if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});
if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText);
}
function _resetAssistantSegment(){
@@ -3871,7 +3901,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_completeAutomaticCompressionOnLiveProgress(activeSid);
ensureAssistantRow(true);
if(assistantRow) assistantRow.setAttribute('data-interim','1');
_flushPendingSegmentRender({force:true});
_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup();
_applyToAnchor('interim_assistant',d,e);
+25
View File
@@ -9079,6 +9079,7 @@ function _syncWorklogReasonFromAnchor(group, anchor, displayTextOverride){
if(anchor){
anchor.classList.add('assistant-segment-worklog-source');
anchor.setAttribute('aria-hidden','true');
anchor.hidden=true;
}
}
function ensureLiveWorklogContainer(blocks, opts){
@@ -9142,6 +9143,7 @@ function _appendWorklogReason(list, anchor){
if(anchor){
anchor.classList.add('assistant-segment-worklog-source');
anchor.setAttribute('aria-hidden','true');
anchor.hidden=true;
}
return reason;
}
@@ -9263,8 +9265,11 @@ function _appendWorklogStep(group, anchor, cards, thinkingText, opts){
function _anchorSceneRowsForRendering(scene, opts){
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
const settled=!!(opts&&opts.settled);
const live=!settled;
const out=[];
const byKey=new Map();
const liveProseTextKeys=new Map();
const proseTextKey=(value)=>String(value||'').replace(/\s+/g,' ').trim();
const keyFor=(row)=>{
if(!row) return '';
if(row.role==='tool') return `tool:${_anchorSceneToolRowLogicalKey(row)||row.row_id||row.event_id||row.local_id||out.length}`;
@@ -9286,8 +9291,23 @@ function _anchorSceneRowsForRendering(scene, opts){
const key=keyFor(row);
if(byKey.has(key)){
const index=byKey.get(key);
if(live&&row.role==='prose'){
const textKey=proseTextKey(text);
const duplicateIndex=textKey?liveProseTextKeys.get(textKey):undefined;
if(duplicateIndex!==undefined&&duplicateIndex!==index) continue;
const previousTextKey=proseTextKey(out[index]&&out[index].text);
if(previousTextKey&&previousTextKey!==textKey&&liveProseTextKeys.get(previousTextKey)===index){
liveProseTextKeys.delete(previousTextKey);
}
if(textKey) liveProseTextKeys.set(textKey,index);
}
out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row;
}else{
if(live&&row.role==='prose'){
const textKey=proseTextKey(text);
if(textKey&&liveProseTextKeys.has(textKey)) continue;
if(textKey) liveProseTextKeys.set(textKey,out.length);
}
byKey.set(key,out.length);
out.push(row);
}
@@ -9600,6 +9620,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){
blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{
el.classList.add('assistant-segment-worklog-source');
el.setAttribute('aria-hidden','true');
el.hidden=true;
});
const group=_anchorSceneWorklogGroup(blocks,{
live:true,
@@ -9664,6 +9685,7 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx
if(Number.isFinite(idx)&&idx<rawIdx){
node.classList.add('assistant-segment-worklog-source');
node.setAttribute('aria-hidden','true');
node.hidden=true;
}
});
let wrote=false;
@@ -9696,6 +9718,7 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
if(Number.isFinite(idx)&&idx<rawIdx){
node.classList.add('assistant-segment-worklog-source');
node.setAttribute('aria-hidden','true');
node.hidden=true;
}
});
blocks.querySelectorAll('.tool-worklog-group:not([data-anchor-scene-owner="1"]),.tool-call-group:not([data-anchor-scene-owner="1"]),.agent-activity-thinking:not([data-anchor-scene-row="1"]),.wl-reason').forEach(el=>el.remove());
@@ -11411,6 +11434,7 @@ function renderMessages(options){
if(messageBelongsInWorklog){
seg.classList.add('assistant-segment-worklog-source');
seg.setAttribute('aria-hidden','true');
seg.hidden=true;
}
if(m._live){
currentAssistantTurn.id='liveAssistantTurn';
@@ -12148,6 +12172,7 @@ function renderMessages(options){
if(!(seg.textContent||'').trim()) continue;
seg.classList.remove('assistant-segment-worklog-source');
seg.removeAttribute('aria-hidden');
seg.hidden=false;
}
}
}
+14 -3
View File
@@ -30,6 +30,18 @@ def _extract_interim_handler(src):
pos = idx
def _visible_interim_flush_pos(fn):
attr_pos = fn.index("setAttribute('data-interim','1')")
candidates = [
"_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true})",
"_flushPendingSegmentRender({force:true})",
]
positions = [fn.find(candidate, attr_pos) for candidate in candidates]
positions = [pos for pos in positions if pos != -1]
assert positions, "visible interim flush call not found after data-interim marker"
return min(positions)
class TestInterimCollapseHandlerStructure:
"""The interim_assistant handler must contain the collapse threshold and logic."""
@@ -93,7 +105,7 @@ class TestInterimCollapseHandlerStructure:
src = read("static/messages.js")
fn = _extract_interim_handler(src)
attr_pos = fn.index("setAttribute('data-interim','1')")
flush_pos = fn.rindex("_flushPendingSegmentRender({force:true})")
flush_pos = _visible_interim_flush_pos(fn)
assert attr_pos < flush_pos, (
"data-interim attribute must be set before _flushPendingSegmentRender "
"so the segment is marked before it is sealed"
@@ -102,7 +114,7 @@ class TestInterimCollapseHandlerStructure:
def test_collapse_after_flush_before_reset(self):
src = read("static/messages.js")
fn = _extract_interim_handler(src)
flush_pos = fn.index("_flushPendingSegmentRender({force:true})")
flush_pos = _visible_interim_flush_pos(fn)
collapse_pos = fn.index("INTERIM_COLLAPSE_THRESHOLD")
reset_pos = fn.index("_resetAssistantSegment()", collapse_pos)
assert flush_pos < collapse_pos < reset_pos, (
@@ -201,4 +213,3 @@ class TestInterimCollapseSurvivesLiveTurnRestore:
"toggle must carry data-threshold so the delegated handler can "
"recompute the collapse set without closure state after a restore."
)
+13 -2
View File
@@ -27,20 +27,31 @@ def test_interim_reasoning_echo_cleans_live_and_anchor_thinking():
assert "const reasoningEcho=!!(d&&d.reasoning_echo);" in body
assert "if(reasoningEcho) _stripLiveReasoningEcho(visible);" in body
assert "function _stripAnchorReasoningEcho(visible)" in MESSAGES
assert "function _removeLiveReasoningEchoRows(visible)" in MESSAGES
assert "events.splice(i,1);" in MESSAGES
assert '.agent-activity-thinking[data-anchor-scene-row="1"]' in MESSAGES
assert "_removeLiveReasoningEchoRows(visible)" in MESSAGES
assert "reasoningText=durable.text;" in MESSAGES
assert "liveReasoningText=live.text;" in MESSAGES
def test_interim_anchor_render_runs_after_legacy_segment_flush():
def test_interim_anchor_render_runs_after_legacy_segment_flush_without_duplicate_process_row():
body = _interim_listener_body()
flush_idx = body.index("_flushPendingSegmentRender({force:true});")
flush_idx = body.index("_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});")
anchor_idx = body.index("_applyToAnchor('interim_assistant',d,e);")
flush_fn_start = MESSAGES.index("function _flushPendingSegmentRender")
flush_fn = MESSAGES[flush_fn_start : MESSAGES.index("function _resetAssistantSegment", flush_fn_start)]
assert "const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);" in flush_fn
assert "if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});" in flush_fn
assert flush_idx < anchor_idx, (
"Anchor live scene must render after the legacy interim segment is flushed, "
"so renderLiveAnchorActivityScene can hide that source segment immediately."
)
assert "_flushPendingSegmentRender({force:true});" in body, (
"already_streamed interim updates must still flush the token-owned prose row."
)
def test_live_anchor_scene_hides_legacy_live_assistant_sources():
@@ -338,12 +338,78 @@ def test_scene_renderer_coalesces_row_updates_and_renders_in_scene_order():
render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog")
live = _function_body(UI_JS, "renderLiveAnchorActivityScene")
assert "const live=!settled" in rows
assert "const liveProseTextKeys=new Map()" in rows
assert "if(textKey&&liveProseTextKeys.has(textKey)) continue;" in rows
assert "byKey.set(key,out.length)" in rows
assert "out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row" in rows
assert "for(const row of rows)" in render
assert "_anchorSceneNodeForRow(row,opts)" in render
assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]')" in live
assert "assistant-segment-worklog-source" in live
assert "el.hidden=true" in live
@pytest.mark.skipif(NODE is None, reason="node is required for anchor row normalization tests")
def test_live_anchor_scene_dedupes_exact_duplicate_process_prose_only_live():
script = f"""
const fs = require('fs');
const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8');
function extractFunc(name){{
const start = src.indexOf('function ' + name);
if(start === -1) 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');
}}
function _anchorSceneToolRowLogicalKey(){{ return ''; }}
function _anchorSceneMergeToolRows(a,b){{ return b; }}
function _anchorSceneIsSettledSuccessfulCompression(){{ return false; }}
eval(extractFunc('_anchorSceneRowsForRendering'));
const scene = {{
activity_rows: [
{{role:'prose', local_id:'reasoning:291', text:'same process prose'}},
{{role:'prose', local_id:'interim:293', text:' same\\nprocess prose '}},
{{role:'thinking', local_id:'thinking:1', text:'same process prose'}},
{{role:'prose', local_id:'process:294', text:'new process prose'}}
]
}};
const liveRows = _anchorSceneRowsForRendering(scene, {{settled:false}});
const settledRows = _anchorSceneRowsForRendering(scene, {{settled:true}});
console.log(JSON.stringify({{
live: liveRows.map(row => row.role + ':' + row.text.replace(/\\s+/g, ' ').trim()),
settled: settledRows.map(row => row.role + ':' + row.text.replace(/\\s+/g, ' ').trim())
}}));
"""
result = _run_node_script(script)
assert result["live"] == [
"prose:same process prose",
"thinking:same process prose",
"prose:new process prose",
]
assert result["settled"] == [
"prose:same process prose",
"prose:same process prose",
"thinking:same process prose",
"prose:new process prose",
]
def test_live_anchor_scene_removes_legacy_interim_collapse_toggle():
@@ -752,6 +818,7 @@ def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source():
"if(m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) return true;"
)
assert "seg.classList.add('assistant-segment-worklog-source')" in render
assert "seg.hidden=true" in render
assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in render
@@ -763,6 +830,7 @@ def test_settled_anchor_scene_hides_prior_process_segments_not_final_answer():
assert "idx<rawIdx" in settled
assert "node.classList.add('assistant-segment-worklog-source')" in settled
assert "node.setAttribute('aria-hidden','true')" in settled
assert "node.hidden=true" in settled
assert "turnDuration:message._turnDuration!==undefined&&message._turnDuration!==null?message._turnDuration:scene.turn_duration" in settled
assert "turnDuration:opts&&opts.turnDuration" in group
assert "data-turn-duration" in group