/ tests / gateway / test_compress_command.py
test_compress_command.py
  1  """Tests for gateway /compress user-facing messaging."""
  2  
  3  from datetime import datetime
  4  from unittest.mock import MagicMock, patch
  5  
  6  import pytest
  7  
  8  from gateway.config import GatewayConfig, Platform, PlatformConfig
  9  from gateway.platforms.base import MessageEvent
 10  from gateway.session import SessionEntry, SessionSource, build_session_key
 11  
 12  
 13  def _make_source() -> SessionSource:
 14      return SessionSource(
 15          platform=Platform.TELEGRAM,
 16          user_id="u1",
 17          chat_id="c1",
 18          user_name="tester",
 19          chat_type="dm",
 20      )
 21  
 22  
 23  def _make_event(text: str = "/compress") -> MessageEvent:
 24      return MessageEvent(text=text, source=_make_source(), message_id="m1")
 25  
 26  
 27  def _make_history() -> list[dict[str, str]]:
 28      return [
 29          {"role": "user", "content": "one"},
 30          {"role": "assistant", "content": "two"},
 31          {"role": "user", "content": "three"},
 32          {"role": "assistant", "content": "four"},
 33      ]
 34  
 35  
 36  def _make_runner(history: list[dict[str, str]]):
 37      from gateway.run import GatewayRunner
 38  
 39      runner = object.__new__(GatewayRunner)
 40      runner.config = GatewayConfig(
 41          platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
 42      )
 43      session_entry = SessionEntry(
 44          session_key=build_session_key(_make_source()),
 45          session_id="sess-1",
 46          created_at=datetime.now(),
 47          updated_at=datetime.now(),
 48          platform=Platform.TELEGRAM,
 49          chat_type="dm",
 50      )
 51      runner.session_store = MagicMock()
 52      runner.session_store.get_or_create_session.return_value = session_entry
 53      runner.session_store.load_transcript.return_value = history
 54      runner.session_store.rewrite_transcript = MagicMock()
 55      runner.session_store.update_session = MagicMock()
 56      runner.session_store._save = MagicMock()
 57      return runner
 58  
 59  
 60  @pytest.mark.asyncio
 61  async def test_compress_command_reports_noop_without_success_banner():
 62      history = _make_history()
 63      runner = _make_runner(history)
 64      agent_instance = MagicMock()
 65      agent_instance.shutdown_memory_provider = MagicMock()
 66      agent_instance.close = MagicMock()
 67      agent_instance._cached_system_prompt = ""
 68      agent_instance.tools = None
 69      agent_instance.context_compressor.has_content_to_compress.return_value = True
 70      agent_instance.session_id = "sess-1"
 71      agent_instance._compress_context.return_value = (list(history), "")
 72  
 73      def _estimate(messages, **_kwargs):
 74          assert messages == history
 75          return 100
 76  
 77      with (
 78          patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
 79          patch("gateway.run._resolve_gateway_model", return_value="test-model"),
 80          patch("run_agent.AIAgent", return_value=agent_instance),
 81          patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
 82      ):
 83          result = await runner._handle_compress_command(_make_event())
 84  
 85      assert "No changes from compression" in result
 86      assert "Compressed:" not in result
 87      assert "Approx request size: ~100 tokens (unchanged)" in result
 88      agent_instance.shutdown_memory_provider.assert_called_once()
 89      agent_instance.close.assert_called_once()
 90  
 91  
 92  @pytest.mark.asyncio
 93  async def test_compress_command_explains_when_token_estimate_rises():
 94      history = _make_history()
 95      compressed = [
 96          history[0],
 97          {"role": "assistant", "content": "Dense summary that still counts as more tokens."},
 98          history[-1],
 99      ]
