diff --git a/headroom/cache/compression_store.py b/headroom/cache/compression_store.py index f78fedcbb..b06108a58 100644 --- a/headroom/cache/compression_store.py +++ b/headroom/cache/compression_store.py @@ -480,6 +480,7 @@ class CompressionStore: "compressed_item_count": entry.compressed_item_count, "query_context": entry.query_context, "compressed_content": entry.compressed_content, + "original_content_preview": entry.original_content[:2000], "created_at": entry.created_at, "ttl": entry.ttl, } diff --git a/headroom/ccr/context_tracker.py b/headroom/ccr/context_tracker.py index aa98dcebe..885966407 100644 --- a/headroom/ccr/context_tracker.py +++ b/headroom/ccr/context_tracker.py @@ -31,6 +31,38 @@ from ..cache.compression_store import get_compression_store logger = logging.getLogger(__name__) +def looks_like_claude_code_compact_summary(*texts: str | None) -> bool: + """Return true for Claude Code `/compact` continuation summaries. + + Claude Code can carry a previous session forward by injecting a compact + conversation summary into a fresh session. Those summaries are already + context; tracking them for CCR proactive expansion makes Headroom re-add + stale session state to later turns. Keep this detector deliberately narrow + so ordinary tool output that happens to mention "summary" remains eligible. + """ + combined = " ".join(text.strip() for text in texts if text and text.strip()) + if not combined: + return False + + normalized = " ".join(combined.lower().split()) + has_summary = "summary" in normalized or "summarized" in normalized + + if "this session is being continued from a previous conversation" in normalized and has_summary: + return True + + if "conversation is summarized below" in normalized and ( + "ran out of context" in normalized or "previous conversation" in normalized + ): + return True + + return ( + "/compact" in normalized + and "claude" in normalized + and has_summary + and ("conversation" in normalized or "session" in normalized) + ) + + @dataclass class CompressedContext: """Represents a piece of compressed context from the conversation. @@ -157,6 +189,13 @@ class ContextTracker: if not self.config.enabled: return + if looks_like_claude_code_compact_summary(query_context, sample_content): + logger.debug( + "CCR Tracker: skipped Claude Code compact summary %s for proactive expansion", + hash_key, + ) + return + context = CompressedContext( hash_key=hash_key, turn_number=turn_number, diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 427ee6413..bb9be767b 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: import httpx from headroom.agent_savings import proxy_pipeline_kwargs +from headroom.ccr.context_tracker import looks_like_claude_code_compact_summary from headroom.copilot_auth import build_copilot_upstream_url from headroom.pipeline import PipelineStage, summarize_routing_markers from headroom.proxy.auth_mode import classify_auth_mode, classify_client @@ -1934,6 +1935,16 @@ class AnthropicHandlerMixin: store = get_compression_store() entry = store.get_metadata(hash_key) if entry: + if looks_like_claude_code_compact_summary( + entry.get("query_context"), + entry.get("compressed_content"), + entry.get("original_content_preview"), + ): + logger.info( + f"[{request_id}] CCR: skipping proactive " + f"tracking for Claude Code compact summary {hash_key}" + ) + continue self.ccr_context_tracker.track_compression( hash_key=hash_key, turn_number=self._turn_counter, diff --git a/tests/test_ccr_context_tracker.py b/tests/test_ccr_context_tracker.py index ac8427cbc..fe45b3d69 100644 --- a/tests/test_ccr_context_tracker.py +++ b/tests/test_ccr_context_tracker.py @@ -23,6 +23,7 @@ from headroom.ccr.context_tracker import ( ContextTrackerConfig, ExpansionRecommendation, get_context_tracker, + looks_like_claude_code_compact_summary, reset_context_tracker, ) @@ -58,6 +59,37 @@ class TestContextTrackerBasics: stats = tracker.get_stats() assert stats["tracked_contexts"] == 1 + def test_claude_code_compact_summary_not_tracked(self): + """Claude Code `/compact` summaries are not proactively re-expanded.""" + tracker = ContextTracker() + + tracker.track_compression( + hash_key="compact_summary", + turn_number=1, + tool_name="Claude Code", + original_count=1, + compressed_count=1, + query_context="Claude Code /compact summary", + sample_content=( + "This session is being continued from a previous conversation " + "that ran out of context. The conversation is summarized below: " + "We changed the auth middleware and still need to run tests." + ), + workspace_key="ws-test", + ) + + assert tracker.get_tracked_hashes() == [] + + def test_claude_code_compact_summary_detector_is_narrow(self): + """The compact-summary detector does not reject ordinary summaries.""" + assert looks_like_claude_code_compact_summary( + "This session is being continued from a previous conversation. " + "The conversation is summarized below." + ) + assert not looks_like_claude_code_compact_summary( + "search results summary for auth middleware files" + ) + def test_track_multiple_compressions(self): """Track multiple compression events.""" tracker = ContextTracker()