diff --git a/api/streaming.py b/api/streaming.py index 8d9cff7c..9abaf85e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5060,11 +5060,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/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