Files
hermes-agent/tests/agent/test_context_compressor_cross_session_guard.py
Elisa Martinez Abad bdba7042a9 fix(compaction): bench a failing summary model instead of blocking compaction
Two fallback summaries in a row tripped the anti-thrash breaker even when
each fallback compaction was effective, so automatic compaction was blocked
and the session rode into the provider's hard context limit. Only
ineffective compactions now trip the breaker; a fallback streak >= 2 instead
benches the summary model (deterministic drop, one probe per recovery
window), which still stops the paid fallback loop from #63008. force=True
(/compress) always calls the model.

Salvaged from #129431.

Co-authored-by: Pascal Bérard <pberard@nvidia.com>
2026-10-06 12:03:52 +05:30

126 lines
5.0 KiB
Python

"""Tests for cross-session _previous_summary contamination bug (#38788).
ContextCompressor._previous_summary is an instance variable that stores the
previous compaction summary for iterative updates. It is cleared by
on_session_reset() which is called for /new and /reset, but NOT when a cron
session ends naturally. A cron session's compaction sets _previous_summary,
then the cron session ends. A subsequent live messaging session inherits this
stale summary, and _generate_summary() injects it as "PREVIOUS SUMMARY:" into
the summarizer prompt — contaminating the live session's context.
Fix: compress() guards against this by clearing _previous_summary when no
handoff summary is found in the current messages.
"""
import sys
import types
from pathlib import Path
from unittest.mock import patch
# Ensure repo root is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
# Stub out optional heavy dependencies not installed in the test environment
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
sys.modules.setdefault("fal_client", types.SimpleNamespace())
from agent.context_compressor import ContextCompressor
def _make_compressor():
"""Build a ContextCompressor with enough state to pass compress() guards."""
c = ContextCompressor.__new__(ContextCompressor)
c.quiet_mode = True
c.model = "test/model"
c.provider = "test"
c.base_url = "http://test"
c.api_key = "test-key"
c.api_mode = ""
c.context_length = 128000
c.threshold_tokens = 64000
c.threshold_percent = 0.50
c.tail_token_budget = 20000
c.protect_last_n = 12
c.summary_model = ""
c.last_prompt_tokens = 100000
c.last_completion_tokens = 0
c._summary_failure_cooldown_until = 0.0
c._max_compaction_summary_tokens = 0
c.summary_budget_tokens = 0
c.abort_on_summary_failure = False
c._last_compress_aborted = False
c._summary_model_fallen_back = False
c.compression_count = 0
c._context_probed = False
c._last_compression_savings_pct = 100.0
c._ineffective_compression_count = 0
c._fallback_compression_streak = 0
c._fallback_probe_at = 0.0
c._last_summary_error = None
c._last_summary_dropped_count = 0
c._last_summary_fallback_used = False
c._last_aux_model_failure_error = None
c._last_aux_model_failure_model = None
c.last_real_prompt_tokens = 0
c.last_compression_rough_tokens = 0
c.awaiting_real_usage_after_compression = False
return c
def _conversation_without_handoff(n_exchanges=12):
"""Build message list with no compaction handoff in it."""
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
for i in range(n_exchanges):
msgs.append({"role": "user", "content": f"Question {i}"})
msgs.append({"role": "assistant", "content": f"Answer {i}"})
return msgs
def _conversation_with_handoff(n_exchanges=12):
"""Build message list WITH a compaction handoff in protected head."""
from agent.context_compressor import SUMMARY_PREFIX
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
msgs.append({"role": "user", "content": SUMMARY_PREFIX + "\nPrevious summary."})
for i in range(n_exchanges):
msgs.append({"role": "user", "content": f"Question {i}"})
msgs.append({"role": "assistant", "content": f"Answer {i}"})
return msgs
def test_stale_previous_summary_cleared_when_no_handoff():
"""Cross-session guard: stale _previous_summary cleared when no handoff."""
c = _make_compressor()
# Simulate state left by a prior cron session's compaction
c._previous_summary = "STALE CRON SUMMARY - this must not leak"
messages = _conversation_without_handoff()
with patch.object(c, "_generate_summary",
return_value="[CONTEXT COMPACTION] Fresh summary."):
result = c.compress(messages)
assert c._previous_summary is None, (
"compress() must clear stale _previous_summary when no handoff "
f"summary exists in current messages. Got: {c._previous_summary!r}"
)
assert result != messages
assert any(
"[CONTEXT COMPACTION]" in (m.get("content", "") or "") for m in result
)
def test_previous_summary_preserved_when_handoff_found():
"""When a handoff IS found, _previous_summary should be preserved for
iterative update within the same session."""
c = _make_compressor()
c._previous_summary = "Summary from earlier compaction in same session"
messages = _conversation_with_handoff()
with patch.object(c, "_generate_summary",
return_value="[CONTEXT COMPACTION] Updated summary."):
c.compress(messages)
# When a handoff IS found, the staleness guard must NOT fire.
# _previous_summary should be updated, not cleared.
assert c._previous_summary is not None, (
"compress() must NOT clear _previous_summary when handoff summary "
"exists in current messages"
)