hermes-agent/tests/agent/test_context_compressor.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

2696 lines
116 KiB
Python

"""Tests for agent/context_compressor.py — compression logic, thresholds, truncation fallback."""
import json
import pytest
import time
from unittest.mock import patch, MagicMock
from agent.context_compressor import (
ContextCompressor,
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
_summarize_tool_result,
_is_summary_access_or_quota_error,
)
from hermes_state import SessionDB
class StubProviderError(Exception):
def __init__(self, message, *, status_code=None, response=None):
super().__init__(message)
self.status_code = status_code
self.response = response
@pytest.fixture()
def compressor():
"""Create a ContextCompressor with mocked dependencies."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
# Resolve context_length while the mock is still active so the
# fixture returns a fully-initialized compressor.
_ = c.context_length
return c
class TestSummarizeToolResultWebExtract:
"""Pre-compression pruning must survive web_extract calls whose ``urls`` are
web_search result dicts ({"url"/"href": ...}), which models routinely forward
straight into web_extract.
"""
CONTENT = "x" * 500 # >200 chars so the pruning pass actually summarizes
def test_single_dict_url_is_unwrapped_not_stringified(self):
args = json.dumps({"urls": [{"url": "https://example.com/a", "title": "A"}]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/a (500 chars)"
assert "{" not in summary # no raw dict repr leaked into the summary
def test_href_key_is_unwrapped(self):
args = json.dumps({"urls": [{"href": "https://example.com/h"}]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/h (500 chars)"
class TestShouldCompress:
def test_below_threshold(self, compressor):
compressor.last_prompt_tokens = 50000
assert compressor.should_compress() is False
def test_above_threshold(self, compressor):
compressor.last_prompt_tokens = 90000
assert compressor.should_compress() is True
def test_explicit_tokens(self, compressor):
assert compressor.should_compress(prompt_tokens=90000) is True
assert compressor.should_compress(prompt_tokens=50000) is False
class TestUpdateFromResponse:
def test_updates_fields(self, compressor):
compressor.awaiting_real_usage_after_compression = True
compressor.last_compression_rough_tokens = 90_000
compressor.update_from_response({
"prompt_tokens": 5000,
"completion_tokens": 1000,
"total_tokens": 6000,
})
assert compressor.last_prompt_tokens == 5000
assert compressor.last_completion_tokens == 1000
assert compressor.last_real_prompt_tokens == 5000
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
assert compressor.awaiting_real_usage_after_compression is False
def test_missing_fields_default_zero(self, compressor):
compressor.update_from_response({})
assert compressor.last_prompt_tokens == 0
class TestPreflightDeferral:
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
compressor.threshold_tokens = 85_000
compressor.last_real_prompt_tokens = 50_000
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
def test_defers_immediately_after_compaction_with_stale_real_prompt(self, compressor):
"""#36718: right after a compaction, last_real_prompt_tokens still holds
the stale pre-compression value (above threshold). The awaiting flag
must force deferral so preflight doesn't fire a SECOND compaction before
real post-compaction usage arrives."""
compressor.threshold_tokens = 85_000
# Stale pre-compression value — would hit the `>= threshold => False`
# short-circuit and defeat deferral without the flag guard.
compressor.last_real_prompt_tokens = 120_000
compressor.awaiting_real_usage_after_compression = True
assert compressor.should_defer_preflight_to_real_usage(95_000) is True
class TestCompress:
def _make_messages(self, n):
return [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(n)]
def test_fallback_summary_does_not_triplicate_latest_user_ask(self):
"""Regression for #49307: the deterministic fallback summary used to
render the latest user ask verbatim under THREE headings (Task
Snapshot, In-Progress, Pending Asks). The model then re-answered it
and buried the genuinely-new post-compaction turn (answer repetition +
new-instruction loss). The latest ask must appear ONCE, as historical
context only — never re-presented as unfulfilled in-progress/pending
work.
"""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test/model", quiet_mode=True)
unique_ask = "PLEASE_COMPUTE_THE_ARITHMETIC_CHAIN_XYZ"
turns = [
{"role": "user", "content": unique_ask},
{"role": "assistant", "content": "working on it"},
]
summary = c._build_static_fallback_summary(turns, reason="provider down")
# The triplication bug rendered the SAME ``active_task`` line —
# formatted as ``User asked: '<ask>'`` — verbatim under three
# headings (Task Snapshot, In-Progress, Pending Asks), making the
# model treat an already-handled ask as unresolved work and re-answer
# it. That exact formatted line must now appear at most ONCE (only as
# the historical Task Snapshot record). The raw ask text may still
# appear elsewhere (e.g. the "Last Dropped Turns" verbatim transcript),
# but never re-labeled as in-progress/pending work.
active_task_line = f"User asked: {unique_ask!r}"
count = summary.count(active_task_line)
assert count <= 1, (
f"active_task line should appear at most once (was triplicated in "
f"#49307), found {count}x:\n{summary}"
)
def test_threshold_below_window_at_minimum_ctx(self):
"""Regression for #14690: at context_length == MINIMUM_CONTEXT_LENGTH
the floored threshold used to equal the whole window, so
auto-compression could never fire. It now triggers at 85% of the
window — high enough not to waste the small budget, below 100% so it
actually fires."""
from agent.context_compressor import MINIMUM_CONTEXT_LENGTH
t = ContextCompressor._compute_threshold_tokens(MINIMUM_CONTEXT_LENGTH, 0.50)
assert t < MINIMUM_CONTEXT_LENGTH
assert t == 54400 # 85% of 64000
def test_max_tokens_coercion_treats_non_int_as_no_reservation(self):
"""A non-int / non-positive max_tokens must coerce safely so the
threshold arithmetic never raises. Guards the path where a mocked
parent agent forwards a MagicMock max_tokens into a child
ContextCompressor (regression for the delegate-test TypeError:
'<=' not supported between MagicMock and int)."""
from unittest.mock import MagicMock
assert ContextCompressor._coerce_max_tokens(None) is None
assert ContextCompressor._coerce_max_tokens(0) is None
assert ContextCompressor._coerce_max_tokens(-5) is None
assert ContextCompressor._coerce_max_tokens("nope") is None
assert ContextCompressor._coerce_max_tokens(65536) == 65536
# The actual regression: building a compressor with a MagicMock
# max_tokens must NOT raise (the unmocked code did `ctx - MagicMock`
# then `MagicMock <= 0`). int(MagicMock()) returns 1, so coercion
# yields a harmless positive int rather than crashing — the threshold
# is computed cleanly with a 1-token reservation.
with patch("agent.context_compressor.get_model_context_length", return_value=200000):
c = ContextCompressor(model="m", quiet_mode=True, max_tokens=MagicMock())
assert isinstance(c.max_tokens, int)
assert isinstance(c.threshold_tokens, int)
assert c.threshold_tokens > 0 # no crash, sane value
def test_compress_strips_db_persisted_from_assembled_messages(self, compressor):
"""Regression for #57491: shallow copies must not carry flush markers."""
msgs = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}", "_db_persisted": True}
for i in range(10)
]
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
result = compressor.compress(msgs)
assert len(result) < len(msgs)
assert all("_db_persisted" not in msg for msg in result)
def test_compress_terminal_sweep_strips_markers_even_if_a_copy_site_leaks(self, compressor):
"""Regression for #57491, structural: even if a copy site fails to strip
the marker (simulating a future refactor that adds/reintroduces a leaky
copy), the single terminal sweep in compress() guarantees no compacted
message leaves carrying `_db_persisted`. Neuter the per-site helper to a
plain leaking copy and assert the invariant still holds."""
import agent.context_compressor as _cc
msgs = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}", "_db_persisted": True}
for i in range(10)
]
# Make the per-site helper leak the marker (dict.copy keeps it).
with patch.object(_cc, "_fresh_compaction_message_copy", lambda m: m.copy()), \
patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
result = compressor.compress(msgs)
assert len(result) < len(msgs)
assert all("_db_persisted" not in msg for msg in result), (
"terminal sweep must strip _db_persisted even when a copy site leaks"
)
def test_protect_first_n_decays_after_first_compression(self):
"""Regression for #11996: protect_first_n must protect early turns on
the FIRST compaction but DECAY afterwards, so the same early user
messages don't get re-copied verbatim into every child session and
fossilize (grow immortal) across a long, repeatedly-compressed
session. The system prompt is always protected separately."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3)
msgs = [{"role": "system", "content": "sys"}] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
for i in range(10)
]
# First compaction: protect system + first 3 non-system.
assert c.compression_count == 0
assert c._effective_protect_first_n() == 3
assert c._protect_head_size(msgs) == 1 + 3
# Simulate having compressed once — early turns now live in the summary.
c.compression_count = 1
assert c._effective_protect_first_n() == 0
assert c._protect_head_size(msgs) == 1 # system prompt only
class TestTailBudgetCodexReplayFields:
def test_tail_cut_counts_codex_replay_and_reasoning_fields(self):
"""Tail protection must budget hidden replay fields sent back to providers.
Codex Responses messages can have tiny visible content but large
`codex_reasoning_items`, `codex_message_items`, or provider-native
reasoning fields. Preflight compression counts these fields, so the
tail-cut budget must count them too; otherwise compression preserves an
oversized tail and immediately starts the next session near the limit.
"""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test/model",
protect_first_n=1,
protect_last_n=1,
quiet_mode=True,
)
big_replay = "x" * 5_000
big_hidden_message = {
"role": "assistant",
"content": "ok",
"reasoning": "summary " + big_replay,
"reasoning_content": "scratchpad " + big_replay,
"reasoning_details": [{"text": "details " + big_replay}],
"codex_reasoning_items": [
{"type": "reasoning", "encrypted_content": "enc_" + big_replay}
],
"codex_message_items": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "reply " + big_replay}],
}
],
}
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "initial ask"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "older follow-up"},
big_hidden_message,
]
messages.extend(
{
"role": "user" if i % 2 == 0 else "assistant",
"content": f"tail visible message {i}",
}
for i in range(14)
)
cut_idx = c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150)
assert cut_idx == 5
assert messages[4]["codex_reasoning_items"][0]["encrypted_content"].startswith("enc_")
assert messages[4]["codex_message_items"][0]["content"][0]["text"].startswith("reply ")
@pytest.mark.parametrize(
("field_name", "field_value"),
[
("reasoning", "x" * 5_000),
("reasoning_content", "x" * 5_000),
("reasoning_details", [{"text": "x" * 5_000}]),
(
"codex_reasoning_items",
[{"type": "reasoning", "encrypted_content": "x" * 5_000}],
),
(
"codex_message_items",
[
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "x" * 5_000}],
}
],
),
],
)
def test_tail_cut_counts_each_hidden_replay_field(self, field_name, field_value):
"""Each provider replay/reasoning field should affect tail budgeting."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test/model",
protect_first_n=1,
protect_last_n=1,
quiet_mode=True,
)
hidden_message = {"role": "assistant", "content": "ok", field_name: field_value}
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "initial ask"},
{"role": "assistant", "content": "first answer"},
{"role": "user", "content": "older follow-up"},
hidden_message,
]
messages.extend(
{
"role": "user" if i % 2 == 0 else "assistant",
"content": f"tail visible message {i}",
}
for i in range(14)
)
assert c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) == 5
class TestGenerateSummaryNoneContent:
"""Regression: content=None (from tool-call-only assistant messages) must not crash."""
def test_none_content_does_not_crash(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: tool calls happened"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": None, "tool_calls": [
{"function": {"name": "search"}}
]},
{"role": "tool", "content": "result"},
{"role": "assistant", "content": None},
{"role": "user", "content": "thanks"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
assert isinstance(summary, str)
assert summary.startswith(SUMMARY_PREFIX)
def test_none_content_in_system_message_compress(self):
"""System message with content=None should not crash during compress."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
msgs = [{"role": "system", "content": None}] + [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(10)
]
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
result = c.compress(msgs)
assert len(result) < len(msgs)
class TestNonStringContent:
"""Regression: content as dict (e.g., llama.cpp tool calls) must not crash."""
def test_none_content_treated_as_failure_not_empty_summary(self):
"""Regression #11978/#11914: a well-formed response with ``content=None``
(some OpenAI-compatible proxies, e.g. cmkey.cn, return HTTP 200 with
null/empty content) must NOT be stored as a prefix-only summary that
silently wipes the compacted turns. It is treated as a summary failure
and routed through cooldown so the turns are dropped without a summary
rather than replaced by an empty one."""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = None
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
# summary_model == model here, so no fallback path: straight to cooldown.
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
# Empty content → failure → None (drop turns), NOT a prefix-only summary.
assert summary is None
assert summary != SUMMARY_PREFIX
# Transient cooldown engaged so we don't immediately retry the bad proxy.
assert c._summary_failure_cooldown_until > 0
def test_string_message_coerced_to_summary_content(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = "plain summary text"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
assert summary.startswith(f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n")
assert "do something" in summary
assert summary.endswith("plain summary text")
def test_task_snapshot_skips_synthetic_user_scaffolding(self):
"""Grounding must anchor on the human ask, not runtime scaffolding.
Todo snapshots, truncation notices, and background-process reports
are injected with role="user"; if the newest user turn is one of
those, the deterministic snapshot must look past it to the real ask
(and return None when no real ask exists at all).
"""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "fix the login bug on prod"},
{"role": "assistant", "content": "on it"},
{
"role": "user",
"content": "[Your active task list was preserved across context compression]\n- item",
"_todo_snapshot_synthetic": True,
},
]
snapshot = c._latest_user_task_snapshot(messages)
assert snapshot is not None
assert "fix the login bug on prod" in snapshot
assert "task list was preserved" not in snapshot
only_synthetic = [
{"role": "user", "content": "[System: Your previous response was truncated ...]"},
]
assert c._latest_user_task_snapshot(only_synthetic) is None
def test_grounding_preserves_following_sections_across_regrounding(self):
"""The snapshot rewrite must keep later headings intact — twice.
A replacement that consumes the section's trailing newlines glues the
next "## " heading mid-line; on the following iterative compaction the
heading is no longer at line start, the regex matches through \\Z, and
every later section is silently deleted.
"""
import re as _re
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
summary = (
f"{HISTORICAL_TASK_HEADING}\n"
"User asked: stale example\n\n"
"## Historical Remaining Work\n- keep me\n\n"
"## Goal\nfinish"
)
turns = [{"role": "user", "content": "real ask"}]
first = c._ground_historical_task_snapshot(summary, turns)
assert _re.search(r"(?m)^## Historical Remaining Work$", first)
assert "- keep me" in first and "## Goal" in first
second = c._ground_historical_task_snapshot(first, turns)
assert "- keep me" in second and "## Goal" in second
assert second.count(HISTORICAL_TASK_HEADING) == 1
class TestSummaryFailureCooldown:
def test_summary_failure_enters_cooldown_and_skips_retry(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", side_effect=Exception("boom")) as mock_call:
first = c._generate_summary(messages)
second = c._generate_summary(messages)
assert first is None
assert second is None
assert mock_call.call_count == 1
class TestAuthFailureAborts:
"""A 401/403 on the summary call must ABORT compression (preserve the
session unchanged) instead of rotating into a degraded child session
with a placeholder summary — regardless of abort_on_summary_failure.
Real incident: a nous token pointed at a stale staging inference URL
401'd on every compression attempt, and because abort_on_summary_failure
defaults False the session rotated anyway (messages N->N), stranding the
user on a fresh-but-broken session that kept failing the same way.
"""
def _msgs(self, n=10):
return [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(n)
]
def _auth_err(self, status=401):
return StubProviderError(
f"Error code: {status} - "
"{'status': 401, 'message': 'Your API key is invalid, blocked or out of funds.'}",
status_code=status,
)
def test_missing_provider_api_key_is_terminal_access_failure(self):
err = RuntimeError(
"Provider 'opencode-zen' is set in config.yaml but no API key was "
"found. Set the OPENCODE-ZEN_API_KEY environment variable."
)
assert _is_summary_access_or_quota_error(err) is True
def test_400_out_of_extra_usage_aborts_instead_of_dropping_context(self):
"""Quota exhaustion preserves the original messages for a later retry."""
err = StubProviderError(
"Error code: 400 - {'error': {'message': 'out of extra usage'}}",
status_code=400,
)
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=err):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_auth_failure is True
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
def test_missing_provider_api_key_preserves_original_messages(self):
"""A configured auxiliary provider without a visible key preserves context."""
err = RuntimeError(
"Provider 'opencode-zen' is set in config.yaml but no API key was "
"found. Set the OPENCODE-ZEN_API_KEY environment variable, or switch "
"to a different provider with hermes model."
)
with patch(
"agent.context_compressor.get_model_context_length", return_value=100000
):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=err):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_error == str(err)
assert c._last_summary_auth_failure is True
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
assert c._last_summary_dropped_count == 0
def test_402_quota_with_retry_uses_existing_fallback(self):
"""A reset-window quota remains transient instead of aborting compression."""
err = StubProviderError(
"quota exceeded, please retry after the window resets",
status_code=402,
)
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=err):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result != msgs
assert c._last_summary_auth_failure is False
assert c._last_compress_aborted is False
assert c._last_summary_fallback_used is True
def test_403_also_flags_auth_failure(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
with patch("agent.context_compressor.call_llm", side_effect=self._auth_err(403)):
c._generate_summary(self._msgs())
assert c._last_summary_auth_failure is True
def test_generate_summary_flags_network_failure(self):
"""A connection/network error on the summary call flags
_last_summary_network_failure (#29559)."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
with patch(
"agent.context_compressor.call_llm",
side_effect=ConnectionError("Connection error."),
):
result = c._generate_summary(self._msgs())
assert result is None
assert c._last_summary_network_failure is True
assert c._last_summary_auth_failure is False
class TestSummaryFallbackToMainModel:
"""When ``summary_model`` differs from the main model and the summary LLM
call fails, the compressor should retry once on the main model before
giving up — losing N turns of context is almost always worse than one
extra summary attempt. Covers both the fast-path (explicit
model-not-found errors) and the unknown-error best-effort retry."""
def _msgs(self):
return [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
def test_model_not_found_404_falls_back_to_main_and_succeeds(self):
"""Classic misconfiguration: ``auxiliary.compression.model`` points at
a model the main provider doesn't serve → 404 → retry on main."""
mock_ok = MagicMock()
mock_ok.choices = [MagicMock()]
mock_ok.choices[0].message.content = "summary via main model"
err_404 = Exception("404 model_not_found: no such model")
err_404.status_code = 404
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="broken-aux-model",
quiet_mode=True,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=[err_404, mock_ok],
) as mock_call:
result = c._generate_summary(self._msgs())
assert mock_call.call_count == 2
# First call used the misconfigured aux model
assert mock_call.call_args_list[0].kwargs.get("model") == "broken-aux-model"
# Second call used the main model (no model kwarg → call_llm uses main)
assert "model" not in mock_call.call_args_list[1].kwargs
assert result is not None
assert "summary via main model" in result
# Aux-model failure is recorded even though retry succeeded — this is
# how callers (gateway /compress, CLI warning) know to tell the user
# their auxiliary.compression.model setting is broken.
assert c._last_aux_model_failure_model == "broken-aux-model"
assert c._last_aux_model_failure_error is not None
assert "404" in c._last_aux_model_failure_error
def test_no_fallback_when_summary_model_equals_main_model(self):
"""If the aux model IS the main model, there's nowhere to fall back
to — go straight to cooldown, don't loop retrying the same call."""
err = Exception("500 internal error")
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="main-model", # same as main
quiet_mode=True,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=err,
) as mock_call:
result = c._generate_summary(self._msgs())
# Only one attempt — retry gate blocks fallback when models match
assert mock_call.call_count == 1
assert result is None
# Not flagged as fallen back — the retry condition was never met
assert getattr(c, "_summary_model_fallen_back", False) is False
def test_json_decode_error_falls_back_to_main_and_succeeds(self):
"""JSONDecodeError from the OpenAI SDK's ``response.json()`` (raised
when a misconfigured proxy returns HTML/plain-text with
``Content-Type: application/json``) should trigger the same
retry-on-main path as 404/timeout. Issue #22244."""
import json as _json
mock_ok = MagicMock()
mock_ok.choices = [MagicMock()]
mock_ok.choices[0].message.content = "summary via main model"
# Simulate the SDK raising a raw JSONDecodeError with a realistic
# error message ("Expecting value: line X column Y char Z").
err_json = _json.JSONDecodeError(
"Expecting value", "<!DOCTYPE html><html>...</html>", 0
)
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="aux-via-broken-proxy",
quiet_mode=True,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=[err_json, mock_ok],
) as mock_call:
result = c._generate_summary(self._msgs())
assert mock_call.call_count == 2
assert mock_call.call_args_list[0].kwargs.get("model") == "aux-via-broken-proxy"
assert "model" not in mock_call.call_args_list[1].kwargs
assert result is not None
assert "summary via main model" in result
# Aux-model failure recorded so /usage / gateway warnings can surface it
assert c._last_aux_model_failure_model == "aux-via-broken-proxy"
assert c._last_aux_model_failure_error is not None
# The 220-char cap is shared with other fallback branches
assert len(c._last_aux_model_failure_error) <= 220
class TestStreamingClosedFallback:
"""httpcore / httpx streaming premature-close errors must be classified the
same as timeouts so the compressor retries on the main model instead of
entering a 60-second cooldown. Issue #18458.
``_is_connection_error`` is patched here because the test venv may not
have ``openai`` installed (the real function does ``from openai import ...``
inside its body). We test the *wiring* — that `_generate_summary` calls
``_is_connection_error`` and acts on its result — not the classifier itself
(that's covered in ``test_auxiliary_client.py::TestIsConnectionError``).
"""
def _msgs(self):
return [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
def test_incomplete_chunked_read_falls_back_to_main(self):
"""``httpcore.RemoteProtocolError: incomplete chunked read`` triggers
the retry-on-main path when ``_is_connection_error`` returns True."""
mock_ok = MagicMock()
mock_ok.choices = [MagicMock()]
mock_ok.choices[0].message.content = "summary via main model"
err = Exception("RemoteProtocolError: incomplete chunked read")
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="aux-stream-model",
quiet_mode=True,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=[err, mock_ok],
) as mock_call, patch(
"agent.context_compressor._is_connection_error",
return_value=True,
):
result = c._generate_summary(self._msgs())
assert mock_call.call_count == 2
assert mock_call.call_args_list[0].kwargs.get("model") == "aux-stream-model"
assert "model" not in mock_call.call_args_list[1].kwargs
assert result is not None
assert "summary via main model" in result
def test_streaming_closed_on_main_uses_short_cooldown(self):
"""When already on the main model, a streaming-closed error should use
the 30s cooldown, not the default 60s — these errors are transient."""
err = Exception("RemoteProtocolError: response ended prematurely")
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
# No summary_model_override → no fallback path.
quiet_mode=True,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=err,
), patch(
"agent.context_compressor._is_connection_error",
return_value=True,
), patch("agent.context_compressor.time.monotonic", return_value=1000.0):
result = c._generate_summary(self._msgs())
assert result is None
# Streaming-closed should use the 30s short cooldown.
assert c._summary_failure_cooldown_until == 1030.0
class TestAuxModelFallbackSurfacedToCallers:
"""When summary_model fails but retry-on-main succeeds, compress() must
expose the aux-model failure via _last_aux_model_failure_{model,error}
so gateway /compress and CLI callers can warn the user about their
broken auxiliary.compression.model config — silent recovery would hide
a misconfiguration only the user can fix."""
def _make_msgs(self):
return [
{"role": "system", "content": "sys"},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "assistant", "content": "msg 6"},
{"role": "user", "content": "msg 7"},
]
def test_compress_exposes_aux_failure_fields_after_successful_fallback(self):
mock_ok = MagicMock()
mock_ok.choices = [MagicMock()]
mock_ok.choices[0].message.content = "summary via main"
err_400 = Exception("400 provider rejected configured model")
err_400.status_code = 400
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="broken-aux-model",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
)
with patch(
"agent.context_compressor.call_llm",
side_effect=[err_400, mock_ok],
):
result = c.compress(self._make_msgs())
# Recovery succeeded → no fallback placeholder
assert c._last_summary_fallback_used is False
# But aux-model failure IS recorded for the gateway/CLI warning
assert c._last_aux_model_failure_model == "broken-aux-model"
assert c._last_aux_model_failure_error is not None
assert "400" in c._last_aux_model_failure_error
# Result is well-formed with a real summary, not a placeholder
assert any(
isinstance(m.get("content"), str) and "summary via main" in m["content"]
for m in result
)
def test_compress_clears_aux_failure_fields_at_start_of_next_call(self):
"""A subsequent successful compression must clear the aux-failure
fields so the warning doesn't persist forever."""
mock_ok = MagicMock()
mock_ok.choices = [MagicMock()]
mock_ok.choices[0].message.content = "summary via main"
err_400 = Exception("400 aux model busted")
err_400.status_code = 400
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="broken-aux-model",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
)
# Call 1: aux fails, retry-on-main succeeds
with patch(
"agent.context_compressor.call_llm",
side_effect=[err_400, mock_ok],
):
c.compress(self._make_msgs())
assert c._last_aux_model_failure_model == "broken-aux-model"
# Call 2: clean run on main (summary_model was cleared to "" after
# first fallback). Aux-failure fields MUST reset at compress() start
# so the old warning state doesn't leak into this call.
with patch(
"agent.context_compressor.call_llm",
return_value=mock_ok,
):
c.compress(self._make_msgs())
assert c._last_aux_model_failure_model is None
assert c._last_aux_model_failure_error is None
class TestSummaryFailureTrackingForGatewayWarning:
"""Default behavior (compression.abort_on_summary_failure=False):
summary-generation failure inserts a static fallback placeholder and
records dropped count + fallback flag so gateway hygiene & /compress
can surface a visible warning."""
def test_compress_records_fallback_and_dropped_count_on_summary_failure(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "assistant", "content": "msg 6"},
{"role": "user", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")):
result = c.compress(msgs)
assert c._last_summary_fallback_used is True
assert c._last_summary_dropped_count > 0
assert c._last_summary_error is not None
# Default mode: abort flag must NOT fire.
assert c._last_compress_aborted is False
assert any(
isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"]
for m in result
)
def test_summary_failure_fallback_preserves_tool_paths_and_redacts_secret_context(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1)
secret = "ghp_" + ("a" * 36)
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": f"Fix /tmp/project/app.py and never leak {secret}"},
{
"role": "assistant",
"content": "I will inspect it.",
"tool_calls": [
{
"id": "call-1",
"function": {
"name": "read_file",
"arguments": '{"path":"/tmp/project/app.py"}',
},
}
],
},
{"role": "tool", "tool_call_id": "call-1", "content": f"read /tmp/project/app.py with token {secret}"},
{"role": "assistant", "content": "Found the bug in /tmp/project/app.py"},
{"role": "user", "content": "Patch it after this"},
{"role": "assistant", "content": "Ready to patch"},
{"role": "user", "content": "current live request should stay in tail"},
]
with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")):
result = c.compress(msgs)
fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", ""))
assert "Called tool(s): read_file" in fallback
assert "/tmp/project/app.py" in fallback
assert secret not in fallback
assert "ghp_" not in fallback
class TestAbortOnSummaryFailure:
"""Opt-in behavior (compression.abort_on_summary_failure=True):
summary-generation failure ABORTS compression entirely — returns the
original messages unchanged and sets _last_compress_aborted=True so
gateway hygiene & /compress can surface a visible warning."""
def _make_msgs(self):
return [
{"role": "system", "content": "sys"},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "assistant", "content": "msg 6"},
{"role": "user", "content": "msg 7"},
]
def _make_compressor(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
return ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=True,
)
def test_compress_aborts_and_preserves_messages_on_summary_failure(self):
c = self._make_compressor()
msgs = self._make_msgs()
with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")):
result = c.compress(msgs)
assert c._last_compress_aborted is True
assert c._last_summary_error is not None
# No fallback inserted, no messages dropped
assert c._last_summary_fallback_used is False
assert c._last_summary_dropped_count == 0
# Original messages preserved byte-for-byte.
assert result == msgs
# No "Summary generation was unavailable" placeholder leaked in.
assert not any(
isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"]
for m in result
)
def test_aux_fallback_clears_persisted_session_cooldown_before_retry(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", "cli")
db.record_compression_failure_cooldown("s1", time.time() + 999.0, "timeout")
c = self._make_compressor()
c.bind_session_state(db, "s1")
c.summary_model = "aux/model"
c._fallback_to_main_for_compression(Exception("provider down"), "failed")
assert c.summary_model == ""
assert c._summary_failure_cooldown_until == 0.0
assert db.get_compression_failure_cooldown("s1") is None
def test_success_clears_persisted_session_cooldown(self, tmp_path):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary text"
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", "cli")
db.record_compression_failure_cooldown("s1", time.time() + 999.0, "timeout")
c = self._make_compressor()
c.bind_session_state(db, "s1")
c._summary_failure_cooldown_until = 0.0
msgs = self._make_msgs()
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_llm:
result = c.compress(msgs, current_tokens=999999)
mock_llm.assert_called()
assert c._last_compress_aborted is False
assert len(result) < len(msgs)
assert db.get_compression_failure_cooldown("s1") is None
class TestSummaryPrefixNormalization:
def test_legacy_prefix_is_replaced(self):
summary = ContextCompressor._with_summary_prefix("[CONTEXT SUMMARY]: did work")
assert summary == f"{SUMMARY_PREFIX}\ndid work"
def test_existing_new_prefix_is_not_duplicated(self):
summary = ContextCompressor._with_summary_prefix(f"{SUMMARY_PREFIX}\ndid work")
assert summary == f"{SUMMARY_PREFIX}\ndid work"
class TestCompressWithClient:
def test_sanitizer_matches_responses_call_id_when_id_differs(self, compressor):
msgs = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "fc_123",
"call_id": "call_123",
"response_item_id": "fc_123",
"type": "function",
"function": {"name": "search_files", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_123", "content": "result"},
]
sanitized = compressor._sanitize_tool_pairs(msgs)
assert [m.get("tool_call_id") for m in sanitized if m.get("role") == "tool"] == [
"call_123"
]
def test_user_role_summary_carries_end_marker(self):
"""When the summary lands as standalone role='user' (e.g. head ends
with assistant/tool), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
read the verbatim past user request quoted in the historical task
snapshot as
fresh input (#11475, #14521).
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary text"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
# head_last=assistant, tail_first=assistant (same shape as the
# existing consecutive-user test) → role resolves to "user".
msgs = [
{"role": "user", "content": "msg 0"},
{"role": "assistant", "content": "msg 1"},
{"role": "user", "content": "msg 2"},
{"role": "assistant", "content": "msg 3"},
{"role": "user", "content": "msg 4"},
{"role": "assistant", "content": "msg 5"},
{"role": "user", "content": "msg 6"},
{"role": "assistant", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = next(
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
)
assert summary_msg["role"] == "user"
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
assert summary_msg["content"].rstrip().endswith(
"respond to the message below, not the summary above ---"
)
def test_assistant_role_summary_carries_end_marker(self):
"""When the summary lands as standalone role='assistant' (head ends
with user), the message body must include the explicit
'--- END OF CONTEXT SUMMARY ---' marker. Without it, models may
regurgitate the summary text as their own output (#33256).
"""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened"
mock_client.chat.completions.create.return_value = mock_response
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
# head_last=user → summary_role="assistant" (same setup as
# test_summary_role_avoids_consecutive_user_when_head_ends_with_user).
# With min_tail=3, tail = last 3 messages (indices 5-7).
# head_last=user, tail_first=user → the assistant-role summary does
# not collide with either neighbor and should be inserted standalone.
msgs = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "msg 1"},
{"role": "user", "content": "msg 2"}, # last head — user
{"role": "assistant", "content": "msg 3"},
{"role": "user", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "assistant", "content": "msg 6"},
{"role": "user", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = next(
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
)
assert summary_msg["role"] == "assistant"
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
assert summary_msg["content"].rstrip().endswith(
"respond to the message below, not the summary above ---"
)
def test_summary_role_avoids_consecutive_user_messages(self):
"""Summary role should alternate with the last head message to avoid consecutive same-role messages."""
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened"
mock_client.chat.completions.create.return_value = mock_response
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
# Last head message (index 1) is "assistant" → summary should be "user".
# With min_tail=3, tail = last 3 messages (indices 5-7).
# head_last=assistant, tail_first=assistant → summary_role="user", no collision.
# Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6.
msgs = [
{"role": "user", "content": "msg 0"},
{"role": "assistant", "content": "msg 1"},
{"role": "user", "content": "msg 2"},
{"role": "assistant", "content": "msg 3"},
{"role": "user", "content": "msg 4"},
{"role": "assistant", "content": "msg 5"},
{"role": "user", "content": "msg 6"},
{"role": "assistant", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = [
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
]
assert len(summary_msg) == 1
assert summary_msg[0]["role"] == "user"
def test_double_collision_merges_summary_into_tail(self):
"""When neither role avoids collision with both neighbors, the summary
should be merged into the first tail message rather than creating a
standalone message that breaks role alternation.
Common scenario: head ends with 'assistant', tail starts with 'user'.
summary='user' collides with tail, summary='assistant' collides with head.
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary text"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3)
# Head: [system, user, assistant] → last head = assistant
# Tail: [user, assistant, user] → first tail = user
# summary_role="user" collides with tail, "assistant" collides with head → merge
# NOTE: protect_first_n=2 preserves 2 non-system messages in addition to
# the system prompt (always implicitly protected).
msgs = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"}, # compressed
{"role": "assistant", "content": "msg 4"}, # compressed
{"role": "user", "content": "msg 5"}, # compressed
{"role": "user", "content": "msg 6"}, # tail start
{"role": "assistant", "content": "msg 7"},
{"role": "user", "content": "msg 8"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
# Verify no consecutive user or assistant messages
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
# The summary text should be merged into the first tail message
first_tail = [m for m in result if "msg 6" in (m.get("content") or "")]
assert len(first_tail) == 1
assert "summary text" in first_tail[0]["content"]
def test_merge_into_tail_end_marker_is_last(self):
"""Regression for #56372: in a merge-into-tail summary, the END MARKER
must come AFTER the preserved prior tail content, not before it.
The old format was SUMMARY + END_MARKER + OLD_CONTENT, so the preserved
tail content landed after the marker and the model could read it as a
fresh message. The fix wraps old content in [PRIOR CONTEXT] delimiters
and always places the END MARKER last.
Mirrors test_double_collision_merges_summary_into_list_tail_content so
the merged tail message genuinely carries preserved content ("msg 6").
"""
from agent.context_compressor import _SUMMARY_END_MARKER
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "SUMMARY_BODY"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3)
msgs = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "user", "content": [{"type": "text", "text": "PRESERVED_TAIL_CONTENT"}]},
{"role": "assistant", "content": "msg 7"},
{"role": "user", "content": "msg 8"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
merged = next(m for m in result if m.get(COMPRESSED_SUMMARY_METADATA_KEY))
content = merged["content"]
text = (
content if isinstance(content, str)
else " ".join(
b.get("text", "") for b in content if isinstance(b, dict)
)
)
end = _SUMMARY_END_MARKER.strip()
# All three fragments present.
assert "PRESERVED_TAIL_CONTENT" in text
assert "SUMMARY_BODY" in text
assert end in text
# Ordering invariant: prior content BEFORE summary BEFORE end marker,
# and the end marker is the very last fragment.
assert text.index("PRESERVED_TAIL_CONTENT") < text.index("SUMMARY_BODY")
assert text.index("SUMMARY_BODY") < text.index(end)
assert text.rstrip().endswith(end)
def test_merged_tail_summary_still_detected_and_stripped(self):
"""Regression for #56372 salvage: the merge-into-tail reorder moves the
summary prefix AFTER the [PRIOR CONTEXT] wrapper, so content-prefix
detection (_is_context_summary_content) and body extraction
(_strip_summary_prefix) must look past the delimiter. Otherwise a merged
summary is mistaken for a real user turn (breaking the last-real-user
anchor and carry-forward summary find) and the wrapper + stale tail
content leaks into the next summarizer prompt.
"""
from agent.context_compressor import (
SUMMARY_PREFIX,
_SUMMARY_END_MARKER,
_MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER,
)
merged = (
_MERGED_PRIOR_CONTEXT_HEADER + "\n"
"old tail content here\n\n"
+ _MERGED_SUMMARY_DELIMITER + "\n\n"
+ SUMMARY_PREFIX + "\nTHE_SUMMARY_BODY\n\n"
+ _SUMMARY_END_MARKER
)
# Detected as a summary despite the prefix not being at the start.
assert ContextCompressor._is_context_summary_content(merged) is True
# Stripping yields only the real summary body — no wrapper, no stale
# tail content, no prefix, no end marker.
body = ContextCompressor._strip_summary_prefix(merged)
assert body == "THE_SUMMARY_BODY"
# Standalone (non-merged) summaries still work unchanged.
standalone = SUMMARY_PREFIX + "\nSTANDALONE_BODY\n\n" + _SUMMARY_END_MARKER
assert ContextCompressor._is_context_summary_content(standalone) is True
assert ContextCompressor._strip_summary_prefix(standalone) == "STANDALONE_BODY"
class TestSummaryTargetRatio:
"""Verify that summary_target_ratio properly scales budgets with context window."""
def test_default_threshold_floored_at_75_percent_below_512k(self):
"""Sub-512K models get the 75% small-context threshold floor."""
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
c = ContextCompressor(model="test", quiet_mode=True)
_ = c.context_length
assert c.threshold_percent == 0.75
# 75% of 100K = 75K, above the 64K minimum floor
assert c.threshold_tokens == 75_000
def test_protect_first_n_0_preserves_only_system_prompt(self):
"""End-to-end: when protect_first_n=0, compression should treat only
the system prompt as head. All user/assistant messages between the
system prompt and the protected tail become summarization candidates.
This is the cleanest configuration for long-running rolling-compaction
sessions — no user/assistant turn gets pinned verbatim forever just
because it happened to be early in the session."""
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=0,
protect_last_n=2,
)
msgs = (
[{"role": "system", "content": "System prompt"}]
+ [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(8)]
)
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
result = c.compress(msgs)
# System prompt (msg[0]) survives as head
assert result[0]["role"] == "system"
assert result[0]["content"].startswith("System prompt")
# The first user/assistant exchange (msg 0, msg 1) should NOT be pinned
# as head verbatim — those would have been summarized or absorbed.
# Under default protect_first_n=3, result[1..3] would be the literal
# "msg 0" / "msg 1" / "msg 2"; with protect_first_n=0 they aren't.
assert result[1].get("content") != "msg 0"
# Last 2 messages are tail-protected under protect_last_n=2
assert result[-1]["content"] == msgs[-1]["content"]
def test_protect_first_n_semantics_stable_without_system_prompt(self):
"""Regression: gateway /compress handler strips the system prompt
before calling compress(). protect_first_n must mean the same thing
in both paths — "N non-system head messages" — so configuring
protect_first_n=0 preserves NOTHING at the head regardless of whether
the system prompt is in the messages list.
Bug this covers: under the old semantics, protect_first_n counted
literally from messages[0]. In the gateway path (no system prompt)
that meant protect_first_n=1 would pin the first user turn of the
session forever — a user-reported complaint that a week-old
resolved question kept getting reinserted into every compaction
summary."""
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=0,
protect_last_n=2,
)
# No system prompt — this is what the gateway passes to compress().
msgs = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(10)
]
head_size = c._protect_head_size(msgs)
# With no system prompt and protect_first_n=0 → head is empty.
# The first user message is NOT pinned as head.
assert head_size == 0
# And with protect_first_n=3 on the same no-system-prompt list →
# head size is 3 (the three earliest non-system messages).
c.protect_first_n = 3
assert c._protect_head_size(msgs) == 3
class TestTokenBudgetTailProtection:
"""Tests for token-budget-based tail protection (PR #6240).
The core change: tail protection is now based on a token budget rather
than a fixed message count. This prevents large tool outputs from
blocking compaction.
"""
@pytest.fixture()
def budget_compressor(self):
"""Compressor with known token budget for tail protection tests."""
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.50, # 100K threshold
protect_first_n=2,
protect_last_n=20,
quiet_mode=True,
)
return c
def test_tiny_budget_preserves_bounded_recent_turns(self, budget_compressor):
"""A token-exhausted tail must preserve more than just the latest ask.
Regression for #9413: the previous hard-coded 3-message floor could
leave the latest user message live while summarizing the assistant/tool
context immediately before it, which made the post-compression turn feel
like a fresh conversation.
"""
c = budget_compressor
c.tail_token_budget = 10
c.protect_last_n = 20
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "old start"},
{"role": "assistant", "content": "old ack"},
{"role": "user", "content": "middle work"},
{"role": "assistant", "content": "middle ack"},
{"role": "user", "content": "middle ask 2"},
{"role": "assistant", "content": "middle answer 2"},
{"role": "user", "content": "middle ask 3"},
{"role": "assistant", "content": "middle answer 3"},
{"role": "user", "content": "recent ask 1"},
{"role": "assistant", "content": "recent answer 1"},
{"role": "user", "content": "recent ask 2"},
{"role": "assistant", "content": "recent answer 2"},
{"role": "user", "content": "latest ask"},
]
cut = c._find_tail_cut_by_tokens(messages, head_end=1)
assert len(messages) - cut >= 8
assert messages[cut]["content"] == "middle answer 2"
assert messages[-1]["content"] == "latest ask"
def test_small_conversation_still_compresses(self, budget_compressor):
"""With the new min of 8 messages (head=2 + 3 + 1 guard + 2 middle),
a small but compressible conversation should still compress."""
c = budget_compressor
# 9 messages: head(2) + 4 middle + 3 tail = compressible
messages = []
for i in range(9):
role = "user" if i % 2 == 0 else "assistant"
messages.append({"role": role, "content": f"Message {i}"})
# Should not early-return (needs > protect_first_n + 3 + 1 = 6)
# Mock the summary generation to avoid real API call
with patch.object(c, "_generate_summary", return_value="Summary of conversation"):
result = c.compress(messages, current_tokens=90_000)
# Should have compressed (fewer messages than original)
assert len(result) < len(messages)
def test_prune_short_conv_protects_entire_tail(self, budget_compressor):
"""Regression guard for PR #17025.
When ``len(messages) <= protect_tail_count`` and a token budget is
also set, every message must be protected. The previous code used
``min(protect_tail_count, len(result) - 1)`` which capped the floor
one below the full length, leaving the oldest message eligible for
pruning.
"""
c = budget_compressor
# 4 messages, protect_tail_count=4 -- nothing should be pruned.
# Oldest message is a large tool result; on the buggy path it falls
# outside the protected window and gets summarized.
messages = [
{"role": "tool", "content": "x" * 5000, "tool_call_id": "c0"},
{"role": "assistant", "content": "ack"},
{"role": "user", "content": "recent"},
{"role": "assistant", "content": "reply"},
]
result, pruned = c._prune_old_tool_results(
messages,
protect_tail_count=4,
protect_tail_tokens=1_000_000, # budget large enough to protect all
)
assert pruned == 0
# Tool result at index 0 must be preserved verbatim
assert result[0]["content"] == "x" * 5000
def test_multimodal_message_accumulates_text_chars_not_block_count(self, budget_compressor):
"""_find_tail_cut_by_tokens must use text char count, not list length,
for multimodal content. Regression guard for #16087.
Setup: 6 messages, budget=80 (soft_ceiling=120). The multimodal message
at index 1 has 500 chars of text → 135 tokens (correct) or 10 tokens (bug).
Fixed path: walk stops at the multimodal (44+135=179 > 120), cut stays at 2,
tail = messages[2:] = 4 messages.
Bug path: walk counts only 10 tokens for the multimodal, exhausts to head_end,
the head_end safeguard forces cut = n - min_tail = 3, tail = only 3 messages.
"""
c = budget_compressor
# 500 chars → 500//4 + 10 = 135 tokens; len([text, image]) // 4 + 10 = 10 (bug)
big_text = "x" * 500
multimodal_content = [
{"type": "text", "text": big_text},
{"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
]
messages = [
{"role": "user", "content": "head1"}, # 0
{"role": "user", "content": multimodal_content}, # 1: BIG (index under test)
{"role": "assistant", "content": "tail1"}, # 2
{"role": "user", "content": "tail2"}, # 3
{"role": "assistant", "content": "tail3"}, # 4
{"role": "user", "content": "tail4"}, # 5
]
c.tail_token_budget = 80 # soft_ceiling = 120
head_end = 0
cut = c._find_tail_cut_by_tokens(messages, head_end)
# With the fix: cut=2, tail has 4 messages (soft_ceiling not exceeded by tail1-4).
# With the bug: head_end safeguard fires → cut = n - min_tail = 3, only 3 in tail.
assert len(messages) - cut >= 4, (
f"Expected ≥4 messages in tail (got {len(messages) - cut}, cut={cut}). "
"The multimodal message was underestimated — len(list) used instead of text chars."
)
class TestUpdateModelBudgets:
"""Regression: update_model() must recalculate token budgets."""
def test_tail_budget_recalculated(self):
"""tail_token_budget must change after switching to a different context length."""
from unittest.mock import patch
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
old_tail = comp.tail_token_budget
old_max_summary = comp.max_summary_tokens
comp.update_model("model-b", context_length=32_000)
assert comp.tail_token_budget != old_tail, "tail_token_budget should change"
assert comp.tail_token_budget < old_tail, "smaller context → smaller budget"
assert comp.max_summary_tokens != old_max_summary, "max_summary_tokens should change"
def test_budgets_proportional(self):
"""Budgets should be proportional to context_length after update."""
from unittest.mock import patch
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
comp.update_model("model-b", context_length=10_000)
assert comp.tail_token_budget == int(comp.threshold_tokens * comp.summary_target_ratio)
assert comp.max_summary_tokens == min(int(10_000 * 0.05), 4000)
class TestUpdateModelResetsCalibration:
"""#23767: update_model() must clear stale cross-call calibration state.
Old-model real-usage / defer baselines must not suppress a preflight
compression the new (smaller) model actually needs.
"""
def _comp(self):
from unittest.mock import patch
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
return ContextCompressor("big-model", threshold_percent=0.50, quiet_mode=True)
def test_real_usage_state_cleared(self):
comp = self._comp()
# Simulate a large-model session that proved a prompt fit.
comp.last_prompt_tokens = 120_000
comp.last_real_prompt_tokens = 120_000
comp.last_rough_tokens_when_real_prompt_fit = 130_000
comp.last_compression_rough_tokens = 130_000
comp.awaiting_real_usage_after_compression = True
comp._ineffective_compression_count = 2
comp.update_model("small-model", context_length=65_536)
assert comp.last_prompt_tokens == 0
assert comp.last_real_prompt_tokens == 0
assert comp.last_rough_tokens_when_real_prompt_fit == 0
assert comp.last_compression_rough_tokens == 0
assert comp.awaiting_real_usage_after_compression is False
assert comp._ineffective_compression_count == 0
def test_defer_no_longer_suppresses_after_switch(self):
"""The exact #23767 failure: old model's 'it fit' must not defer
preflight on the new smaller model."""
comp = self._comp()
comp.last_real_prompt_tokens = 50_000
comp.last_rough_tokens_when_real_prompt_fit = 90_000
# Before switch, a modest rough growth would defer.
comp.threshold_tokens = 85_000
assert comp.should_defer_preflight_to_real_usage(93_000) is True
# After switching to a 65K model, the stale state is gone, so a rough
# estimate over the new threshold is NOT deferred — preflight will run.
comp.update_model("small-model", context_length=65_536)
assert comp.should_defer_preflight_to_real_usage(comp.threshold_tokens + 5_000) is False
class TestThresholdTokensCap:
"""Tests for the absolute token cap (compression.threshold_tokens).
The cap takes the lower of the ratio-based threshold and the absolute
count. It must survive model switches (update_model re-applies it)
and be clamped to the model's context length.
"""
def test_no_cap_uses_ratio_only(self):
"""Without a cap, the ratio-based threshold is used."""
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
comp = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
)
_ = comp.context_length
assert comp.threshold_tokens == 500_000
assert comp.threshold_tokens_cap is None
def test_invalid_cap_treated_as_none(self):
"""Non-numeric, zero, or negative cap values are treated as None."""
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
comp0 = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap=0,
)
assert comp0.threshold_tokens_cap is None
assert comp0.threshold_tokens == 500_000
comp_neg = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap=-100,
)
assert comp_neg.threshold_tokens_cap is None
comp_str = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap="not-a-number",
)
assert comp_str.threshold_tokens_cap is None
def test_should_compress_fires_at_cap_below_ratio_threshold(self):
"""Behavioral: with a cap below the ratio-based threshold,
should_compress() fires once usage crosses the cap — even though
the percentage threshold has not been reached (first-fires-wins)."""
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
comp = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap=200_000,
)
# Ratio-based would be 500K; cap pulls the trigger down to 200K.
assert comp.should_compress(150_000) is False # below cap
assert comp.should_compress(200_000) is True # at cap (below 500K pct)
assert comp.should_compress(250_000) is True # above cap
def test_default_config_disabled_and_no_behavior_change(self):
"""DEFAULT_CONFIG ships threshold_tokens=None (disabled) and both
None and 0 leave the ratio-based trigger byte-identical."""
from hermes_cli.config import DEFAULT_CONFIG
assert DEFAULT_CONFIG["compression"]["threshold_tokens"] is None
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
baseline = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
)
comp_none = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap=None,
)
comp_zero = ContextCompressor(
"model-a", threshold_percent=0.50, quiet_mode=True,
threshold_tokens_cap=0,
)
assert comp_none.threshold_tokens == baseline.threshold_tokens
assert comp_zero.threshold_tokens == baseline.threshold_tokens
# And after a model switch, still identical to baseline.
baseline.update_model("model-b", context_length=200_000)
comp_none.update_model("model-b", context_length=200_000)
comp_zero.update_model("model-b", context_length=200_000)
assert comp_none.threshold_tokens == baseline.threshold_tokens
assert comp_zero.threshold_tokens == baseline.threshold_tokens
class TestTruncateToolCallArgsJson:
"""Regression tests for #11762.
The previous implementation produced invalid JSON by slicing
``function.arguments`` mid-string, which caused non-retryable 400s from
strict providers (observed on MiniMax) and stuck long sessions in a
re-send loop. The helper here must always emit parseable JSON whose
shape matches the original — shrunken, not corrupted.
"""
def _helper(self):
from agent.context_compressor import _truncate_tool_call_args_json
return _truncate_tool_call_args_json
def test_shrunken_args_remain_valid_json(self):
import json as _json
shrink = self._helper()
original = _json.dumps({
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
"content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400,
})
assert len(original) > 500
shrunk = shrink(original)
parsed = _json.loads(shrunk) # must not raise
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
assert parsed["content"].endswith("...[truncated]")
assert len(shrunk) < len(original)
def test_non_string_leaves_preserved(self):
import json as _json
shrink = self._helper()
payload = _json.dumps({
"retries": 3,
"enabled": True,
"timeout": None,
"items": [1, 2, 3],
"note": "z" * 500,
})
parsed = _json.loads(shrink(payload))
assert parsed["retries"] == 3
assert parsed["enabled"] is True
assert parsed["timeout"] is None
assert parsed["items"] == [1, 2, 3]
assert parsed["note"].endswith("...[truncated]")
def test_pass3_emits_valid_json_for_downstream_provider(self):
"""End-to-end: Pass 3 must never produce the exact failure payload
that caused the 400 loop (unterminated string, missing brace)."""
import json as _json
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=1,
protect_last_n=1,
quiet_mode=True,
)
huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400
args_payload = _json.dumps({
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
"content": huge_content,
})
assert len(args_payload) > 500 # triggers the Pass-3 shrink
messages = [
{"role": "user", "content": "please write two files"},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "write_file", "arguments": args_payload}},
]},
{"role": "tool", "tool_call_id": "call_1",
"content": '{"bytes_written": 727}'},
{"role": "user", "content": "ok"},
{"role": "assistant", "content": "done"},
]
result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)
shrunk = result[1]["tool_calls"][0]["function"]["arguments"]
# Must parse — otherwise downstream provider returns 400
parsed = _json.loads(shrunk)
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
assert parsed["content"].endswith("...[truncated]")
class TestLazyContextResolution:
"""Verify that ContextCompressor defers get_model_context_length until
context_length is first accessed, so construction never blocks on network
I/O or blocks startup when the model metadata service is slow."""
def test_init_does_not_probe_when_not_quiet(self, caplog):
"""quiet_mode=False must ALSO stay non-blocking in __init__.
Regression for the lazy-init defect: the "Context compressor
initialized" log reads context_length/threshold_tokens/tail_token_budget,
so emitting it in __init__ forced the deferred get_model_context_length()
probe to run during construction whenever quiet_mode was False (the
interactive CLI path) — silently re-introducing the #32221 blocking that
the original PR set out to remove. The informative line must instead be
emitted once, on first context-length resolution.
"""
import logging
with patch(
"agent.context_compressor.get_model_context_length",
return_value=200_000,
) as mock_get:
c = ContextCompressor(model="test/model", quiet_mode=False)
# No probe, and no init log, at construction time.
mock_get.assert_not_called()
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
_ = c.context_length
mock_get.assert_called_once()
init_lines = [
r for r in caplog.records
if "Context compressor initialized" in r.getMessage()
]
assert len(init_lines) == 1, (
f"expected exactly one init log on first access, got {len(init_lines)}"
)
# Subsequent access must not re-probe or re-log.
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
_ = c.context_length
_ = c.threshold_tokens
mock_get.assert_called_once()
again = [
r for r in caplog.records
if "Context compressor initialized" in r.getMessage()
]
assert len(again) == 1, "init log fired more than once"
def test_config_context_length_skips_network_probe(self):
"""When config_context_length is provided, the resolver must use it
as the cached value and not make a network call."""
with patch(
"agent.context_compressor.get_model_context_length",
side_effect=lambda model, **kwargs: kwargs.get("config_context_length"),
) as mock_get:
c = ContextCompressor(
model="test/model",
quiet_mode=True,
config_context_length=200_000,
)
result = c.context_length
assert result == 200_000
class TestPreflightSentinelGuard:
"""Regression for #36718: the preflight token-display seed in
run_conversation must NOT overwrite the -1 sentinel that
compress_context() sets immediately after compression.
The old guard `_preflight_tokens > (last_prompt_tokens or 0)` evaluated
`(-1 or 0)` -> -1 (truthy), so any positive preflight estimate was > -1
and clobbered the sentinel with a schema-inflated rough count, re-firing
compression on the next turn. The fix treats any negative value as
"no real usage yet" and skips the seed.
"""
def _seed(self, last_prompt_tokens, preflight_tokens):
# Mirror the exact guard in agent/conversation_loop.py run_conversation.
_last = last_prompt_tokens
if _last >= 0 and preflight_tokens > _last:
return preflight_tokens # would overwrite
return last_prompt_tokens # preserved
def test_sentinel_preserved_after_compression(self, compressor):
compressor.last_prompt_tokens = -1
# A large schema-inflated preflight estimate must NOT overwrite -1.
result = self._seed(compressor.last_prompt_tokens, 250_000)
assert result == -1
def test_real_value_still_revises_upward(self, compressor):
compressor.last_prompt_tokens = 10_000
result = self._seed(compressor.last_prompt_tokens, 50_000)
assert result == 50_000
class TestTurnPairPreservation:
"""Causal Coupling guard (#22523): compaction must never orphan a user turn.
``_ensure_last_user_message_in_tail`` pulls the cut back to keep the last
user message in the tail (fixes #10896). But its final
``max(last_user_idx, head_end + 1)`` clamp pushes the cut *past* the user
when the user sits at ``head_end`` (the first compressible index) — the
only case where ``head_end + 1 > last_user_idx``. The user then lands in
the compressed region without its assistant reply; the summariser marks it
as a pending ask and the next session re-executes the completed task.
The guard detects that split and pushes the cut forward to ``pair_end`` so
the complete (user -> assistant [-> tool results]) pair is summarised as a
finished unit.
"""
@pytest.fixture
def compressor(self):
return ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=1,
protect_last_n=0,
quiet_mode=True,
)
# ------------------------------------------------------------------
# _find_turn_pair_end unit tests
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# _ensure_last_user_message_in_tail unit tests
# ------------------------------------------------------------------
def test_user_in_compressed_region_pulled_back(self, compressor):
"""User in the middle (not at head_end) is pulled into the tail (#10896)."""
msgs = [
{"role": "user", "content": "head"}, # 0
{"role": "assistant", "content": "hi"}, # 1
{"role": "user", "content": "do thing"}, # 2 <- last user
{"role": "assistant", "content": "done"}, # 3
]
# head_end=0, so head_end+1=1 <= last_user_idx=2: the #10896 pullback
# applies and the user stays in the tail (no forward push).
result = compressor._ensure_last_user_message_in_tail(msgs, cut_idx=3, head_end=0)
assert result <= 2
def test_orphan_prevented_user_at_head_end(self, compressor):
"""Causal Coupling: user at head_end pushes the WHOLE pair into the summary.
This is the #22523 case: last_user_idx == head_end, so the clamp would
return head_end+1 and orphan the user. The guard instead pushes the
cut forward to pair_end so user + reply + tool results are summarised
together and the tail never starts with a dangling user ask.
"""
msgs = [
{"role": "user", "content": "first exchange"}, # 0 head
{"role": "user", "content": "THE ACTIVE ASK"}, # 1 = head_end, last user
{"role": "assistant", "content": "done"}, # 2 reply
{"role": "tool", "tool_call_id": "c1", "content": "toolout"}, # 3
{"role": "assistant", "content": "final reply"}, # 4
]
head_end = 1
result = compressor._ensure_last_user_message_in_tail(msgs, cut_idx=3, head_end=head_end)
# Whole pair (indices 1..3) lands in the compressed region; tail starts at 4.
assert result == 4
tail = msgs[result:]
assert tail and tail[0]["role"] == "assistant"
def test_no_orphan_after_full_compaction_cycle(self, compressor):
"""End-to-end: after _find_tail_cut_by_tokens, the tail never starts
with an unanswered user message."""
msgs = [
{"role": "user", "content": "initial"},
{"role": "assistant", "content": "ok"},
]
for i in range(5):
msgs.append({"role": "user", "content": f"step {i}"})
msgs.append({"role": "assistant", "content": f"done {i}"})
msgs.append({"role": "user", "content": "lights off please"})
msgs.append({"role": "assistant", "content": "lights are off"})
head_end = compressor.protect_first_n
cut = compressor._find_tail_cut_by_tokens(msgs, head_end)
tail = msgs[cut:]
if tail and tail[0].get("role") == "user":
assert len(tail) >= 2 and tail[1].get("role") == "assistant", (
f"Orphan user turn at tail start: {tail[0]['content']!r}"
f"next role is {tail[1].get('role') if len(tail) > 1 else 'nothing'}"
)
class TestSanitizerStripsOrphanedToolCalls:
"""PR #51218 (salvaged from #51225): orphaned tool_calls are stripped from
assistant messages instead of having stub tool results inserted, avoiding
the call_id != id mismatch that let downstream repair_message_sequence drop
the stubs and re-expose orphans."""
def test_sanitizer_strips_orphaned_tool_calls(self, compressor):
"""Orphaned tool_calls (no matching tool result) are stripped from
assistant messages instead of having stubs inserted. #51218"""
msgs = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
],
},
{"role": "user", "content": "never mind"},
]
sanitized = compressor._sanitize_tool_pairs(msgs)
# Orphaned tool_call should be stripped, not stub-inserted
asst = next(m for m in sanitized if m.get("role") == "assistant")
assert not asst.get("tool_calls"), "orphaned tool_calls should be stripped"
# No stub tool messages should be added
assert not any(m.get("role") == "tool" for m in sanitized)
# Empty assistant should get placeholder content
assert asst.get("content") == "(tool call removed)"
def test_sanitizer_strips_orphaned_keeps_valid(self, compressor):
"""When an assistant has both valid and orphaned tool_calls, only
the orphans are stripped. #51218"""
msgs = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_valid", "function": {"name": "read_file", "arguments": "{}"}},
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tc_valid", "content": "file content"},
]
sanitized = compressor._sanitize_tool_pairs(msgs)
asst = next(m for m in sanitized if m.get("role") == "assistant")
assert len(asst["tool_calls"]) == 1
assert asst["tool_calls"][0]["id"] == "tc_valid"
# Valid tool result preserved
tool_msgs = [m for m in sanitized if m.get("role") == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0]["tool_call_id"] == "tc_valid"
def test_sanitizer_strips_orphaned_preserves_text_content(self, compressor):
"""When an assistant has text content AND orphaned tool_calls,
the text is preserved and only tool_calls are stripped. #51218"""
msgs = [
{
"role": "assistant",
"content": "Let me search for that.",
"tool_calls": [
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
],
},
{"role": "user", "content": "thanks"},
]
sanitized = compressor._sanitize_tool_pairs(msgs)
asst = next(m for m in sanitized if m.get("role") == "assistant")
assert asst["content"] == "Let me search for that."
assert not asst.get("tool_calls")
# The placeholder must NOT overwrite existing text content.
assert asst["content"] != "(tool call removed)"
def test_sanitizer_strips_orphaned_with_call_id_mismatch(self, compressor):
"""Stubs with call_id != id used to be dropped by downstream
repair_message_sequence, re-exposing orphans. Stripping avoids
this entirely. #51218"""
msgs = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "fc_abc",
"call_id": "call_abc",
"function": {"name": "search", "arguments": "{}"},
},
],
},
# No tool result for call_abc — orphaned
{"role": "user", "content": "next"},
]
sanitized = compressor._sanitize_tool_pairs(msgs)
asst = next(m for m in sanitized if m.get("role") == "assistant")
assert not asst.get("tool_calls")
# No stub tool messages (which would have call_id != id mismatch)
class TestCooldownReentryAbort:
"""Regression: a second compress() call during the failure cooldown must
still abort when the original failure was a network/auth error.
Before the fix, compress() unconditionally reset _last_summary_network_failure
and _last_summary_auth_failure at the top of every call. When
_generate_summary() returned None from the cooldown early-return (without
re-setting the flags), the abort guard saw False and fell through to the
destructive static-fallback path — reproducing the data-loss scenario from
#29559 / #25585 that PR #51881 originally fixed.
"""
def _msgs(self, n=12):
return [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
for i in range(n)
]
def test_network_failure_cooldown_reentry_still_aborts(self):
"""ConnectionError → first compress aborts (PR #51881). Second
compress within the 30s cooldown must ALSO abort — not drop the
middle window via the static-fallback path."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch(
"agent.context_compressor.call_llm",
side_effect=ConnectionError("Connection error."),
):
first = c.compress(msgs, current_tokens=999999, force=True)
assert first == msgs
assert c._last_compress_aborted is True
assert c._last_summary_network_failure is True
second = c.compress(msgs, current_tokens=999999)
assert second == msgs, (
"Second compress during cooldown must abort (preserve messages), "
"not drop the middle window via static-fallback"
)
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
def test_auth_failure_cooldown_reentry_still_aborts(self):
"""Same re-entry hole for auth failures: a 401 sets the flag, cooldown
returns None, second compress must still abort."""
err = Exception("Error code: 401 - invalid api key")
err.status_code = 401
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test",
quiet_mode=True,
protect_first_n=2,
protect_last_n=2,
abort_on_summary_failure=False,
)
msgs = self._msgs(12)
with patch("agent.context_compressor.call_llm", side_effect=err):
first = c.compress(msgs, current_tokens=999999, force=True)
assert first == msgs
assert c._last_compress_aborted is True
assert c._last_summary_auth_failure is True
second = c.compress(msgs, current_tokens=999999)
assert second == msgs, (
"Second compress during cooldown must abort (preserve messages), "
"not drop the middle window via static-fallback"
)
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
class TestDoubleCompactionSummaryRole:
"""PR #52160 (salvaged from #52167): when only the system prompt is
protected, the summary must lead with role=user (Anthropic/Bedrock send
system as a separate param, so the summary is the first visible message)."""
def test_double_compaction_summary_must_be_user_when_only_system_protected(self):
"""After the first compression, protect_first_n decays to 0.
On the second compression the only protected head message is the
system prompt (role=system). The summary becomes the first
*visible* message in the API request because adapters like
Anthropic and Bedrock send the system prompt as a separate
``system`` parameter. The summary MUST be role=user or the
provider rejects with HTTP 400 (#52160).
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary of earlier turns"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2,
)
# Simulate second compression: protect_first_n decays to 0.
c.compression_count = 1
# compress_start will be 1 (system only), last_head_role = "system".
# Without the fix, summary_role would be "assistant".
msgs = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"},
{"role": "assistant", "content": "msg 6"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
# The system message must still be at index 0.
assert result[0]["role"] == "system"
# The summary (first non-system message) must be role=user.
non_system = [m for m in result if m.get("role") != "system"]
assert non_system, "expected at least one non-system message"
assert non_system[0]["role"] == "user", (
f"first non-system message must be role=user for Anthropic "
f"compatibility, got role={non_system[0]['role']!r}"
)
def test_restart_handoff_without_system_still_starts_with_user(self):
"""When decayed head protection leaves no head, the visible transcript
must still begin with a user role for Anthropic/Bedrock compatibility.
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary of resumed turns"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test", quiet_mode=True, protect_first_n=3, protect_last_n=2,
)
msgs = [
{"role": "user", "content": f"{SUMMARY_PREFIX}\nold persisted summary"},
{"role": "assistant", "content": "handoff acknowledged"},
{"role": "user", "content": "new work after restart"},
{"role": "assistant", "content": "new answer after restart"},
{"role": "user", "content": "more new work after restart"},
{"role": "assistant", "content": "more new answer after restart"},
{"role": "user", "content": "tail request"},
{"role": "assistant", "content": "tail answer"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
assert result[0]["role"] == "user"
assert "summary of resumed turns" in (result[0].get("content") or "")
def test_double_compaction_user_tail_merges_into_tail(self):
"""When the summary is forced to role=user (system-only head) and
the first tail message is also user, the summary must merge into
the tail rather than flipping back to assistant (#52160).
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary of earlier turns"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2,
)
c.compression_count = 1 # decay protect_first_n
# tail starts with user → would collide with forced summary_role=user.
# The fix should merge into tail instead of flipping to assistant.
msgs = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "msg 1"},
{"role": "assistant", "content": "msg 2"},
{"role": "user", "content": "msg 3"},
{"role": "assistant", "content": "msg 4"},
{"role": "user", "content": "msg 5"}, # tail start (user)
{"role": "assistant", "content": "msg 6"},
{"role": "user", "content": "msg 7"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
# No standalone summary message should exist (merged into tail).
summary_msgs = [
m for m in result
if m.get("_compressed_summary") and "msg 5" not in (m.get("content") or "")
]
assert len(summary_msgs) == 0, (
"summary should be merged into tail, not standalone"
)
# The first non-system message must be role=user.
non_system = [m for m in result if m.get("role") != "system"]
assert non_system[0]["role"] == "user"
# The merged tail should contain the summary text.
assert any(
"summary of earlier turns" in (m.get("content") or "")
for m in result
)
class TestSummaryPromptBounding:
def test_iterative_update_path_is_bounded(self):
"""The iterative prompt (previous summary + new turns) must be bounded
too — a pathological rehydrated handoff must not blow up the prompt."""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "updated summary"
with patch("agent.context_compressor.get_model_context_length", return_value=272000):
c = ContextCompressor(model="test", quiet_mode=True)
cap = c._SUMMARY_INPUT_MAX_CHARS
c._previous_summary = "PREV_HEAD " + ("p" * (cap * 2)) + " PREV_TAIL"
messages = [
{"role": "user", "content": f"turn-{i}-" + ("x" * 6000)}
for i in range(80)
]
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call:
summary = c._generate_summary(messages)
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
assert summary.startswith(SUMMARY_PREFIX)
# previous summary block + new-turns block each capped, plus the
# fixed template: well under 3x the cap (unbounded would be ~800K).
assert len(prompt) < 2 * cap + 30_000
assert "PREV_HEAD" in prompt
assert "PREV_TAIL" in prompt
assert "summary input truncated" in prompt
def test_marker_does_not_collide_with_summary_classifier(self):
"""The omitted-middle marker must never make bounded content classify
as a compaction handoff (SUMMARY_PREFIX / merged-handoff patterns)."""
cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS
bounded = ContextCompressor._bound_summary_input("z" * (cap * 2))
assert "summary input truncated" in bounded
assert ContextCompressor.classify_summary_content(bounded) is None
# Marker alone (worst case: lands at the start of a message) is not a
# handoff prefix either.
marker_only = bounded[bounded.index("\n\n...[summary input truncated"):]
assert ContextCompressor.classify_summary_content(marker_only.lstrip()) is None
class TestMinTailUserMessages:
"""COMPRESS-01,02,07,08: N-user-message tail protection.
Tests the ``_ensure_last_n_user_messages_in_tail`` method and its
integration through ``_find_tail_cut_by_tokens``.
"""
def test_n3_tool_group_integrity(self):
"""COMPRESS-02: When the 3rd-to-last user message is preceded by
assistant(tool_calls) + tool results, the boundary is set at the
user message (a clean boundary). The preceding tool group stays
together — it is either entirely in the compressed region or
entirely in the tail, never split across the boundary."""
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=1,
quiet_mode=True,
min_tail_user_messages=3,
)
c.tail_token_budget = 200
messages = [
{"role": "user", "content": "start"},
{"role": "assistant", "content": None,
"tool_calls": [{"function": {"name": "read_file", "arguments": "{}"}}]},
{"role": "tool", "content": "result content",
"tool_call_id": "call_1"},
{"role": "user", "content": "user 3rd last"},
{"role": "assistant", "content": "reply 3rd last"},
{"role": "user", "content": "user 2nd last"},
{"role": "assistant", "content": "reply 2nd last"},
{"role": "user", "content": "user last"},
{"role": "assistant", "content": "reply last"},
]
head_end = c.protect_first_n
cut = c._find_tail_cut_by_tokens(messages, head_end)
compressed = messages[:cut]
tool_positions = [
i for i, m in enumerate(compressed)
if m.get("role") in ("assistant", "tool")
and (m.get("tool_calls") or m.get("tool_call_id"))
]
if len(tool_positions) >= 2:
assert tool_positions[-1] - tool_positions[0] == len(tool_positions) - 1, (
"Tool group must stay contiguous across the boundary"
)
tail_messages = messages[cut:]
tail_users = [m["content"] for m in tail_messages if m["role"] == "user"]
assert "user 3rd last" in tail_users
assert "user 2nd last" in tail_users
assert "user last" in tail_users
assert cut >= head_end + 1
def test_default_is_behavior_preserving(self):
"""Default min_tail_user_messages=1 leaves the tail cut byte-identical
to the historical single-anchor pipeline.
A default of 3 was measured to CHANGE the cut on transcripts whose
tail budget covers only the last turn, so the default is gated to 1
(= the existing single last-user anchor) and N>1 is opt-in.
"""
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c_default = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=2,
quiet_mode=True,
)
c_explicit = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=2,
quiet_mode=True,
min_tail_user_messages=1,
)
assert c_default.min_tail_user_messages == 1
c_default.tail_token_budget = 200
c_explicit.tail_token_budget = 200
messages = [
{"role": "user", "content": "head msg"},
{"role": "assistant", "content": "head reply"},
]
for i in range(3):
messages.append({"role": "user", "content": f"user {i}"})
messages.append({"role": "assistant", "content": "X" * 4000})
messages.append({"role": "user", "content": "final user"})
messages.append({"role": "assistant", "content": "final reply"})
head_end = c_default.protect_first_n
assert (
c_default._find_tail_cut_by_tokens(messages, head_end)
== c_explicit._find_tail_cut_by_tokens(messages, head_end)
)
def test_blank_echo_does_not_count_toward_n(self):
"""A blank platform echo (empty user row) must not consume one of the
N slots — otherwise the guarantee silently degrades to N-1 real turns
(the #69291 bug class the single anchor already fixed)."""
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=1,
quiet_mode=True,
min_tail_user_messages=3,
)
messages = [
{"role": "user", "content": "head"}, # 0 (head)
{"role": "assistant", "content": "head reply"}, # 1
{"role": "user", "content": "real oldest"}, # 2 <- 3rd real user
{"role": "assistant", "content": "reply oldest"}, # 3
{"role": "user", "content": "real middle"}, # 4
{"role": "assistant", "content": "reply middle"}, # 5
{"role": "user", "content": ""}, # 6 blank echo
{"role": "assistant", "content": "reply to echo"}, # 7
{"role": "user", "content": " "}, # 8 whitespace echo
{"role": "assistant", "content": "another reply"}, # 9
{"role": "user", "content": "real latest"}, # 10
{"role": "assistant", "content": "final reply"}, # 11
]
head_end = c.protect_first_n # = 1
# cut_idx=10 → only "real latest" in tail; N=3 must walk back to
# index 2 ("real oldest"), NOT stop at a blank echo (6/8).
result = c._ensure_last_n_user_messages_in_tail(
messages, cut_idx=10, head_end=head_end, n=3
)
assert result == 2, (
f"3rd real user is at index 2, got cut {result} — blank echoes "
"must not count toward N"
)
tail_users = [
m["content"] for m in messages[result:]
if m["role"] == "user" and m["content"].strip()
]
assert {"real oldest", "real middle", "real latest"} <= set(tail_users)
def test_n_guarantee_wins_over_tail_token_budget_and_floor(self):
"""Interaction contract: the N-user guarantee WINS over both
tail_token_budget and _MAX_TAIL_MESSAGE_FLOOR.
The budget walk (and its bounded message floor) computes the initial
cut; the N-anchor then only ever pulls the cut BACKWARD (tail can
grow, never shrink), exactly like the existing single-user and
assistant anchors. So a tiny budget cannot roll real users 2..N into
the summary, and the floor remains a lower bound, not a cap, on the
anchored tail.
"""
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=1,
quiet_mode=True,
min_tail_user_messages=3,
)
# Budget covers roughly one bulky turn — without the N-anchor the
# cut lands after users 2..3.
c.tail_token_budget = 150
messages = [{"role": "user", "content": "head"},
{"role": "assistant", "content": "head reply"}]
for i in (3, 2, 1):
messages.append({"role": "user", "content": f"real {i}"})
messages.append({"role": "assistant", "content": "B" * 6000})
head_end = c.protect_first_n
cut = c._find_tail_cut_by_tokens(messages, head_end)
tail = messages[cut:]
tail_users = [m["content"] for m in tail if m["role"] == "user"]
assert {"real 1", "real 2", "real 3"} <= set(tail_users), (
f"N-guarantee must win over the token budget; tail users: {tail_users}"
)
# The anchored tail legitimately exceeds the budget (and the 8-message
# floor is a minimum, not a cap): the guarantee is the stronger
# invariant by design.
from agent.context_compressor import _estimate_msg_budget_tokens
accumulated = sum(_estimate_msg_budget_tokens(m) for m in tail)
assert accumulated > c.tail_token_budget
class TestContextLengthSetterCoherence:
"""The context_length setter must (a) not wipe runtime corrections on
no-op re-assignment of the same window (codex app-server usage callback
re-reports it every response), and (b) re-apply the small-context
threshold floor for a genuinely new window so percent and tokens derive
from the same window."""
def test_same_value_reassignment_preserves_threshold_override(self):
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
c = ContextCompressor(model="test", quiet_mode=True)
_ = c.context_length
# Runtime correction (aux-context threshold sync pattern).
c.threshold_tokens = 42_000
c.tail_token_budget = 8_400
# Codex usage callback re-reports the same window every response.
c.context_length = 200_000
assert c.threshold_tokens == 42_000
assert c.tail_token_budget == 8_400
def test_new_value_assignment_refloors_and_invalidates(self):
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
c = ContextCompressor(model="test", quiet_mode=True)
_ = c.context_length
assert c.threshold_percent == 0.50 # 1M >= 512K: configured value
# Switch to a small window via direct assignment (codex path).
c.context_length = 200_000
# Floor re-applied for the new window...
assert c.threshold_percent == 0.75
# ...and budgets recompute from the same window+percent.
assert c.threshold_tokens == 150_000