From 4c8e88ab2d613ad513e5c9b3cc88b77976a67ca2 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 23 Jun 2026 22:58:25 +0800 Subject: [PATCH] fix: dedupe live compact worklog process prose --- CHANGELOG.md | 4 ++ static/messages.js | 38 +++++++++-- static/ui.js | 25 +++++++ tests/test_issue2403_interim_collapse.py | 17 ++++- tests/test_live_anchor_progress_echo.py | 15 +++- ...test_live_to_final_anchor_visible_order.py | 68 +++++++++++++++++++ 6 files changed, 158 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b5a2af6..dc2d0e8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/messages.js b/static/messages.js index 3888c7627..9f5833f58 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/ui.js b/static/ui.js index 1c9f759cb..b35874d59 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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)&&idxel.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; } } } diff --git a/tests/test_issue2403_interim_collapse.py b/tests/test_issue2403_interim_collapse.py index c271de6c5..9d5ce5eb4 100644 --- a/tests/test_issue2403_interim_collapse.py +++ b/tests/test_issue2403_interim_collapse.py @@ -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." ) - diff --git a/tests/test_live_anchor_progress_echo.py b/tests/test_live_anchor_progress_echo.py index f20438d4c..2b988c651 100644 --- a/tests/test_live_anchor_progress_echo.py +++ b/tests/test_live_anchor_progress_echo.py @@ -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(): diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index f3702d1c4..fa56fa90a 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -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 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