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.
This commit is contained in:
fxd-jason
2026-05-25 15:16:26 +08:00
parent 7aae822872
commit 90dfbf2f2d
3 changed files with 172 additions and 11 deletions
+49 -4
View File
@@ -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)
+25 -7
View File
@@ -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...',
@@ -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