From 479cbb7237712afb691034178d57cda8c6c7c9de Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Thu, 9 Jul 2026 21:51:04 +0800 Subject: [PATCH 1/2] fix(gateway): default /model to session scope on messaging platforms When users pick models via Telegram/Discord/Slack menus (which cannot send --global/--session flags), the switch now defaults to session-scoped unless --global is explicitly provided. This prevents accidental global config changes from platform picker interactions. Fixes #61458 --- gateway/slash_commands.py | 14 ++++++++ tests/gateway/test_model_picker_persist.py | 40 +++++++++++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 3d7bed924c7bb..2691c12cd1035 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1427,6 +1427,20 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: force_refresh, is_session, ) = parse_model_flags(raw_args) + + # Context-aware default: messaging platforms (Telegram, Discord, Slack, etc.) + # should default to session scope unless --global is explicitly provided. + # This prevents accidental global config changes when users pick models via + # platform menus, which cannot send --global/--session flags. + source = event.source + is_messaging_platform = ( + source.platform is not None + and source.platform != Platform.LOCAL + ) + if is_messaging_platform and not is_global_flag and not is_session: + # Treat as if --session was passed (session-scoped by default) + is_session = True + persist_global = resolve_persist_behavior(is_global_flag, is_session) # --refresh: bust the disk cache so the picker shows live data. diff --git a/tests/gateway/test_model_picker_persist.py b/tests/gateway/test_model_picker_persist.py index 95654030d8264..8c3262719c693 100644 --- a/tests/gateway/test_model_picker_persist.py +++ b/tests/gateway/test_model_picker_persist.py @@ -154,26 +154,40 @@ async def _drive_picker(runner, event): ], ids=["nested-dict", "flat-string"], ) -async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model): - """Tapping a model in the picker (bare /model) persists to config.yaml, - matching the typed ``/model`` default — this is the #49176 fix. The written - ``model:`` must always end up a nested dict regardless of the seed shape.""" +async def test_picker_tap_is_session_scoped_by_default_on_messaging_platforms( + tmp_path, monkeypatch, seed_model +): + """Tapping a model in the picker (bare /model) is session-scoped by default + on messaging platforms (Telegram, Discord, Slack, etc.). + + This prevents accidental global config changes when users pick models via + platform menus, which cannot send --global/--session flags. The in-memory + session override IS applied (the switch worked), but config.yaml is untouched. + + See #61458 for context. + """ adapter = _FakePickerAdapter() cfg_path = _setup_isolated_home(tmp_path, monkeypatch, seed_model) + runner = _make_runner(adapter) - confirmation = await _drive_picker(_make_runner(adapter), _make_event("/model")) + confirmation = await _drive_picker(runner, _make_event("/model")) assert confirmation is not None assert "gpt-5.5" in confirmation - written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) - assert isinstance(written["model"], dict), ( - "model: should be coerced to a dict, got %r" % (written["model"],) + # The session override IS applied (proves the path didn't no-op). + assert runner._session_model_overrides, "session override should be set" + assert any( + ov.get("model") == "gpt-5.5" + for ov in runner._session_model_overrides.values() ) - assert written["model"]["default"] == "gpt-5.5" - assert written["model"]["provider"] == "openrouter" - assert "base_url" not in written["model"] - assert "api_key" not in written["model"] - assert "api_mode" not in written["model"] + # But config.yaml is untouched — session-scoped by default on messaging platforms. + written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + if isinstance(written["model"], dict): + assert written["model"]["default"] == ( + "old-model" if isinstance(seed_model, dict) else "deepseek-v4-flash" + ) + else: + assert written["model"] == "deepseek-v4-flash" @pytest.mark.asyncio From 19398659ab3a9ee585c9fd8b75138ec9df9f3c9b Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 10 Jul 2026 01:31:48 +0800 Subject: [PATCH 2/2] fix(tests): update test for messaging platform session-scoped default --- .../test_model_command_flat_string_config.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/gateway/test_model_command_flat_string_config.py b/tests/gateway/test_model_command_flat_string_config.py index 8de92e9e57dd3..f84f0250c5219 100644 --- a/tests/gateway/test_model_command_flat_string_config.py +++ b/tests/gateway/test_model_command_flat_string_config.py @@ -159,11 +159,15 @@ async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path, @pytest.mark.asyncio -async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch): - """A plain ``/model X`` (no --global) now persists to config.yaml. +async def test_model_no_flag_is_session_scoped_by_default_on_messaging_platforms(tmp_path, monkeypatch): + """A plain ``/model X`` (no --global) on messaging platforms (Telegram, Discord, Slack, etc.) + defaults to session-scoped, not persisted to config.yaml. - This is the user-facing fix: switching models in one session survives - into the next without re-typing the switch every time. + This prevents accidental global config changes when users pick models via + platform menus, which cannot send --global/--session flags. The in-memory + session override IS applied (the switch worked), but config.yaml is untouched. + + See #61458 for context. """ cfg_path = _setup_isolated_home( tmp_path, @@ -171,14 +175,22 @@ async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch): {"default": "old-model", "provider": "openai-codex"}, ) - result = await _make_runner()._handle_model_command( + runner = _make_runner() + result = await runner._handle_model_command( _make_event("/model gpt-5.5") ) assert result is not None assert "gpt-5.5" in result + # The session override IS applied (proves the path didn't no-op). + assert runner._session_model_overrides, "session override should be set" + assert any( + ov.get("model") == "gpt-5.5" + for ov in runner._session_model_overrides.values() + ) + # But config.yaml is untouched — session-scoped by default on messaging platforms. written = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) - assert written["model"]["default"] == "gpt-5.5" + assert written["model"]["default"] == "old-model" @pytest.mark.asyncio