From 042c1d6bb0543c543ed1a81f009aab4569b0405d Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Sun, 24 May 2026 10:24:14 +0800 Subject: [PATCH] test: cover fallback dropped-turn handoff --- agent/context_compressor.py | 47 +++++++++++++++++++++----- tests/agent/test_context_compressor.py | 26 ++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3af78da1dc5..58829dbf4fb 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -79,6 +79,7 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 # only meant to preserve continuity anchors from the dropped window, not to # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 +_FALLBACK_TURN_MAX_CHARS = 700 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -574,8 +575,8 @@ class ContextCompressor(ContextEngine): self.quiet_mode = quiet_mode # When True, summary-generation failure aborts compression entirely # (returns messages unchanged, sets _last_compress_aborted=True). - # When False (default = historical behavior), insert a static - # "summary unavailable" placeholder and drop the middle window. + # When False (default = historical behavior), insert a + # deterministic "summary unavailable" handoff and drop the middle window. self.abort_on_summary_failure = abort_on_summary_failure self.context_length = get_model_context_length( @@ -941,6 +942,23 @@ class ContextCompressor(ContextEngine): tool_actions: list[str] = [] relevant_files: list[str] = [] blockers: list[str] = [] + last_dropped_turns: list[str] = [] + + def _compact_fallback_turn(value: Any) -> str: + text = redact_sensitive_text(_content_text_for_contains(value)) + text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > _FALLBACK_TURN_MAX_CHARS: + text = text[: _FALLBACK_TURN_MAX_CHARS - 15].rstrip() + " ...[truncated]" + return re.sub(r"\bgh[pousr]_[A-Za-z0-9_.-]+", "[REDACTED]", text) + + def _remember_dropped_turn(label: str, text: str, *, limit: int = 8) -> None: + text = text.strip() + if not text: + return + last_dropped_turns.append(f"{label}: {text}") + if len(last_dropped_turns) > limit: + del last_dropped_turns[0] def _collect_paths_from_jsonish(obj: Any) -> None: if isinstance(obj, dict): @@ -972,10 +990,20 @@ class ContextCompressor(ContextEngine): for msg in turns_to_summarize: role = msg.get("role", "unknown") - text = redact_sensitive_text( - _content_text_for_contains(msg.get("content")) - ).strip() + text = _compact_fallback_turn(msg.get("content")) _collect_path_mentions(text, relevant_files) + + turn_text = text + turn_tool_names: list[str] = [] + if role == "assistant" and msg.get("tool_calls"): + for tc in msg.get("tool_calls") or []: + name, _args = _extract_tool_call_name_and_args(tc) + turn_tool_names.append(name) + if turn_tool_names: + prefix = "tool calls: " + ", ".join(turn_tool_names[:6]) + turn_text = f"{prefix}; {turn_text}" if turn_text else prefix + _remember_dropped_turn(str(role).upper(), turn_text) + if len(text) > 600: text = text[:420].rstrip() + " ... " + text[-160:].lstrip() @@ -1073,6 +1101,9 @@ None recoverable from deterministic fallback. ## Remaining Work Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. +## Last Dropped Turns +{_bullets(last_dropped_turns, limit=8)} + ## Critical Context Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" summary = self._with_summary_prefix(redact_sensitive_text(body.strip())) @@ -1808,9 +1839,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio # True → ABORT compression entirely. Return messages unchanged # and set _last_compress_aborted=True so callers can warn # the user and stop the auto-compress retry loop. - # False → Fall through to the legacy fallback path below: insert - # a static "summary unavailable" placeholder and drop the - # middle window. Records _last_summary_fallback_used / + # False → Fall through to the default fallback path below: insert + # a deterministic "summary unavailable" handoff and drop + # the middle window. Records _last_summary_fallback_used / # _last_summary_dropped_count for gateway hygiene to # surface a warning. # Default is False (historical behavior). diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 476e2e930f6..0d7aa81f41f 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -881,6 +881,32 @@ class TestSummaryFailureTrackingForGatewayWarning: assert "/repo/src/pkg/module.py" in fallback assert "C:\\work\\pkg\\module.py" in fallback assert "Traceback" in fallback + assert "## Last Dropped Turns" in fallback + assert "TOOL: Traceback in /repo/src/pkg/module.py: boom" in fallback + + def test_summary_failure_fallback_preserves_last_dropped_turns_without_tail(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) + + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "Investigate dropped-window request in /tmp/active.py"}, + {"role": "assistant", "content": "I inspected /tmp/active.py and found the failing branch"}, + {"role": "tool", "tool_call_id": "call-old", "content": "ValueError: boom in /tmp/active.py"}, + {"role": "assistant", "content": "Next step is patching /tmp/active.py"}, + {"role": "user", "content": "Confirm regression coverage for /tmp/active.py"}, + {"role": "assistant", "content": "Regression note is ready"}, + {"role": "user", "content": "protected tail request must not be copied from dropped window"}, + ] + + 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 "## Last Dropped Turns" in fallback + assert "ASSISTANT: I inspected /tmp/active.py and found the failing branch" in fallback + assert "TOOL: ValueError: boom in /tmp/active.py" in fallback + assert "protected tail request must not be copied" not in fallback def test_summary_failure_fallback_is_bounded(self): with patch("agent.context_compressor.get_model_context_length", return_value=100000):