"""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._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.last_rough_tokens_when_real_prompt_fit = 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" ) def test_no_false_positive_when_previous_summary_already_none(): """When _previous_summary is already None and no handoff found, nothing should break (the guard is a no-op in this case).""" c = _make_compressor() c._previous_summary = None messages = _conversation_without_handoff() with patch.object(c, "_generate_summary", return_value="[CONTEXT COMPACTION] Fresh summary."): c.compress(messages) # Should still be None — guard is no-op assert c._previous_summary is None