/ tests / gateway / test_model_switch_persistence.py
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