diff --git a/agent/manual_compression_feedback.py b/agent/manual_compression_feedback.py new file mode 100644 index 000000000000..8f2d5e5d520a --- /dev/null +++ b/agent/manual_compression_feedback.py @@ -0,0 +1,49 @@ +"""User-facing summaries for manual compression commands.""" + +from __future__ import annotations + +from typing import Any, Sequence + + +def summarize_manual_compression( + before_messages: Sequence[dict[str, Any]], + after_messages: Sequence[dict[str, Any]], + before_tokens: int, + after_tokens: int, +) -> 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) + + if noop: + headline = f"No changes from compression: {before_count} messages" + if after_tokens == before_tokens: + token_line = ( + f"Rough transcript estimate: ~{before_tokens:,} tokens (unchanged)" + ) + else: + token_line = ( + f"Rough transcript estimate: ~{before_tokens:,} → " + f"~{after_tokens:,} tokens" + ) + else: + headline = f"Compressed: {before_count} → {after_count} messages" + token_line = ( + f"Rough transcript estimate: ~{before_tokens:,} → " + f"~{after_tokens:,} tokens" + ) + + note = None + if not noop and after_count < before_count and after_tokens > before_tokens: + note = ( + "Note: fewer messages can still raise this rough transcript estimate " + "when compression rewrites the transcript into denser summaries." + ) + + return { + "noop": noop, + "headline": headline, + "token_line": token_line, + "note": note, + } diff --git a/cli.py b/cli.py index 221976ad256f..76979c45f50f 100644 --- a/cli.py +++ b/cli.py @@ -5754,21 +5754,29 @@ def _manual_compress(self): original_count = len(self.conversation_history) try: from agent.model_metadata import estimate_messages_tokens_rough - approx_tokens = estimate_messages_tokens_rough(self.conversation_history) + from agent.manual_compression_feedback import summarize_manual_compression + original_history = list(self.conversation_history) + approx_tokens = estimate_messages_tokens_rough(original_history) print(f"🗜️ Compressing {original_count} messages (~{approx_tokens:,} tokens)...") - compressed, new_system = self.agent._compress_context( - self.conversation_history, + compressed, _ = self.agent._compress_context( + original_history, self.agent._cached_system_prompt or "", approx_tokens=approx_tokens, ) self.conversation_history = compressed - new_count = len(self.conversation_history) new_tokens = estimate_messages_tokens_rough(self.conversation_history) - print( - f" ✅ Compressed: {original_count} → {new_count} messages " - f"(~{approx_tokens:,} → ~{new_tokens:,} tokens)" + summary = summarize_manual_compression( + original_history, + self.conversation_history, + approx_tokens, + new_tokens, ) + icon = "🗜️" if summary["noop"] else "✅" + print(f" {icon} {summary['headline']}") + print(f" {summary['token_line']}") + if summary["note"]: + print(f" {summary['note']}") except Exception as e: print(f" ❌ Compression failed: {e}") diff --git a/gateway/run.py b/gateway/run.py index b75b0e1f0b23..0762592df446 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5000,6 +5000,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: try: from run_agent import AIAgent + from agent.manual_compression_feedback import summarize_manual_compression from agent.model_metadata import estimate_messages_tokens_rough runtime_kwargs = _resolve_runtime_agent_kwargs() @@ -5027,6 +5028,13 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: ) tmp_agent._print_fn = lambda *a, **kw: None + compressor = tmp_agent.context_compressor + compress_start = compressor.protect_first_n + compress_start = compressor._align_boundary_forward(msgs, compress_start) + compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start) + if compress_start >= compress_end: + return "Nothing to compress yet (the transcript is still all protected context)." + loop = asyncio.get_event_loop() compressed, _ = await loop.run_in_executor( None, @@ -5047,13 +5055,17 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: self.session_store.update_session( session_entry.session_key, last_prompt_tokens=0 ) - new_count = len(compressed) new_tokens = estimate_messages_tokens_rough(compressed) - - return ( - f"🗜️ Compressed: {original_count} → {new_count} messages\n" - f"~{approx_tokens:,} → ~{new_tokens:,} tokens" + summary = summarize_manual_compression( + msgs, + compressed, + approx_tokens, + new_tokens, ) + lines = [f"🗜️ {summary['headline']}", summary["token_line"]] + if summary["note"]: + lines.append(summary["note"]) + return "\n".join(lines) except Exception as e: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" diff --git a/tests/cli/test_manual_compress.py b/tests/cli/test_manual_compress.py new file mode 100644 index 000000000000..d201f9cee59e --- /dev/null +++ b/tests/cli/test_manual_compress.py @@ -0,0 +1,66 @@ +"""Tests for CLI manual compression messaging.""" + +from unittest.mock import MagicMock, patch + +from tests.cli.test_cli_init import _make_cli + + +def _make_history() -> list[dict[str, str]]: + return [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + {"role": "assistant", "content": "four"}, + ] + + +def test_manual_compress_reports_noop_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._compress_context.return_value = (list(history), "") + + def _estimate(messages): + assert messages == history + return 100 + + with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate): + shell._manual_compress() + + output = capsys.readouterr().out + assert "No changes from compression" in output + assert "✅ Compressed" not in output + assert "Rough transcript estimate: ~100 tokens (unchanged)" in output + + +def test_manual_compress_explains_when_token_estimate_rises(capsys): + shell = _make_cli() + history = _make_history() + compressed = [ + history[0], + {"role": "assistant", "content": "Dense summary that still counts as more tokens."}, + history[-1], + ] + shell.conversation_history = history + shell.agent = MagicMock() + shell.agent.compression_enabled = True + shell.agent._cached_system_prompt = "" + shell.agent._compress_context.return_value = (compressed, "") + + def _estimate(messages): + if messages == history: + return 100 + if messages == compressed: + return 120 + raise AssertionError(f"unexpected transcript: {messages!r}") + + with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate): + shell._manual_compress() + + output = capsys.readouterr().out + assert "✅ Compressed: 4 → 3 messages" in output + assert "Rough transcript estimate: ~100 → ~120 tokens" in output + assert "denser summaries" in output diff --git a/tests/gateway/test_compress_command.py b/tests/gateway/test_compress_command.py new file mode 100644 index 000000000000..edeb1f47c974 --- /dev/null +++ b/tests/gateway/test_compress_command.py @@ -0,0 +1,121 @@ +"""Tests for gateway /compress user-facing messaging.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ) + + +def _make_event(text: str = "/compress") -> MessageEvent: + return MessageEvent(text=text, source=_make_source(), message_id="m1") + + +def _make_history() -> list[dict[str, str]]: + return [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + {"role": "user", "content": "three"}, + {"role": "assistant", "content": "four"}, + ] + + +def _make_runner(history: list[dict[str, str]]): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = history + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner.session_store._save = MagicMock() + return runner + + +@pytest.mark.asyncio +async def test_compress_command_reports_noop_without_success_banner(): + history = _make_history() + runner = _make_runner(history) + agent_instance = MagicMock() + agent_instance.context_compressor.protect_first_n = 0 + agent_instance.context_compressor._align_boundary_forward.return_value = 0 + agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2 + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (list(history), "") + + def _estimate(messages): + assert messages == history + return 100 + + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance), + patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate), + ): + result = await runner._handle_compress_command(_make_event()) + + assert "No changes from compression" in result + assert "Compressed:" not in result + assert "Rough transcript estimate: ~100 tokens (unchanged)" in result + + +@pytest.mark.asyncio +async def test_compress_command_explains_when_token_estimate_rises(): + history = _make_history() + compressed = [ + history[0], + {"role": "assistant", "content": "Dense summary that still counts as more tokens."}, + history[-1], + ] + runner = _make_runner(history) + agent_instance = MagicMock() + agent_instance.context_compressor.protect_first_n = 0 + agent_instance.context_compressor._align_boundary_forward.return_value = 0 + agent_instance.context_compressor._find_tail_cut_by_tokens.return_value = 2 + agent_instance.session_id = "sess-1" + agent_instance._compress_context.return_value = (compressed, "") + + def _estimate(messages): + if messages == history: + return 100 + if messages == compressed: + return 120 + raise AssertionError(f"unexpected transcript: {messages!r}") + + with ( + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), + patch("gateway.run._resolve_gateway_model", return_value="test-model"), + patch("run_agent.AIAgent", return_value=agent_instance), + patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate), + ): + result = await runner._handle_compress_command(_make_event()) + + assert "Compressed: 4 → 3 messages" in result + assert "Rough transcript estimate: ~100 → ~120 tokens" in result + assert "denser summaries" in result