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()