test_model_switch_persistence.py
1 """Tests that gateway /model switch persists across messages. 2 3 The gateway /model command stores session overrides in 4 ``_session_model_overrides``. These must: 5 6 1. Be applied in ``run_sync()`` so the next agent uses the switched model. 7 2. Not be mistaken for fallback activation (which evicts the cached agent). 8 3. Survive across multiple messages until /reset clears them. 9 10 Tests exercise the real ``_apply_session_model_override()`` and 11 ``_is_intentional_model_switch()`` methods on ``GatewayRunner``. 12 """ 13 14 from datetime import datetime 15 from types import SimpleNamespace 16 from unittest.mock import AsyncMock, MagicMock 17 18 19 from gateway.config import GatewayConfig, Platform, PlatformConfig 20 from gateway.session import SessionEntry, SessionSource, build_session_key 21 22 23 # --------------------------------------------------------------------------- 24 # Helpers 25 # --------------------------------------------------------------------------- 26 27 28 def _make_source() -> SessionSource: 29 return SessionSource( 30 platform=Platform.TELEGRAM, 31 user_id="u1", 32 chat_id="c1", 33 user_name="tester", 34 chat_type="dm", 35 ) 36 37 38 def _make_runner(): 39 """Create a minimal GatewayRunner with stubbed internals.""" 40 from gateway.run import GatewayRunner 41 42 runner = object.__new__(GatewayRunner) 43 runner.config = GatewayConfig( 44 platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")} 45 ) 46 adapter = MagicMock() 47 adapter.send = AsyncMock() 48 runner.adapters = {Platform.TELEGRAM: adapter} 49 runner._voice_mode = {} 50 runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) 51 runner._session_model_overrides = {} 52 runner._pending_model_notes = {} 53 runner._background_tasks = set() 54 runner._running_agents = {} 55 runner._pending_messages = {} 56 runner._pending_approvals = {} 57 runner._session_db = None 58 runner._agent_cache = {} 59 runner._agent_cache_lock = None 60 runner._effective_model = None 61 runner._effective_provider = None 62 runner.session_store = MagicMock() 63 session_key = build_session_key(_make_source()) 64 session_entry = SessionEntry( 65 session_key=session_key, 66 session_id="sess-1", 67 created_at=datetime.now(), 68 updated_at=datetime.now(), 69 platform=Platform.TELEGRAM, 70 chat_type="dm", 71 ) 72 runner.session_store.get_or_create_session.return_value = session_entry 73 runner.session_store._entries = {session_key: session_entry} 74 return runner 75 76 77 # --------------------------------------------------------------------------- 78 # Tests: _apply_session_model_override 79 # --------------------------------------------------------------------------- 80 81 82 class TestApplySessionModelOverride: 83 """Verify _apply_session_model_override replaces config defaults.""" 84 85 def test_override_replaces_all_fields(self): 86 runner = _make_runner() 87 sk = build_session_key(_make_source()) 88 89 runner._session_model_overrides[sk] = { 90 "model": "gpt-5.4-turbo", 91 "provider": "openrouter", 92 "api_key": "or-key-123", 93 "base_url": "https://openrouter.ai/api/v1", 94 "api_mode": "chat_completions", 95 } 96 97 model, rt = runner._apply_session_model_override( 98 sk, 99 "anthropic/claude-sonnet-4", 100 {"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"}, 101 ) 102 103 assert model == "gpt-5.4-turbo" 104 assert rt["provider"] == "openrouter" 105 assert rt["api_key"] == "or-key-123" 106 assert rt["base_url"] == "https://openrouter.ai/api/v1" 107 assert rt["api_mode"] == "chat_completions" 108 109 def test_no_override_returns_originals(self): 110 runner = _make_runner() 111 sk = build_session_key(_make_source()) 112 113 orig_model = "anthropic/claude-sonnet-4" 114 orig_rt = {"provider": "anthropic", "api_key": "***", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"} 115 116 model, rt = runner._apply_session_model_override(sk, orig_model, dict(orig_rt)) 117 118 assert model == orig_model 119 assert rt == orig_rt 120 121 def test_override_preserves_acp_command_args(self): 122 runner = _make_runner() 123 sk = build_session_key(_make_source()) 124 125 runner._session_model_overrides[sk] = { 126 "model": "kimi-for-coding", 127 "provider": "opencode-kimi-oauth", 128 "api_key": "***", 129 "base_url": "acp://opencode", 130 "api_mode": "chat_completions", 131 "command": "/home/user/.local/bin/opencode", 132 "args": ["acp"], 133 } 134 135 model, rt = runner._apply_session_model_override( 136 sk, 137 "anthropic/claude-sonnet-4", 138 { 139 "provider": "anthropic", 140 "api_key": "ant-key", 141 "base_url": "https://api.anthropic.com", 142 "api_mode": "anthropic_messages", 143 }, 144 ) 145 146 assert model == "kimi-for-coding" 147 assert rt["provider"] == "opencode-kimi-oauth" 148 assert rt["base_url"] == "acp://opencode" 149 assert rt["api_mode"] == "chat_completions" 150 assert rt["command"] == "/home/user/.local/bin/opencode" 151 assert rt["args"] == ["acp"] 152 153 def test_resolve_session_runtime_fast_path_preserves_acp_command_args(self): 154 runner = _make_runner() 155 sk = build_session_key(_make_source()) 156 runner._session_model_overrides[sk] = { 157 "model": "kimi-for-coding", 158 "provider": "opencode-kimi-oauth", 159 "api_key": "***", 160 "base_url": "acp://opencode", 161 "api_mode": "chat_completions", 162 "command": "/home/user/.local/bin/opencode", 163 "args": ["acp"], 164 } 165 166 model, rt = runner._resolve_session_agent_runtime( 167 session_key=sk, 168 user_config={"model": {"default": "anthropic/claude-sonnet-4"}}, 169 ) 170 171 assert model == "kimi-for-coding" 172 assert rt["provider"] == "opencode-kimi-oauth" 173 assert rt["base_url"] == "acp://opencode" 174 assert rt["command"] == "/home/user/.local/bin/opencode" 175 assert rt["args"] == ["acp"] 176 177 def test_none_values_do_not_overwrite(self): 178 """Override with None api_key/base_url should preserve config defaults.""" 179 runner = _make_runner() 180 sk = build_session_key(_make_source()) 181 182 runner._session_model_overrides[sk] = { 183 "model": "gpt-5.4", 184 "provider": "openai", 185 "api_key": None, 186 "base_url": None, 187 "api_mode": "chat_completions", 188 } 189 190 model, rt = runner._apply_session_model_override( 191 sk, 192 "anthropic/claude-sonnet-4", 193 {"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"}, 194 ) 195 196 assert model == "gpt-5.4" 197 assert rt["provider"] == "openai" 198 assert rt["api_key"] == "ant-key" # preserved — None didn't overwrite 199 assert rt["base_url"] == "https://api.anthropic.com" # preserved 200 assert rt["api_mode"] == "chat_completions" # overwritten (not None) 201 202 def test_empty_string_overwrites(self): 203 """Empty string is not None — it should overwrite the config value.""" 204 runner = _make_runner() 205 sk = build_session_key(_make_source()) 206 207 runner._session_model_overrides[sk] = { 208 "model": "local-model", 209 "provider": "custom", 210 "api_key": "local-key", 211 "base_url": "", 212 "api_mode": "chat_completions", 213 } 214 215 _, rt = runner._apply_session_model_override( 216 sk, 217 "anthropic/claude-sonnet-4", 218 {"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"}, 219 ) 220 221 assert rt["base_url"] == "" # empty string overwrites 222 223 def test_different_session_key_not_affected(self): 224 runner = _make_runner() 225 sk = build_session_key(_make_source()) 226 other_sk = "other_session" 227 228 runner._session_model_overrides[other_sk] = { 229 "model": "gpt-5.4", 230 "provider": "openai", 231 "api_key": "key", 232 "base_url": "", 233 "api_mode": "chat_completions", 234 } 235 236 model, rt = runner._apply_session_model_override( 237 sk, 238 "anthropic/claude-sonnet-4", 239 {"provider": "anthropic", "api_key": "ant-key", "base_url": "url", "api_mode": "anthropic_messages"}, 240 ) 241 242 assert model == "anthropic/claude-sonnet-4" # unchanged — wrong session key 243 244 245 # --------------------------------------------------------------------------- 246 # Tests: _is_intentional_model_switch 247 # --------------------------------------------------------------------------- 248 249 250 class TestIsIntentionalModelSwitch: 251 """Verify fallback detection respects intentional /model overrides.""" 252 253 def test_matches_override(self): 254 runner = _make_runner() 255 sk = build_session_key(_make_source()) 256 257 runner._session_model_overrides[sk] = { 258 "model": "gpt-5.4", 259 "provider": "openai", 260 "api_key": "key", 261 "base_url": "", 262 "api_mode": "chat_completions", 263 } 264 265 assert runner._is_intentional_model_switch(sk, "gpt-5.4") is True 266 267 def test_no_override_returns_false(self): 268 runner = _make_runner() 269 sk = build_session_key(_make_source()) 270 271 assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False 272 273 def test_different_model_returns_false(self): 274 """Agent fell back to a different model than the override.""" 275 runner = _make_runner() 276 sk = build_session_key(_make_source()) 277 278 runner._session_model_overrides[sk] = { 279 "model": "gpt-5.4", 280 "provider": "openai", 281 "api_key": "key", 282 "base_url": "", 283 "api_mode": "chat_completions", 284 } 285 286 assert runner._is_intentional_model_switch(sk, "gpt-5.4-mini") is False 287 288 def test_wrong_session_key(self): 289 runner = _make_runner() 290 sk = build_session_key(_make_source()) 291 292 runner._session_model_overrides["other_session"] = { 293 "model": "gpt-5.4", 294 "provider": "openai", 295 "api_key": "key", 296 "base_url": "", 297 "api_mode": "chat_completions", 298 } 299 300 assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False