From 90dfbf2f2dffc37537668667ece6ddd9d42c5a5c Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Mon, 25 May 2026 15:16:26 +0800 Subject: [PATCH] fix: marker-based compression anchor calculation Instead of using len(visible_after)-1 (which points to the last visible message and gets pushed behind the render window as more turns accumulate), find the last [CONTEXT COMPACTION] marker in s.messages and compute the anchor from visible messages before it. This keeps the compression reference card at the correct boundary even after 50+ subsequent turns have scrolled the render window past the old anchor position. Fixes a bug where the assistant's output message appeared to disappear after automatic context compression because the reference card was placed at the wrong position. --- api/streaming.py | 53 +++++++++- static/messages.js | 32 ++++-- ...st_issue2028_compression_anchor_helpers.py | 98 +++++++++++++++++++ 3 files changed, 172 insertions(+), 11 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 1c72f13a..d8e4c932 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4684,11 +4684,56 @@ def _run_agent_streaming( # Notify the frontend that compression happened if _compressed: visible_after = visible_messages_for_anchor(s.messages, auto_compression=True) - s.compression_anchor_visible_idx = ( - max(0, len(visible_after) - 1) if visible_after else None - ) + # Find the LAST [CONTEXT COMPACTION] marker in s.messages + # and count visible messages before it. This is the correct + # anchor — it points to the compression boundary regardless + # of how many turns have been added since the boundary was + # established. Using len(visible_before)-1 is fragile when + # _previous_messages doesn't include markers or when extra + # messages accumulate between compression and the done event. + _last_marker_raw_idx = None + for _mi, _m in enumerate(s.messages): + if _is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + if _last_marker_raw_idx is not None: + _visible_before_marker = visible_messages_for_anchor( + s.messages[:_last_marker_raw_idx], auto_compression=True, + ) + s.compression_anchor_visible_idx = max(0, len(_visible_before_marker) - 1) + logger.info( + '[ANCHOR-MARKER] session=%s marker_raw=%d vis_before=%d anchor=%d', + getattr(s, 'session_id', '?'), + _last_marker_raw_idx, + len(_visible_before_marker), + s.compression_anchor_visible_idx, + ) + else: + # Fallback: use pre-turn display messages + visible_before = visible_messages_for_anchor( + _previous_messages, auto_compression=True, + ) + if visible_before: + s.compression_anchor_visible_idx = max(0, len(visible_before) - 1) + elif visible_after: + s.compression_anchor_visible_idx = 0 + else: + s.compression_anchor_visible_idx = None + logger.info( + '[ANCHOR-FALLBACK] session=%s vis_before=%d anchor=%d', + getattr(s, 'session_id', '?'), + len(visible_before) if visible_before else 0, + s.compression_anchor_visible_idx if s.compression_anchor_visible_idx is not None else -1, + ) + # Pick anchor_msg for _compression_anchor_message_key + _anchor_vis_idx = s.compression_anchor_visible_idx + if _anchor_vis_idx is not None and visible_after and _anchor_vis_idx < len(visible_after): + anchor_msg = visible_after[_anchor_vis_idx] + elif visible_after: + anchor_msg = visible_after[-1] + else: + anchor_msg = None s.compression_anchor_message_key = ( - _compression_anchor_message_key(visible_after[-1]) if visible_after else None + _compression_anchor_message_key(anchor_msg) if anchor_msg else None ) s.compression_anchor_summary = _compact_summary_text( _compression_summary_from_messages(s.messages) diff --git a/static/messages.js b/static/messages.js index b5052fb8..2baf8ebc 100644 --- a/static/messages.js +++ b/static/messages.js @@ -570,10 +570,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // A same-stream transport can be reused unless the browser has already // marked it closed; closed streams must still fall through to reopen. (typeof EventSource==='undefined'||existingLive.source.readyState!==EventSource.CLOSED) - ){ - return; - } - closeOtherLiveStreams(activeSid); + ){ + window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; + } + // Compression is complete — clear the transient UI state so + // renderMessages() uses the session's anchor metadata (reference card) + // instead of the live compression card, allowing compaction marker + // messages to be properly handled. + window._compressionUi = null; + // Find the last assistant message closeLiveStream(activeSid); let assistantText=''; @@ -1649,6 +1654,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ){ window._compressionUi={...window._compressionUi, sessionId:d.session.session_id}; } + // Compression is complete — clear the transient UI state so + // renderMessages() uses the session's anchor metadata (reference card) + // instead of the live compression card, allowing compaction marker + // messages to be properly handled. + window._compressionUi = null; // Find the last assistant message once for both reasoning persistence and timestamp const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant'); // Persist reasoning trace so thinking card survives page reload @@ -1721,6 +1731,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); + // Clear stale auto-compression UI state once the turn is fully + // committed, so subsequent renderMessages() calls do not project + // a ghost compression card or skip compaction marker messages. + if(window._compressionUi&&window._compressionUi.automatic){ + window._compressionUi=null; + if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); + } if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); loadDir('.'); // TTS auto-read: speak the last assistant response if enabled (#499) @@ -1787,13 +1804,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('compressing',e=>{ // Context auto-compression is starting. Surface the same calm running // compression card as manual /compress while the summarizer LLM call runs. - if(!S.session||S.session.session_id!==activeSid) return; + if(!S.session) return; + const currentSid=S.session.session_id; let d={}; try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } - if(d.session_id&&d.session_id!==activeSid) return; + if(d.session_id&&d.session_id!==currentSid) return; if(typeof setCompressionUi==='function'){ const state={ - sessionId:activeSid, + sessionId:currentSid, phase:'running', automatic:true, message:d.message||'Auto-compressing context...', diff --git a/tests/test_issue2028_compression_anchor_helpers.py b/tests/test_issue2028_compression_anchor_helpers.py index d675d2a0..35334885 100644 --- a/tests/test_issue2028_compression_anchor_helpers.py +++ b/tests/test_issue2028_compression_anchor_helpers.py @@ -97,3 +97,101 @@ def test_compression_summary_ignores_tool_output_that_mentions_compression(): assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"] assert _compression_summary_from_messages([skill_tool_output]) is None + + +def test_marker_based_anchor_calculation(): + """Verify that the marker-based anchor logic used in streaming.py's done + handler correctly computes compression_anchor_visible_idx from the last + [CONTEXT COMPACTION] marker position. This prevents the reference card + from being pushed behind the render window after subsequent turns.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "first reply"}, + {"role": "tool", "content": "tool output"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "second reply"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] summary"}, + {"role": "user", "content": "third question"}, + {"role": "assistant", "content": "third reply"}, + {"role": "tool", "content": "more tool output"}, + {"role": "user", "content": "fourth question"}, + {"role": "assistant", "content": "fourth reply"}, + ] + + # Simulate the fix logic from streaming.py + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx == 5, "expected marker at index 5" + + _visible_before_marker = visible_messages_for_anchor( + messages[:_last_marker_raw_idx], auto_compression=True, + ) + anchor = max(0, len(_visible_before_marker) - 1) + # Visible before marker: 4 messages (hello, first reply, second question, second reply) + # tool at index 2 is filtered out. Anchor = 3 (last visible before marker) + assert anchor == 3, f"expected anchor 3, got {anchor}" + + # Verify the anchor points to the right message + full_vis = visible_messages_for_anchor(messages, auto_compression=True) + # full_vis: hello, first reply, second question, second reply, third question, third reply, fourth question, fourth reply = 8 + assert len(full_vis) == 8, f"expected 8, got {len(full_vis)}" + assert full_vis[anchor]["content"] == "second reply" + + # After additional turns, anchor stays at marker boundary + messages_extended = messages + [ + {"role": "user", "content": "fifth question"}, + {"role": "assistant", "content": "fifth reply"}, + {"role": "tool", "content": "hidden"}, + ] + full_vis_ext = visible_messages_for_anchor(messages_extended, auto_compression=True) + # The anchor should still point to the same position in full_vis_ext + assert full_vis_ext[anchor]["content"] == "second reply" + assert anchor < len(full_vis_ext) - 3 # anchor is not at the end + + +def test_marker_based_anchor_multiple_compressions(): + """With multiple compression markers, the anchor uses the LAST marker.""" + messages = [ + {"role": "user", "content": "A"}, + {"role": "assistant", "content": "B"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] first"}, + {"role": "user", "content": "C"}, + {"role": "assistant", "content": "D"}, + {"role": "assistant", "content": "[CONTEXT COMPACTION \u2014 REFERENCE ONLY] second"}, + {"role": "user", "content": "E"}, + {"role": "assistant", "content": "F"}, + ] + # Find last marker + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx == 5 + + _visible_before_marker = visible_messages_for_anchor( + messages[:_last_marker_raw_idx], auto_compression=True, + ) + anchor = max(0, len(_visible_before_marker) - 1) + # Visible before last marker at raw[5]: messages[0:5] = [A, B, marker, C, D] + # After filtering: A, B, C, D = 4 visible. Anchor = 3 (last visible = D) + assert anchor == 3, f"expected anchor 3, got {anchor}" + assert _visible_before_marker[anchor]["content"] == "D" + + +def test_marker_based_anchor_fallback_when_no_marker(): + """When no compression marker exists, fall back to old behavior.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "reply"}, + ] + _last_marker_raw_idx = None + for _mi, _m in enumerate(messages): + if is_context_compression_marker(_m): + _last_marker_raw_idx = _mi + assert _last_marker_raw_idx is None + + # Should fall through (not crash) - verified by the old path + visible_after = visible_messages_for_anchor(messages, auto_compression=True) + assert len(visible_after) > 0