|
| 1 | +"""Tests for cross-session _previous_summary contamination bug (#38788). |
| 2 | +
|
| 3 | +ContextCompressor._previous_summary is an instance variable that stores the |
| 4 | +previous compaction summary for iterative updates. It is cleared by |
| 5 | +on_session_reset() which is called for /new and /reset, but NOT when a cron |
| 6 | +session ends naturally. A cron session's compaction sets _previous_summary, |
| 7 | +then the cron session ends. A subsequent live messaging session inherits this |
| 8 | +stale summary, and _generate_summary() injects it as "PREVIOUS SUMMARY:" into |
| 9 | +the summarizer prompt — contaminating the live session's context. |
| 10 | +
|
| 11 | +Fix: compress() guards against this by clearing _previous_summary when no |
| 12 | +handoff summary is found in the current messages. |
| 13 | +""" |
| 14 | + |
| 15 | +import sys |
| 16 | +import types |
| 17 | +from pathlib import Path |
| 18 | +from unittest.mock import patch |
| 19 | + |
| 20 | +# Ensure repo root is importable |
| 21 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) |
| 22 | + |
| 23 | +# Stub out optional heavy dependencies not installed in the test environment |
| 24 | +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) |
| 25 | +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) |
| 26 | +sys.modules.setdefault("fal_client", types.SimpleNamespace()) |
| 27 | + |
| 28 | +from agent.context_compressor import ContextCompressor |
| 29 | + |
| 30 | + |
| 31 | +def _make_compressor(): |
| 32 | + """Build a ContextCompressor with enough state to pass compress() guards.""" |
| 33 | + c = ContextCompressor.__new__(ContextCompressor) |
| 34 | + c.quiet_mode = True |
| 35 | + c.model = "test/model" |
| 36 | + c.provider = "test" |
| 37 | + c.base_url = "http://test" |
| 38 | + c.api_key = "test-key" |
| 39 | + c.api_mode = "" |
| 40 | + c.context_length = 128000 |
| 41 | + c.threshold_tokens = 64000 |
| 42 | + c.threshold_percent = 0.50 |
| 43 | + c.tail_token_budget = 20000 |
| 44 | + c.protect_last_n = 12 |
| 45 | + c.summary_model = "" |
| 46 | + c.last_prompt_tokens = 100000 |
| 47 | + c.last_completion_tokens = 0 |
| 48 | + c._summary_failure_cooldown_until = 0.0 |
| 49 | + c._max_compaction_summary_tokens = 0 |
| 50 | + c.summary_budget_tokens = 0 |
| 51 | + c.abort_on_summary_failure = False |
| 52 | + c._last_compress_aborted = False |
| 53 | + c._summary_model_fallen_back = False |
| 54 | + c.compression_count = 0 |
| 55 | + c._context_probed = False |
| 56 | + c._last_compression_savings_pct = 100.0 |
| 57 | + c._ineffective_compression_count = 0 |
| 58 | + c._last_summary_error = None |
| 59 | + c._last_summary_dropped_count = 0 |
| 60 | + c._last_summary_fallback_used = False |
| 61 | + c._last_aux_model_failure_error = None |
| 62 | + c._last_aux_model_failure_model = None |
| 63 | + c.last_real_prompt_tokens = 0 |
| 64 | + c.last_compression_rough_tokens = 0 |
| 65 | + c.last_rough_tokens_when_real_prompt_fit = 0 |
| 66 | + c.awaiting_real_usage_after_compression = False |
| 67 | + return c |
| 68 | + |
| 69 | + |
| 70 | +def _conversation_without_handoff(n_exchanges=12): |
| 71 | + """Build message list with no compaction handoff in it.""" |
| 72 | + msgs = [{"role": "system", "content": "You are a helpful assistant."}] |
| 73 | + for i in range(n_exchanges): |
| 74 | + msgs.append({"role": "user", "content": f"Question {i}"}) |
| 75 | + msgs.append({"role": "assistant", "content": f"Answer {i}"}) |
| 76 | + return msgs |
| 77 | + |
| 78 | + |
| 79 | +def _conversation_with_handoff(n_exchanges=12): |
| 80 | + """Build message list WITH a compaction handoff in protected head.""" |
| 81 | + from agent.context_compressor import SUMMARY_PREFIX |
| 82 | + msgs = [{"role": "system", "content": "You are a helpful assistant."}] |
| 83 | + msgs.append({"role": "user", "content": SUMMARY_PREFIX + "\nPrevious summary."}) |
| 84 | + for i in range(n_exchanges): |
| 85 | + msgs.append({"role": "user", "content": f"Question {i}"}) |
| 86 | + msgs.append({"role": "assistant", "content": f"Answer {i}"}) |
| 87 | + return msgs |
| 88 | + |
| 89 | + |
| 90 | +def test_stale_previous_summary_cleared_when_no_handoff(): |
| 91 | + """Cross-session guard: stale _previous_summary cleared when no handoff.""" |
| 92 | + c = _make_compressor() |
| 93 | + # Simulate state left by a prior cron session's compaction |
| 94 | + c._previous_summary = "STALE CRON SUMMARY - this must not leak" |
| 95 | + |
| 96 | + messages = _conversation_without_handoff() |
| 97 | + |
| 98 | + with patch.object(c, "_generate_summary", |
| 99 | + return_value="[CONTEXT COMPACTION] Fresh summary."): |
| 100 | + result = c.compress(messages) |
| 101 | + |
| 102 | + assert c._previous_summary is None, ( |
| 103 | + "compress() must clear stale _previous_summary when no handoff " |
| 104 | + f"summary exists in current messages. Got: {c._previous_summary!r}" |
| 105 | + ) |
| 106 | + assert result != messages |
| 107 | + assert any( |
| 108 | + "[CONTEXT COMPACTION]" in (m.get("content", "") or "") for m in result |
| 109 | + ) |
| 110 | + |
| 111 | + |
| 112 | +def test_previous_summary_preserved_when_handoff_found(): |
| 113 | + """When a handoff IS found, _previous_summary should be preserved for |
| 114 | + iterative update within the same session.""" |
| 115 | + c = _make_compressor() |
| 116 | + c._previous_summary = "Summary from earlier compaction in same session" |
| 117 | + |
| 118 | + messages = _conversation_with_handoff() |
| 119 | + |
| 120 | + with patch.object(c, "_generate_summary", |
| 121 | + return_value="[CONTEXT COMPACTION] Updated summary."): |
| 122 | + c.compress(messages) |
| 123 | + |
| 124 | + # When a handoff IS found, the staleness guard must NOT fire. |
| 125 | + # _previous_summary should be updated, not cleared. |
| 126 | + assert c._previous_summary is not None, ( |
| 127 | + "compress() must NOT clear _previous_summary when handoff summary " |
| 128 | + "exists in current messages" |
| 129 | + ) |
| 130 | + |
| 131 | + |
| 132 | +def test_no_false_positive_when_previous_summary_already_none(): |
| 133 | + """When _previous_summary is already None and no handoff found, nothing |
| 134 | + should break (the guard is a no-op in this case).""" |
| 135 | + c = _make_compressor() |
| 136 | + c._previous_summary = None |
| 137 | + |
| 138 | + messages = _conversation_without_handoff() |
| 139 | + |
| 140 | + with patch.object(c, "_generate_summary", |
| 141 | + return_value="[CONTEXT COMPACTION] Fresh summary."): |
| 142 | + c.compress(messages) |
| 143 | + |
| 144 | + # Should still be None — guard is no-op |
| 145 | + assert c._previous_summary is None |
0 commit comments