diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ca8b7f..318ac9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.536] — 2026-06-20 — Release SU (transparent-stream tool-call rows persist after settle) + +### Fixed + +- **Transparent-stream tool-call rows no longer vanish on turn settle (#4539).** The settled-node cleanup sweep in `renderMessages` was removing `.tool-card-row` elements regardless of type; it now excludes rows with `data-event-type="tool"` (the transparent-stream rows) so they survive settlement. The burst-key assignment also pre-scans burst IDs into a `Set` before the tool-card loop (O(1) lookup instead of O(n) spread per card), and only uses a burst key when the matching assistant segment is present. Thanks @rodboev. + ## [v0.51.535] — 2026-06-20 — Release ST (open model picker stays stable during stream sync) ### Fixed diff --git a/static/ui.js b/static/ui.js index 6fb23a23..9004c2c0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10796,7 +10796,7 @@ function renderMessages(options){ // regression vs master; same content-loss-on-switch class as #3668). The // `:not([data-live-thinking="1"])` / live-card guards below keep the active // turn's own live nodes from being double-built. - inner.querySelectorAll('.tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking:not([data-live-thinking="1"]):not([data-event-type="thinking"]),.wl-reason[data-worklog-anchor-reason="1"],.wl-reason[data-worklog-reason-source="reasoning"]').forEach(el=>el.remove()); + inner.querySelectorAll('.tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]):not([data-event-type="tool"]),.agent-activity-thinking:not([data-live-thinking="1"]):not([data-event-type="thinking"]),.wl-reason[data-worklog-anchor-reason="1"],.wl-reason[data-worklog-reason-source="reasoning"]').forEach(el=>el.remove()); const byActivity = new Map(); const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b); const _assistantAnchorForActivity=(aIdx,segmentSeq,burstId)=>{ @@ -10847,13 +10847,16 @@ function renderMessages(options){ const hasValue=value!==undefined&&value!==null&&String(value)!==''&&String(value)!=='0'; return hasValue?String(value):''; }; + const knownBurstIds=new Set(); + for(const s of assistantSegments.values()) if(s){const b=s.getAttribute('data-activity-burst-id');if(b)knownBurstIds.add(b);} for(const tc of (S.toolCalls||[])){ if(!tc) continue; const aIdx=tc.assistant_msg_idx!==undefined?parseInt(tc.assistant_msg_idx):-1; if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue; const segmentSeq=normalizeToken(tc.activitySegmentSeq); const burstId=normalizeToken(tc.activityBurstId); - const key=segmentSeq?`segment:${segmentSeq}`:(burstId?`burst:${burstId}`:`assistant:${aIdx}`); + const burstResolvable=burstId&&knownBurstIds.has(burstId); + const key=segmentSeq?`segment:${segmentSeq}`:(burstResolvable?`burst:${burstId}`:`assistant:${aIdx}`); const entry=ensureActivityBucket(key,aIdx,segmentSeq,burstId); entry.cards.push(tc); entry.includeAnchorReason=true; diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py index af0962e7..1b3efc94 100644 --- a/tests/test_issue3820_chat_activity_display_mode.py +++ b/tests/test_issue3820_chat_activity_display_mode.py @@ -420,7 +420,7 @@ def test_thinking_blocks_persist_after_renderMessages(): assert inner_sweep in UI_JS # The promoted-row guard is the only thing that changed; the rest of # the selector must remain intact. - head = ".tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking" + head = ".tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]):not([data-event-type=\"tool\"]),.agent-activity-thinking" tail = ".wl-reason[data-worklog-reason-source=\"reasoning\"]" assert head in UI_JS assert tail in UI_JS diff --git a/tests/test_issue4539_transparent_tool_settle.py b/tests/test_issue4539_transparent_tool_settle.py new file mode 100644 index 00000000..3c654c40 --- /dev/null +++ b/tests/test_issue4539_transparent_tool_settle.py @@ -0,0 +1,99 @@ +"""Regression tests for issue #4539: transparent-stream tool-call rows +vanish on turn settle, reappear only after tab/session switch.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _settled_cleanup_selector(): + """Extract the settled-node cleanup querySelectorAll string.""" + anchor = ".tool-worklog-group:not([data-compression-card])" + idx = UI_JS.index(anchor) + marker = ".forEach(el=>el.remove())" + end = UI_JS.index(marker, idx) + len(marker) + line_start = UI_JS.rfind("\n", 0, idx) + 1 + return UI_JS[line_start:end] + + +def _tool_bucketing_block(): + """Extract the tool-call bucketing region including burst-ID pre-scan.""" + start = UI_JS.index("const knownBurstIds=") + end = UI_JS.index("\n }", UI_JS.index("for(const tc of (S.toolCalls||[])){", start)) + len("\n }") + return UI_JS[start:end] + + +class TestCleanupSelectorProtectsTransparentToolRows: + """Fix A: the settled-node cleanup must not remove transparent tool rows.""" + + def test_tool_card_row_arm_excludes_event_type_tool(self): + selector = _settled_cleanup_selector() + assert ".tool-card-row:not([data-compression-card]):not([data-event-type=\"tool\"])" in selector, ( + "The .tool-card-row cleanup arm must exclude [data-event-type='tool'] " + "rows to protect transparent tool-call rows from deletion" + ) + + def test_thinking_arm_still_excludes_event_type_thinking(self): + selector = _settled_cleanup_selector() + assert ":not([data-event-type=\"thinking\"])" in selector, ( + "The .agent-activity-thinking arm must still exclude " + "[data-event-type='thinking'] rows" + ) + + def test_tool_and_thinking_arms_follow_same_event_type_pattern(self): + selector = _settled_cleanup_selector() + assert ":not([data-event-type=\"tool\"])" in selector + assert ":not([data-event-type=\"thinking\"])" in selector + + +class TestBurstIdFallbackWhenSegmentLacksBurstAttribute: + """Fix B: bucketing must fall back to assistant:${aIdx} when no segment + carries a matching data-activity-burst-id.""" + + def test_burst_resolvable_check_before_burst_key(self): + block = _tool_bucketing_block() + assert "burstResolvable" in block, ( + "The tool-call bucketing loop must check burst-ID resolvability " + "before using 'burst:' as the bucket key" + ) + + def test_burst_ids_pre_scanned_from_assistant_segments(self): + block = _tool_bucketing_block() + assert "assistantSegments.values()" in block, ( + "knownBurstIds must be built from assistantSegments before the loop" + ) + assert "knownBurstIds.has(burstId)" in block, ( + "burstResolvable must use the pre-scanned knownBurstIds set" + ) + + def test_fallback_uses_burst_resolvable_not_raw_burst_id(self): + block = _tool_bucketing_block() + assert re.search(r"burstResolvable\?.*burst:", block), ( + "The bucket key must use burstResolvable (not raw burstId) " + "as the guard for the burst: key path" + ) + + def test_assistant_fallback_preserved(self): + block = _tool_bucketing_block() + assert "`assistant:${aIdx}`" in block, ( + "The assistant:${aIdx} fallback must still be present" + ) + + +class TestDecorateTransparentEventRowSetsEventType: + """Confirms the decorator still sets the attributes the cleanup + selector depends on for its exclusion.""" + + def test_decorator_sets_data_event_type(self): + start = UI_JS.index("function _decorateTransparentEventRow(row, opts){") + end = UI_JS.index("\nfunction ", start + 1) + block = UI_JS[start:end] + assert "row.setAttribute('data-event-type',type)" in block + + def test_tool_type_triggers_transparent_event_card(self): + start = UI_JS.index("function _decorateTransparentEventRow(row, opts){") + end = UI_JS.index("\nfunction ", start + 1) + block = UI_JS[start:end] + assert "if(type==='tool')" in block