mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
fix: dedupe live compact worklog process prose
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user