diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 1a7d96b77dbb..e9e701b14506 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -47,6 +47,11 @@ _SUMMARY_PERMANENT_QUOTA_MARKERS: tuple[str, ...] = ( "out of extra usage", ) +_SUMMARY_MISSING_CREDENTIAL_MARKERS: tuple[str, ...] = ( + "no api key was found", + "no api key found", +) + def _is_summary_access_or_quota_error(exc: Exception) -> bool: """Return True for non-retryable summary auth, permission, or quota errors.""" @@ -57,13 +62,16 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: if classified.reason in {FailoverReason.auth, FailoverReason.auth_permanent}: return True + err_text = str(exc).lower() + if any(marker in err_text for marker in _SUMMARY_MISSING_CREDENTIAL_MARKERS): + return True + status = getattr(exc, "status_code", None) or getattr( getattr(exc, "response", None), "status_code", None ) if status in {401, 402, 403}: return True - err_text = str(exc).lower() if classified.reason is FailoverReason.billing: return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) return any(marker in err_text for marker in _SUMMARY_PERMANENT_QUOTA_MARKERS) diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py index 32b00f7cf4b5..9a40cc36df1e 100644 --- a/agent/manual_compression_feedback.py +++ b/agent/manual_compression_feedback.py @@ -4,45 +4,83 @@ from __future__ import annotations from typing import Any, Sequence +from agent.redact import redact_sensitive_text + def summarize_manual_compression( before_messages: Sequence[dict[str, Any]], after_messages: Sequence[dict[str, Any]], before_tokens: int, after_tokens: int, + *, + compression_state: Any = None, ) -> dict[str, Any]: """Return consistent user-facing feedback for manual compression.""" before_count = len(before_messages) after_count = len(after_messages) noop = list(after_messages) == list(before_messages) + aborted = ( + compression_state is not None + and getattr(compression_state, "_last_compress_aborted", False) is True + ) + fallback_used = ( + compression_state is not None + and getattr(compression_state, "_last_summary_fallback_used", False) is True + ) + failure_reason = ( + getattr(compression_state, "_last_summary_error", None) + if compression_state is not None + else None + ) + if not isinstance(failure_reason, str) or not failure_reason.strip(): + failure_reason = None - if noop: + if aborted: + headline = f"Compression aborted: {before_count} messages preserved" + elif fallback_used: + headline = ( + f"Compressed with fallback: {before_count} → {after_count} messages" + ) + elif noop: headline = f"No changes from compression: {before_count} messages" - if after_tokens == before_tokens: - token_line = ( - f"Approx request size: ~{before_tokens:,} tokens (unchanged)" - ) - else: - token_line = ( - f"Approx request size: ~{before_tokens:,} → " - f"~{after_tokens:,} tokens" - ) else: headline = f"Compressed: {before_count} → {after_count} messages" + + if noop and after_tokens == before_tokens: + token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)" + else: token_line = ( f"Approx request size: ~{before_tokens:,} → " f"~{after_tokens:,} tokens" ) note = None - if not noop and after_count < before_count and after_tokens > before_tokens: + if aborted: + note = "Summary generation failed; no messages were removed." + elif fallback_used: + dropped_count = getattr( + compression_state, "_last_summary_dropped_count", None + ) + if not isinstance(dropped_count, int) or isinstance(dropped_count, bool): + dropped_count = max(before_count - after_count, 0) + note = ( + "Summary generation failed; Hermes used limited fallback context " + f"and removed {dropped_count} message(s)." + ) + elif not noop and after_count < before_count and after_tokens > before_tokens: note = ( "Note: fewer messages can still raise this estimate when " "compression rewrites the transcript into denser summaries." ) + if failure_reason and (aborted or fallback_used): + safe_reason = redact_sensitive_text(failure_reason.strip()) + note = f"{note} Reason: {safe_reason}" + return { "noop": noop, + "aborted": aborted, + "fallback_used": fallback_used, "headline": headline, "token_line": token_line, "note": note, diff --git a/cli.py b/cli.py index 8a8fdf6f5ff5..8184f98d392b 100644 --- a/cli.py +++ b/cli.py @@ -9641,8 +9641,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self.conversation_history, approx_tokens, new_tokens, + compression_state=getattr( + self.agent, "context_compressor", None + ), ) - icon = "🗜️" if summary["noop"] else "✅" + if summary.get("aborted") or summary.get("fallback_used"): + icon = "⚠️" + else: + icon = "🗜️" if summary["noop"] else "✅" print(f" {icon} {summary['headline']}") print(f" {summary['token_line']}") if summary["note"]: diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index d24daf59467f..08ef6da986eb 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -854,11 +854,19 @@ class TestAuthFailureAborts: def test_quota_classifier_accepts_explicit_provider_signals(self, message): assert _is_summary_access_or_quota_error(Exception(message)) is True + def test_missing_provider_api_key_is_terminal_access_failure(self): + err = RuntimeError( + "Provider 'opencode-zen' is set in config.yaml but no API key was " + "found. Set the OPENCODE-ZEN_API_KEY environment variable." + ) + assert _is_summary_access_or_quota_error(err) is True + @pytest.mark.parametrize( "message", [ "billing portal is temporarily unavailable", "usage limit documentation could not be loaded", + "API key documentation was not found", "rate limit exceeded; retry later", "quota exceeded, please retry after the window resets", "request timed out", @@ -905,6 +913,34 @@ class TestAuthFailureAborts: assert c._last_compress_aborted is True assert c._last_summary_fallback_used is False + def test_missing_provider_api_key_preserves_original_messages(self): + """A configured auxiliary provider without a visible key preserves context.""" + err = RuntimeError( + "Provider 'opencode-zen' is set in config.yaml but no API key was " + "found. Set the OPENCODE-ZEN_API_KEY environment variable, or switch " + "to a different provider with hermes model." + ) + with patch( + "agent.context_compressor.get_model_context_length", return_value=100000 + ): + c = ContextCompressor( + model="test", + quiet_mode=True, + protect_first_n=2, + protect_last_n=2, + abort_on_summary_failure=False, + ) + msgs = self._msgs(12) + with patch("agent.context_compressor.call_llm", side_effect=err): + result = c.compress(msgs, current_tokens=999999, force=True) + + assert result == msgs + assert c._last_summary_error == str(err) + assert c._last_summary_auth_failure is True + assert c._last_compress_aborted is True + assert c._last_summary_fallback_used is False + assert c._last_summary_dropped_count == 0 + def test_402_quota_with_retry_uses_existing_fallback(self): """A reset-window quota remains transient instead of aborting compression.""" err = StubProviderError( diff --git a/tests/agent/test_manual_compression_feedback.py b/tests/agent/test_manual_compression_feedback.py new file mode 100644 index 000000000000..51f9fdf7b651 --- /dev/null +++ b/tests/agent/test_manual_compression_feedback.py @@ -0,0 +1,62 @@ +"""Behavioral coverage for manual compression status messages.""" + +from types import SimpleNamespace + +from agent.manual_compression_feedback import summarize_manual_compression + + +def _messages(count: int) -> list[dict[str, str]]: + return [ + {"role": "user" if index % 2 == 0 else "assistant", "content": str(index)} + for index in range(count) + ] + + +def test_aborted_compression_reports_preserved_messages_and_reason(): + messages = _messages(12) + state = SimpleNamespace( + _last_compress_aborted=True, + _last_summary_fallback_used=False, + _last_summary_error=( + "Provider 'opencode-zen' is set in config.yaml but no API key was found." + ), + ) + + feedback = summarize_manual_compression( + messages, + list(messages), + 120_000, + 120_000, + compression_state=state, + ) + + assert feedback["aborted"] is True + assert feedback["fallback_used"] is False + assert feedback["headline"] == "Compression aborted: 12 messages preserved" + assert "no messages were removed" in feedback["note"] + assert "no API key was found" in feedback["note"] + + +def test_fallback_compression_reports_dropped_message_count(): + before = _messages(12) + after = before[:2] + before[-2:] + state = SimpleNamespace( + _last_compress_aborted=False, + _last_summary_fallback_used=True, + _last_summary_dropped_count=8, + _last_summary_error="summary provider returned an invalid response", + ) + + feedback = summarize_manual_compression( + before, + after, + 120_000, + 40_000, + compression_state=state, + ) + + assert feedback["aborted"] is False + assert feedback["fallback_used"] is True + assert feedback["headline"] == "Compressed with fallback: 12 → 4 messages" + assert "removed 8 message(s)" in feedback["note"] + assert "invalid response" in feedback["note"] diff --git a/tests/cli/test_manual_compress.py b/tests/cli/test_manual_compress.py index d68106ffd5a1..054b1a6005c6 100644 --- a/tests/cli/test_manual_compress.py +++ b/tests/cli/test_manual_compress.py @@ -38,6 +38,32 @@ def test_manual_compress_reports_noop_without_success_banner(capsys): assert "Approx request size: ~100 tokens (unchanged)" in output +def test_manual_compress_reports_aborted_summary_without_success_banner(capsys): + shell = _make_cli() + history = _make_history() + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = True + shell.agent._cached_system_prompt = "" + shell.agent.tools = None + shell.agent.session_id = shell.session_id + shell.agent.context_compressor._last_compress_aborted = True + shell.agent.context_compressor._last_summary_fallback_used = False + shell.agent.context_compressor._last_summary_error = ( + "Provider 'opencode-zen' is set in config.yaml but no API key was found." + ) + shell.agent._compress_context.return_value = (list(history), "") + + with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100): + shell._manual_compress() + + output = capsys.readouterr().out + assert "⚠️ Compression aborted: 4 messages preserved" in output + assert "no messages were removed" in output + assert "no API key was found" in output + assert "✅ Compressed:" not in output + + def test_manual_compress_explains_when_token_estimate_rises(capsys): shell = _make_cli() history = _make_history() diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1f9ec457d9bf..afb38a1b9365 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4759,6 +4759,52 @@ def test_session_compress_uses_compress_helper(monkeypatch): emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"}) +def test_session_compress_reports_aborted_summary_without_success(monkeypatch): + compression_state = types.SimpleNamespace( + _last_compress_aborted=True, + _last_summary_fallback_used=False, + _last_summary_error=( + "Provider 'opencode-zen' is set in config.yaml but no API key was found." + ), + ) + agent = types.SimpleNamespace( + context_compressor=compression_state, + _cached_system_prompt="", + tools=None, + ) + history = [{"role": "user", "content": f"m{i}"} for i in range(6)] + server._sessions["sid"] = _session(agent=agent, history=history) + + monkeypatch.setattr( + server, + "_compress_session_history", + lambda session, focus_topic=None, **_kw: (0, {"total": 42}), + ) + monkeypatch.setattr(server, "_session_info", lambda _agent, *a: {"model": "x"}) + + try: + with patch("tui_gateway.server._emit"): + resp = server.handle_request( + { + "id": "1", + "method": "session.compress", + "params": {"session_id": "sid"}, + } + ) + + result = resp["result"] + assert result["status"] == "aborted" + assert result["removed"] == 0 + assert result["summary"]["aborted"] is True + assert result["summary"]["headline"] == ( + "Compression aborted: 6 messages preserved" + ) + assert "no API key was found" in result["summary"]["note"] + assert "Compressed:" not in result["summary"]["headline"] + finally: + server._sessions.pop("sid", None) + + def test_session_compress_syncs_session_key_after_rotation(monkeypatch): """When AIAgent._compress_context rotates session_id (compression split), the gateway session_key must follow so subsequent approval routing, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6fc7fe38b246..dd267a4037a9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7976,14 +7976,18 @@ def _(rid, params: dict) -> dict: agent = session["agent"] _sync_session_key_after_compress(sid, session) summary = summarize_manual_compression( - before_messages, messages, before_tokens, after_tokens + before_messages, + messages, + before_tokens, + after_tokens, + compression_state=getattr(agent, "context_compressor", None), ) info = _session_info(agent, session) _emit("session.info", sid, info) return _ok( rid, { - "status": "compressed", + "status": "aborted" if summary["aborted"] else "compressed", "removed": removed, "before_messages": before_count, "after_messages": after_count, @@ -12442,7 +12446,11 @@ def _(rid, params: dict) -> dict: ) _sync_session_key_after_compress(sid, session) summary = summarize_manual_compression( - before_messages, after_messages, before_tokens, after_tokens + before_messages, + after_messages, + before_tokens, + after_tokens, + compression_state=getattr(_agent, "context_compressor", None), ) _emit("session.info", sid, _session_info(session.get("agent"), session)) return _ok( @@ -13215,7 +13223,11 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: ) _emit("session.info", sid, _session_info(agent, session)) _fb = summarize_manual_compression( - _before_messages, _after_messages, _before_tokens, _after_tokens + _before_messages, + _after_messages, + _before_tokens, + _after_tokens, + compression_state=getattr(agent, "context_compressor", None), ) _lines = [_fb["headline"], _fb["token_line"]] if _fb.get("note"):