100      runner = _make_runner(history)
101      agent_instance = MagicMock()
102      agent_instance.shutdown_memory_provider = MagicMock()
103      agent_instance.close = MagicMock()
104      agent_instance._cached_system_prompt = ""
105      agent_instance.tools = None
106      agent_instance.context_compressor.has_content_to_compress.return_value = True
107      agent_instance.session_id = "sess-1"
108      agent_instance._compress_context.return_value = (compressed, "")
109  
110      def _estimate(messages, **_kwargs):
111          if messages == history:
112              return 100
113          if messages == compressed:
114              return 120
115          raise AssertionError(f"unexpected transcript: {messages!r}")
116  
117      with (
118          patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
119          patch("gateway.run._resolve_gateway_model", return_value="test-model"),
120          patch("run_agent.AIAgent", return_value=agent_instance),
121          patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
122      ):
123          result = await runner._handle_compress_command(_make_event())
124  
125      assert "Compressed: 4 → 3 messages" in result
126      assert "Approx request size: ~100 → ~120 tokens" in result
127      assert "denser summaries" in result
128      agent_instance.shutdown_memory_provider.assert_called_once()
129      agent_instance.close.assert_called_once()
130  
131  
132  @pytest.mark.asyncio
133  async def test_compress_command_appends_warning_when_summary_generation_fails():
134      """When the auxiliary summariser fails and the compressor inserts a static
135      fallback placeholder, /compress must append a visible ⚠️ warning to its
136      reply. Otherwise the failure is silently logged and the user has no idea
137      earlier context is unrecoverable."""
138      history = _make_history()
139      # Compressed shape is irrelevant for this test — we only care that the
140      # warning surfaces. Drop one message so the headline is non-noop.
141      compressed = [
142          history[0],
143          {"role": "assistant", "content": "[fallback placeholder]"},
144          history[-1],
145      ]
146      runner = _make_runner(history)
147      agent_instance = MagicMock()
148      agent_instance.shutdown_memory_provider = MagicMock()
149      agent_instance.close = MagicMock()
150      agent_instance._cached_system_prompt = ""
151      agent_instance.tools = None
152      agent_instance.context_compressor.has_content_to_compress.return_value = True
153      # Simulate summary-generation failure: fallback flag set, dropped count
154      # populated, error string captured.
155      agent_instance.context_compressor._last_summary_fallback_used = True
156      agent_instance.context_compressor._last_summary_dropped_count = 7
157      agent_instance.context_compressor._last_summary_error = (
158          "404 model not found: gemini-3-flash-preview"
159      )
160      agent_instance.session_id = "sess-1"
161      agent_instance._compress_context.return_value = (compressed, "")
162  
163      def _estimate(messages, **_kwargs):
164          if messages == history:
165              return 100
166          if messages == compressed:
167              return 60
168          raise AssertionError(f"unexpected transcript: {messages!r}")
169  
170      with (
171          patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
172          patch("gateway.run._resolve_gateway_model", return_value="test-model"),
173          patch("run_agent.AIAgent", return_value=agent_instance),
174          patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
175      ):
176          result = await runner._handle_compress_command(_make_event())
177  
178      # The compress reply itself still goes through (the transcript was rewritten).
179      assert "Compressed:" in result
180      # ...but a clearly-marked warning must be appended.
181      assert "⚠️" in result
182      assert "Summary generation failed" in result
183      # Underlying error must surface so users can fix their config.
184      assert "404 model not found" in result
185      # Dropped count must be visible — silently losing N messages is the bug.
186      assert "7" in result
187      assert "historical message(s) were removed" in result
188      agent_instance.shutdown_memory_provider.assert_called_once()
189      agent_instance.close.assert_called_once()
190  
191  
192  @pytest.mark.asyncio
193  async def test_compress_command_surfaces_aux_model_failure_even_when_recovered():
194      """When the user's configured ``auxiliary.compression.model`` errors out
195      but compression recovers by retrying on the main model, /compress must
196      STILL inform the user.  Silent recovery hides broken config the user
197      needs to fix."""
198      history = _make_history()
199      # Compressed transcript — normal successful compression, no placeholder.
200      compressed = [
201          history[0],
202          {"role": "assistant", "content": "summary via main model"},
203          history[-1],
204      ]
205      runner = _make_runner(history)
206      agent_instance = MagicMock()
207      agent_instance.shutdown_memory_provider = MagicMock()
208      agent_instance.close = MagicMock()
209      agent_instance._cached_system_prompt = ""
210      agent_instance.tools = None
211      agent_instance.context_compressor.has_content_to_compress.return_value = True
212      # Fallback placeholder was NOT used — recovery succeeded.
213      agent_instance.context_compressor._last_summary_fallback_used = False
214      agent_instance.context_compressor._last_summary_dropped_count = 0
215      agent_instance.context_compressor._last_summary_error = None
216      # But the configured aux model DID fail before the retry succeeded.
217      agent_instance.context_compressor._last_aux_model_failure_model = (
218          "gemini-3-flash-preview"
219      )
220      agent_instance.context_compressor._last_aux_model_failure_error = (
221          "404 model not found: gemini-3-flash-preview"
222      )
223      agent_instance.session_id = "sess-1"
224      agent_instance._compress_context.return_value = (compressed, "")
225  
226      def _estimate(messages, **_kwargs):
227          if messages == history:
228              return 100
229          if messages == compressed:
230              return 60
231          raise AssertionError(f"unexpected transcript: {messages!r}")
232  
233      with (
234          patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
235          patch("gateway.run._resolve_gateway_model", return_value="test-model"),
236          patch("run_agent.AIAgent", return_value=agent_instance),
237          patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
238      ):
239          result = await runner._handle_compress_command(_make_event())
240  
241      # Compression succeeded
242      assert "Compressed:" in result
243      # No ⚠️ warning (that's reserved for dropped-turns case)
244      assert "⚠️" not in result
245      # But there IS an info note about the broken aux model
246      assert "ℹ️" in result
247      assert "gemini-3-flash-preview" in result
248      assert "404" in result
249      assert "auxiliary.compression.model" in result
250      # The user's context is explicitly called out as intact
251      assert "intact" in result
252      agent_instance.shutdown_memory_provider.assert_called_once()
253      agent_instance.close.assert_called_once()