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.
198 lines
9.0 KiB
Python
198 lines
9.0 KiB
Python
"""
|
|
Regression coverage for shared compression-anchor visibility helpers (#2028).
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
|
|
from api.streaming import _compression_summary_from_messages, _is_context_compression_marker
|
|
|
|
|
|
def test_legacy_duplicate_anchor_helpers_are_removed():
|
|
routes_src = Path("api/routes.py").read_text(encoding="utf-8")
|
|
streaming_src = Path("api/streaming.py").read_text(encoding="utf-8")
|
|
|
|
assert "def _visible_messages_for_anchor" not in routes_src
|
|
assert "def _visible_messages_for_compression_anchor" not in streaming_src
|
|
assert "visible_messages_for_anchor(compressed, auto_compression=False)" in routes_src
|
|
assert "visible_messages_for_anchor(s.messages, auto_compression=True)" in streaming_src
|
|
|
|
|
|
def test_visible_messages_for_anchor_preserves_manual_text_part_filter():
|
|
text_only = {"role": "assistant", "content": [{"type": "text", "text": "Visible"}]}
|
|
input_only = {"role": "assistant", "content": [{"type": "input_text", "text": "Model input"}]}
|
|
reasoning_only = {"role": "assistant", "content": [{"type": "thinking", "text": "hidden"}]}
|
|
tool_use_only = {"role": "assistant", "content": [{"type": "tool_use", "id": "call_1"}]}
|
|
tool_message = {"role": "tool", "content": "tool output"}
|
|
|
|
assert visible_messages_for_anchor(
|
|
[text_only, input_only, reasoning_only, tool_use_only, tool_message],
|
|
auto_compression=False,
|
|
) == [text_only, reasoning_only, tool_use_only]
|
|
|
|
|
|
def test_visible_messages_for_anchor_preserves_auto_compression_text_part_filter():
|
|
text_only = {"role": "assistant", "content": [{"type": "text", "text": "Visible"}]}
|
|
input_only = {"role": "assistant", "content": [{"type": "input_text", "text": "Model input"}]}
|
|
output_only = {"role": "assistant", "content": [{"type": "output_text", "text": "Model output"}]}
|
|
reasoning_only = {"role": "assistant", "content": [{"type": "reasoning", "text": "hidden"}]}
|
|
tool_message = {"role": "tool", "content": "tool output"}
|
|
|
|
assert visible_messages_for_anchor(
|
|
[text_only, input_only, output_only, reasoning_only, tool_message],
|
|
auto_compression=True,
|
|
) == [text_only, input_only, output_only, reasoning_only]
|
|
|
|
|
|
def test_visible_messages_for_anchor_keeps_manual_user_messages_simple():
|
|
user_tool_metadata = {"role": "user", "content": [], "tool_calls": [{"id": "call_1"}]}
|
|
user_attachment = {"role": "user", "content": [], "attachments": [{"name": "screenshot.png"}]}
|
|
assistant_tool_metadata = {"role": "assistant", "content": [], "tool_calls": [{"id": "call_2"}]}
|
|
|
|
assert visible_messages_for_anchor(
|
|
[user_tool_metadata, user_attachment, assistant_tool_metadata],
|
|
auto_compression=False,
|
|
) == [user_attachment, assistant_tool_metadata]
|
|
|
|
assert visible_messages_for_anchor(
|
|
[user_tool_metadata, user_attachment, assistant_tool_metadata],
|
|
auto_compression=True,
|
|
) == [user_tool_metadata, user_attachment, assistant_tool_metadata]
|
|
|
|
|
|
def test_context_compression_marker_detection_is_prefix_and_role_scoped():
|
|
real_marker = {
|
|
"role": "assistant",
|
|
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.",
|
|
}
|
|
preserved_tasks_marker = {
|
|
"role": "user",
|
|
"content": "[Your active task list was preserved across context compression] - [ ] follow up",
|
|
}
|
|
tool_noise = {
|
|
"role": "tool",
|
|
"content": "{\"description\": \"Troubleshoot frequent context compression indicators\"}",
|
|
}
|
|
user_discussion = {
|
|
"role": "user",
|
|
"content": "Why do I see context compression after every message?",
|
|
}
|
|
|
|
assert is_context_compression_marker(real_marker)
|
|
assert is_context_compression_marker(preserved_tasks_marker)
|
|
assert _is_context_compression_marker(real_marker)
|
|
assert not is_context_compression_marker(tool_noise)
|
|
assert not is_context_compression_marker(user_discussion)
|
|
|
|
|
|
def test_compression_summary_ignores_tool_output_that_mentions_compression():
|
|
marker = {
|
|
"role": "assistant",
|
|
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Keep this handoff as reference.",
|
|
}
|
|
skill_tool_output = {
|
|
"role": "tool",
|
|
"content": "{\"name\": \"hermes-webui-operations\", \"content\": \"Troubleshooting frequent context compression indicators...\"}",
|
|
}
|
|
|
|
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
|