{"repo":"NousResearch/hermes-agent","public_id":"61458","display_label":"Fix /model command scope in messaging gateways","summary":"These issues and PRs address a bug where the /model command defaults to global configuration instead of session scope when used in messaging gateway contexts. The work involves correcting scoping behavior for /model and related commands across different surfaces.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67946,"merged_at":"2026-07-20T09:22:23+00:00"},"resolution":{"state":"resolved","by":"pr-67946"},"freshness":{"state":"verified","last_verified_at":"2026-07-20T09:22:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":58290,"pr_number":67946,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-20T09:22:23+00:00"},{"issue_number":61458,"pr_number":67946,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T09:22:23+00:00"}],"members":[{"kind":"issue","number":61458,"title":"[Bug]: /model command defaults to global config in messaging gateways","url":null,"state":"closed"},{"kind":"pr","number":61488,"title":"fix(gateway): default /model to session scope on messaging platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67946","number":67946},{"kind":"pr","value":"#58371","number":58371}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":67946,"title":"fix: session-scoped /model and /reasoning defaults across all surfaces","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","gateway/slash_commands.py","hermes_cli/cli_commands_mixin.py","hermes_cli/commands.py","hermes_cli/model_switch.py","tests/cli/test_reasoning_command.py","tests/gateway/test_25107_stale_base_url_api_mode.py","tests/gateway/test_model_command_flat_string_config.py","tests/gateway/test_model_picker_persist.py","tests/hermes_cli/test_model_switch_persist_default.py","tests/hermes_cli/test_reasoning_full_command.py","tests/test_tui_gateway_server.py","tui_gateway/server.py","ui-tui/src/__tests__/createSlashHandler.test.ts","ui-tui/src/app/slash/commands/session.ts","website/docs/reference/cli-commands.md","website/docs/user-guide/configuration.md"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -7151,11 +7151,15 @@ def new_session(self, silent=False, title=None):\n         self.conversation_history = []\n         self._pending_title = None\n         self._resumed = False\n+        self.reasoning_config = _parse_reasoning_config(\n+            CLI_CONFIG[\"agent\"].get(\"reasoning_effort\", \"\")\n+        )\n         _sync_process_session_id(self.session_id)\n \n         if self.agent:\n             self.agent.session_id = self.session_id\n             self.agent.session_start = self.session_start\n+            self.agent.reasoning_config = self.reasoning_config\n             self.agent.reset_session_state()\n             if hasattr(self.agent, \"_last_flushed_db_idx\"):\n                 self.agent._last_flushed_db_idx = 0","@@ -8189,16 +8193,16 @@ def _handle_model_switch(self, cmd_original: str):\n \n         Supports:\n           /model                              — show current model + usage hints\n-          /model <name>                       — switch model (persists by default)\n+          /model <name>                       — switch model (this session only)\n           /model <name> --once                — switch for the next turn only\n-          /model <name> --session             — switch for this session only\n-          /model <name> --global              — switch and persist (explicit)\n+          /model <name> --session             — switch for this session only (explicit)\n+          /model <name> --global              — switch and persist to config.yaml\n           /model <name> --provider <provider> — switch provider + model\n           /model --provider <provider>        — switch to provider, auto-detect model\n \n-        Persistence defaults to on (``model.persist_switch_by_default`` in\n-        config.yaml, default True). Use ``--session`` for this CLI session or\n-        ``--once`` for the next turn only.\n+        Persistence defaults to off (``model.persist_switch_by_default`` in\n+        config.yaml, default False — switches are session-scoped). Use\n+        ``--global`` to persist, or ``--once`` for the next turn only.\n         \"\"\"\n         from hermes_cli.model_switch import (\n             switch_model,","@@ -8225,11 +8229,14 @@ def _handle_model_switch(self, cmd_original: str):\n         if one_turn and not model_input and not explicit_provider:\n             _cprint(\"  ✗ /model --once requires a model or provider.\")\n             return\n-        # Resolve the effective persistence once: --session overrides the\n-        # config-gated default, --global forces persist, otherwise defer to\n-        # model.persist_switch_by_default (defaults to True so /model survives\n-        # across sessions).\n-        persist_global = resolve_persist_behavior(is_global_flag, is_session, is_once=one_turn)\n+        # Resolve the effective persistence once: --global forces persist,\n+        # --session/--once force session-scope, otherwise defer to\n+        # model.persist_switch_by_default (defaults to False so /model is\n+        # session-scoped unless the user opts in).\n+        persist_global = resolve_persist_behavior(\n+            is_global_flag, is_session, is_once=one_turn,\n+            explicit_provider=explicit_provider,\n+        )\n \n         # --refresh: wipe the on-disk picker cache before building the\n         # provider list. Forces a live re-fetch of every authed provider's","@@ -13157,8 +13164,8 @@ def handle_enter(event):\n             # --- /model picker modal ---\n             if self._model_picker_state:\n                 try:\n-                    # Picker selections persist by default (same default as\n-                    # /model <name>); honour model.persist_switch_by_default.\n+                    # Picker selections follow the same session-scoped default\n+                    # as /model <name>; honour model.persist_switch_by_default.\n                     from hermes_cli.model_switch import resolve_persist_behavior\n \n                     self._handle_model_picker_selection("]},{"file":"gateway/slash_commands.py","hunks":["@@ -1437,10 +1437,10 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n \n         Supports:\n           /model                              — interactive picker (Telegram/Discord) or text list\n-          /model <name>                       — switch model (persists by default)\n+          /model <name>                       — switch model (this session only)\n           /model <name> --once                — switch for the next turn only\n-          /model <name> --session             — switch for this session only\n-          /model <name> --global              — switch and persist (explicit)\n+          /model <name> --session             — switch for this session only (explicit)\n+          /model <name> --global              — switch and persist to config.yaml\n           /model <name> --provider <provider> — switch provider + model\n           /model --provider <provider>        — switch to provider, auto-detect model\n         \"\"\"","@@ -1478,6 +1478,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n             is_global_flag,\n             is_session,\n             is_once=one_turn,\n+            explicit_provider=explicit_provider,\n         )\n \n         # --refresh: bust the disk cache so the picker shows live data."]},{"file":"hermes_cli/cli_commands_mixin.py","hunks":["@@ -2515,13 +2515,14 @@ def _handle_reasoning_command(self, cmd: str):\n \n         Usage:\n             /reasoning              Show current effort level and display state\n-            /reasoning <level>      Set effort (none, minimal, low, medium, high, xhigh, max, ultra)\n+            /reasoning <level>      Set effort for this session only (none, minimal, low, medium, high, xhigh, max, ultra)\n+            /reasoning <level> --global  Persist reasoning effort to config.yaml\n             /reasoning show|on      Show model thinking/reasoning in output\n             /reasoning hide|off     Hide model thinking/reasoning from output\n             /reasoning full         Show complete thinking (no 10-line clamp)\n             /reasoning clamp        Collapse long thinking to the first 10 lines\n         \"\"\"\n-        from cli import _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value\n+        from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value\n         parts = cmd.strip().split(maxsplit=1)\n \n         if len(parts) < 2:","@@ -2537,10 +2538,20 @@ def _handle_reasoning_command(self, cmd: str):\n             full_state = \"full\" if getattr(self, \"reasoning_full\", False) else \"clamped to 10 lines\"\n             _cprint(f\"  {_ACCENT}Reasoning effort:  {level}{_RST}\")\n             _cprint(f\"  {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}\")\n-            _cprint(f\"  {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp>{_RST}\")\n+            _cprint(f\"  {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra|show|hide|full|clamp> [--global]{_RST}\")\n             return\n \n         arg = parts[1].strip().lower()\n+        arg_tokens = arg.split()\n+        # Session scope is the default; --global opts into persisting to\n+        # config.yaml. --session is accepted as an explicit no-op for parity\n+        # with /model and the gateway /reasoning handler.\n+        explicit_global = \"--global\" in arg_tokens\n+        if explicit_global or \"--session\" in arg_tokens:\n+            arg = \" \".join(\n+                token for token in arg_tokens\n+                if token not in (\"--global\", \"--session\")\n+            )\n \n         # Display toggle\n         if arg in {\"show\", \"on\"}:","@@ -2580,15 +2591,23 @@ def _handle_reasoning_command(self, cmd: str):\n             _cprint(f\"  {_DIM}(._.) Unknown argument: {arg}{_RST}\")\n             _cprint(f\"  {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}\")\n             _cprint(f\"  {_DIM}Display:      show, hide{_RST}\")\n+            _cprint(f\"  {_DIM}Scope:        session-scoped by default, --global to persist{_RST}\")\n             return\n \n         self.reasoning_config = parsed\n         self.agent = None  # Force agent re-init with new reasoning config\n \n-        if save_config_value(\"agent.reasoning_effort\", arg):\n+        if explicit_global and save_config_value(\"agent.reasoning_effort\", arg):\n+            agent_cfg = CLI_CONFIG.get(\"agent\")\n+            if not isinstance(agent_cfg, dict):\n+                agent_cfg = {}\n+                CLI_CONFIG[\"agent\"] = agent_cfg\n+            agent_cfg[\"reasoning_effort\"] = arg\n             _cprint(f\"  {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}\")\n+        elif explicit_global:\n+            _cprint(f\"  {_ACCENT}✓ Reasoning effort set to '{arg}' (session only; config save failed){_RST}\")\n         else:\n-            _cprint(f\"  {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}\")\n+            _cprint(f\"  {_ACCENT}✓ Reasoning effort set to '{arg}' (this session — use --global to persist){_RST}\")\n \n     def _handle_busy_command(self, cmd: str):\n         \"\"\"Handle /busy — control what Enter does while Hermes is working."]},{"file":"hermes_cli/commands.py","hunks":["@@ -131,7 +131,7 @@ class CommandDef:\n     # Configuration\n     CommandDef(\"config\", \"Show current configuration\", \"Configuration\",\n                cli_only=True),\n-    CommandDef(\"model\", \"Switch model (persists by default)\", \"Configuration\",\n+    CommandDef(\"model\", \"Switch model (session-scoped; --global to persist)\", \"Configuration\",\n                args_hint=\"[model] [--provider name] [--global|--session] [--refresh]\"),\n     CommandDef(\"codex-runtime\", \"Toggle codex app-server runtime for OpenAI/Codex models\",\n                \"Configuration\", aliases=(\"codex_runtime\",),","@@ -153,8 +153,8 @@ class CommandDef:\n     CommandDef(\"yolo\", \"Toggle YOLO mode (skip all dangerous command approvals)\",\n                \"Configuration\"),\n     CommandDef(\"reasoning\", \"Manage reasoning effort and display\", \"Configuration\",\n-               args_hint=\"[level|show|hide|full|clamp]\",\n-               subcommands=(\"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\", \"ultra\", \"show\", \"hide\", \"on\", \"off\", \"full\", \"clamp\")),\n+               args_hint=\"[level|show|hide|full|clamp] [--global]\",\n+               subcommands=(\"none\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\", \"ultra\", \"show\", \"hide\", \"on\", \"off\", \"full\", \"clamp\", \"--global\")),\n     CommandDef(\"fast\", \"Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)\", \"Configuration\",\n                args_hint=\"[normal|fast|status]\",\n                subcommands=(\"normal\", \"fast\", \"status\", \"on\", \"off\")),"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -552,7 +552,12 @@ def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:\n     )\n \n \n-def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool = False) -> bool:\n+def resolve_persist_behavior(\n+    is_global: bool,\n+    is_session: bool,\n+    is_once: bool = False,\n+    explicit_provider: str = \"\",\n+) -> bool:\n     \"\"\"Decide whether a ``/model`` switch should persist to ``config.yaml``.\n \n     Resolution order:","@@ -560,13 +565,19 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =\n     1. ``--once`` explicitly opts out → ``False`` (next turn only).\n     2. ``--session`` explicitly opts out → ``False`` (this session only).\n     3. ``--global`` explicitly opts in → ``True``.\n-    4. Otherwise defer to ``model.persist_switch_by_default`` in\n-       ``config.yaml`` (defaults to ``True``, so a plain ``/model <name>``\n-       survives across sessions — the behavior users expect).\n+    4. ``--provider`` given without an explicit persist flag → ``False``\n+       (session only).  Provider switches are typically exploratory — the\n+       user is trying a different backend for this conversation, not\n+       reconfiguring the default.  ``--global`` can still force persist.\n+    5. Otherwise defer to ``model.persist_switch_by_default`` in\n+       ``config.yaml`` (defaults to ``False``: a plain ``/model <name>``\n+       affects only the current session).  Users who want the old\n+       persist-by-default behavior can set the key to ``true``; a one-off\n+       ``--global`` always persists.\n \n     The config read is defensive: on a fresh install ``model`` may be a\n     flat string rather than a dict, in which case the built-in default\n-    (``True``) applies.\n+    (``False``) applies.\n     \"\"\"\n     if is_once:\n         return False","@@ -574,15 +585,17 @@ def resolve_persist_behavior(is_global: bool, is_session: bool, is_once: bool =\n         return False\n     if is_global:\n         return True\n+    if explicit_provider:\n+        return False\n     try:\n         from hermes_cli.config import load_config\n \n         model_cfg = load_config().get(\"model\")\n         if isinstance(model_cfg, dict):\n-            return bool(model_cfg.get(\"persist_switch_by_default\", True))\n+            return bool(model_cfg.get(\"persist_switch_by_default\", False))\n     except Exception:\n         pass\n-    return True\n+    return False\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/cli/test_reasoning_command.py","hunks":["@@ -154,6 +154,72 @@ def test_status_with_explicit_level(self):\n         level = rc.get(\"effort\", \"medium\")\n         self.assertEqual(level, \"xhigh\")\n \n+    def test_effort_defaults_to_session_only(self):\n+        \"\"\"Plain /reasoning <level> is session-scoped — no config write.\"\"\"\n+        from hermes_cli.cli_commands_mixin import CLICommandsMixin\n+\n+        stub = self._make_cli(reasoning_config={\"enabled\": True, \"effort\": \"medium\"})\n+        with patch(\"cli.save_config_value\") as save_config, patch(\"cli._cprint\"):\n+            CLICommandsMixin._handle_reasoning_command(stub, \"/reasoning high\")\n+\n+        save_config.assert_not_called()\n+        self.assertEqual(stub.reasoning_config, {\"enabled\": True, \"effort\": \"high\"})\n+        self.assertIsNone(stub.agent)\n+\n+    def test_effort_global_flag_persists_config(self):\n+        \"\"\"--global opts into persisting the effort to config.yaml.\"\"\"\n+        from cli import CLI_CONFIG\n+        from hermes_cli.cli_commands_mixin import CLICommandsMixin\n+\n+        stub = self._make_cli(reasoning_config={\"enabled\": True, \"effort\": \"medium\"})\n+        with patch.dict(CLI_CONFIG.setdefault(\"agent\", {}), {\"reasoning_effort\": \"medium\"}), \\\n+             patch(\"cli.save_config_value\", return_value=True) as save_config, \\\n+             patch(\"cli._cprint\"):\n+            CLICommandsMixin._handle_reasoning_command(stub, \"/reasoning high --global\")\n+            self.assertEqual(CLI_CONFIG[\"agent\"][\"reasoning_effort\"], \"high\")\n+\n+        save_config.assert_called_once_with(\"agent.reasoning_effort\", \"high\")\n+        self.assertEqual(stub.reasoning_config, {\"enabled\": True, \"effort\": \"high\"})\n+        self.assertIsNone(stub.agent)\n+\n+    def test_effort_session_flag_does_not_persist_config(self):\n+        \"\"\"--session (explicit no-op alias for the default) stays session-only.\"\"\"\n+        from hermes_cli.cli_commands_mixin import CLICommandsMixin\n+\n+        stub = self._make_cli(reasoning_config={\"enabled\": True, \"effort\": \"medium\"})\n+        with patch(\"cli.save_config_value\") as save_config, patch(\"cli._cprint\"):\n+            CLICommandsMixin._handle_reasoning_command(stub, \"/reasoning high --session\")\n+\n+        save_config.assert_not_called()\n+        self.assertEqual(stub.reasoning_config, {\"enabled\": True, \"effort\": \"high\"})\n+        self.assertIsNone(stub.agent)\n+\n+    def test_new_session_clears_session_reasoning_override(self):\n+        \"\"\"/new and /clear must not carry a session-only effort override forward.\"\"\"\n+        from cli import CLI_CONFIG, HermesCLI\n+\n+        agent = SimpleNamespace(\n+            reasoning_config={\"enabled\": True, \"effort\": \"high\"},\n+            reset_session_state=MagicMock(),\n+        )\n+        stub = SimpleNamespace(\n+            agent=agent,\n+            conversation_history=[],\n+            session_id=\"old-session\",\n+            _session_db=None,\n+            _pending_title=None,\n+            _resumed=False,\n+            reasoning_config={\"enabled\": True, \"effort\": \"high\"},\n+            _notify_session_boundary=MagicMock(),\n+        )\n+\n+        with patch.dict(CLI_CONFIG.setdefault(\"agent\", {}), {\"reasoning_effort\": \"medium\"}):\n+            HermesCLI.new_session(stub, silent=True)\n+\n+        self.assertEqual(stub.reasoning_config, {\"enabled\": True, \"effort\": \"medium\"})\n+        self.assertEqual(agent.reasoning_config, {\"enabled\": True, \"effort\": \"medium\"})\n+        agent.reset_session_state.assert_called_once()\n+\n \n # ---------------------------------------------------------------------------\n # Reasoning extraction and result dict"]},{"file":"tests/gateway/test_25107_stale_base_url_api_mode.py","hunks":["@@ -186,7 +186,7 @@ async def test_picker_tap_to_custom_clears_stale_base_url_and_api_mode(tmp_path,\n     adapter = _FakePickerAdapter()\n     cfg_path = _setup_isolated_home(tmp_path, monkeypatch, dict(_STALE_MODEL_CFG))\n \n-    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model\"))\n+    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model --global\"))\n \n     assert confirmation is not None\n     written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))"]},{"file":"tests/gateway/test_model_command_flat_string_config.py","hunks":["@@ -159,11 +159,11 @@ async def test_model_global_persists_when_config_has_proper_dict_model(tmp_path,\n \n \n @pytest.mark.asyncio\n-async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):\n-    \"\"\"A plain ``/model X`` (no --global) now persists to config.yaml.\n+async def test_model_no_flag_is_session_scoped_by_default(tmp_path, monkeypatch):\n+    \"\"\"A plain ``/model X`` (no --global) does NOT persist to config.yaml.\n \n-    This is the user-facing fix: switching models in one session survives\n-    into the next without re-typing the switch every time.\n+    This is the user-facing fix: switches are session-scoped unless the user\n+    opts in with ``--global`` or sets ``model.persist_switch_by_default: true``.\n     \"\"\"\n     cfg_path = _setup_isolated_home(\n         tmp_path,","@@ -178,7 +178,7 @@ async def test_model_no_flag_persists_by_default(tmp_path, monkeypatch):\n     assert result is not None\n     assert \"gpt-5.5\" in result\n     written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n-    assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n+    assert written[\"model\"][\"default\"] == \"old-model\"\n \n \n @pytest.mark.asyncio"]},{"file":"tests/gateway/test_model_picker_persist.py","hunks":["@@ -8,9 +8,10 @@\n *typing* the same model persisted — a contradiction the same PR introduced.\n \n After the fix (#49176), the picker callback honors the resolved\n-``persist_global`` (defaults to ``True``, still respects ``--session``) and runs\n-the same read-modify-write block the text path uses, so a tapped model survives\n-across sessions like a typed one.\n+``persist_global`` and runs the same read-modify-write block the text path\n+uses, so a tapped model behaves exactly like a typed one.  Since the\n+session-scope-by-default change, both default to session-only and persist\n+only with ``--global`` (or ``model.persist_switch_by_default: true``).\n \n These tests drive the real ``_handle_model_command`` with a fake picker-capable\n adapter that captures the ``on_model_selected`` callback, then invoke that","@@ -176,14 +177,14 @@ async def _drive_picker(runner, event):\n     ],\n     ids=[\"nested-dict\", \"flat-string\"],\n )\n-async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model):\n-    \"\"\"Tapping a model in the picker (bare /model) persists to config.yaml,\n-    matching the typed ``/model`` default — this is the #49176 fix. The written\n-    ``model:`` must always end up a nested dict regardless of the seed shape.\"\"\"\n+async def test_picker_tap_global_flag_persists(tmp_path, monkeypatch, seed_model):\n+    \"\"\"Tapping a model in a ``/model --global`` picker persists to config.yaml,\n+    matching the typed ``/model --global`` path. The written ``model:`` must\n+    always end up a nested dict regardless of the seed shape.\"\"\"\n     adapter = _FakePickerAdapter()\n     cfg_path = _setup_isolated_home(tmp_path, monkeypatch, seed_model)\n \n-    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model\"))\n+    confirmation = await _drive_picker(_make_runner(adapter), _make_event(\"/model --global\"))\n \n     assert confirmation is not None\n     assert \"gpt-5.5\" in confirmation","@@ -198,6 +199,34 @@ async def test_picker_tap_persists_by_default(tmp_path, monkeypatch, seed_model)\n     assert \"api_mode\" not in written[\"model\"]\n \n \n[redacted:email]\n+async def test_picker_tap_is_session_scoped_by_default(tmp_path, monkeypatch):\n+    \"\"\"Tapping a model in a bare ``/model`` picker applies an in-memory session\n+    override and does NOT touch config.yaml — switches are session-scoped\n+    unless the user opts in with ``--global`` (or sets\n+    ``model.persist_switch_by_default: true``).\"\"\"\n+    adapter = _FakePickerAdapter()\n+    cfg_path = _setup_isolated_home(\n+        tmp_path, monkeypatch, {\"default\": \"old-model\", \"provider\": \"openrouter\"}\n+    )\n+    runner = _make_runner(adapter)\n+\n+    confirmation = await _drive_picker(runner, _make_event(\"/model\"))\n+\n+    assert confirmation is not None\n+    assert \"gpt-5.5\" in confirmation\n+    # The session override IS applied in-memory (the switch worked).\n+    assert runner._session_model_overrides, \"session override should be set\"\n+    assert any(\n+        ov.get(\"model\") == \"gpt-5.5\"\n+        for ov in runner._session_model_overrides.values()\n+    )\n+    # But config.yaml is untouched — session-scoped by default.\n+    written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n+    assert written[\"model\"][\"default\"] == \"old-model\"\n+    assert written[\"model\"][\"provider\"] == \"openrouter\"\n+\n+\n @pytest.mark.asyncio\n async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch):\n     \"\"\"``/model --session`` then a picker tap stays in-memory only — config"]},{"file":"tests/hermes_cli/test_model_switch_persist_default.py","hunks":["@@ -1,11 +1,12 @@\n-\"\"\"Tests for persist-by-default model switching.\n+\"\"\"Tests for session-scoped-by-default model switching.\n \n Covers:\n - ``parse_model_flags`` recognises ``--session`` (and keeps ``--global``).\n - ``resolve_persist_behavior`` applies the config-gated default and the\n   ``--session`` / ``--global`` overrides.\n-- The default (no flags) persists, which is the user-facing fix: a plain\n-  ``/model <name>`` survives across sessions.\n+- The default (no flags) is session-only, which is the user-facing fix: a\n+  plain ``/model <name>`` affects only the current session unless the user\n+  passes ``--global`` or sets ``model.persist_switch_by_default: true``.\n \"\"\"\n \n from unittest.mock import patch","@@ -73,10 +74,10 @@ def test_global_flag_always_persists(self):\n         with _config({\"model\": {\"persist_switch_by_default\": False}}):\n             assert resolve_persist_behavior(True, False) is True\n \n-    def test_default_persists_when_config_missing(self):\n-        # No model section at all → built-in default (True).\n+    def test_default_session_only_when_config_missing(self):\n+        # No model section at all → built-in default (False, session-only).\n         with _config({}):\n-            assert resolve_persist_behavior(False, False) is True\n+            assert resolve_persist_behavior(False, False) is False\n \n     def test_default_persists_when_key_true(self):\n         with _config({\"model\": {\"persist_switch_by_default\": True}}):","@@ -87,15 +88,35 @@ def test_default_session_only_when_key_false(self):\n             assert resolve_persist_behavior(False, False) is False\n \n     def test_default_when_model_is_flat_string(self):\n-        # Fresh install: ``model: \"\"`` (not a dict) → built-in default True.\n+        # Fresh install: ``model: \"\"`` (not a dict) → built-in default False.\n         with _config({\"model\": \"\"}):\n-            assert resolve_persist_behavior(False, False) is True\n+            assert resolve_persist_behavior(False, False) is False\n \n     def test_session_overrides_global_when_both_set(self):\n         # --session is the explicit opt-out and wins over --global.\n         with _config({\"model\": {\"persist_switch_by_default\": True}}):\n             assert resolve_persist_behavior(True, True) is False\n \n+    def test_provider_flag_defaults_to_session_only(self):\n+        # --provider without --global/--session → session only.\n+        with _config({\"model\": {\"persist_switch_by_default\": True}}):\n+            assert resolve_persist_behavior(False, False, explicit_provider=\"anthropic\") is False\n+\n+    def test_provider_with_global_still_persists(self):\n+        # --provider + --global → persists.\n+        with _config({\"model\": {\"persist_switch_by_default\": False}}):\n+            assert resolve_persist_behavior(True, False, explicit_provider=\"anthropic\") is True\n+\n+    def test_provider_with_session_still_session_only(self):\n+        # --provider + --session → session only.\n+        with _config({\"model\": {\"persist_switch_by_default\": True}}):\n+            assert resolve_persist_behavior(False, True, explicit_provider=\"anthropic\") is False\n+\n+    def test_no_provider_uses_config_default(self):\n+        # No --provider → respects config default (True).\n+        with _config({\"model\": {\"persist_switch_by_default\": True}}):\n+            assert resolve_persist_behavior(False, False, explicit_provider=\"\") is True\n+\n \n # ---------------------------------------------------------------------------\n # helper"]},{"file":"tests/hermes_cli/test_reasoning_full_command.py","hunks":["@@ -55,7 +55,7 @@ def test_reasoning_full_sets_and_persists(tmp_path, monkeypatch):\n     assert saved[\"display\"][\"reasoning_full\"] is True\n \n \n-def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch):\n+def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch, capsys):\n     hh = _seed_config(tmp_path, monkeypatch)\n     s = _Stub()\n     s.reasoning_full = True","@@ -64,6 +64,7 @@ def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch):\n     assert s.reasoning_full is False\n     saved = yaml.safe_load((hh / \"config.yaml\").read_text())\n     assert saved[\"display\"][\"reasoning_full\"] is False\n+    assert \"Unknown argument\" not in capsys.readouterr().out\n \n \n def test_reasoning_all_is_alias_for_full(tmp_path, monkeypatch):"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -4649,8 +4649,18 @@ def test_complete_slash_details_args():\n     assert any(item[\"text\"] == \"expanded\" for item in resp_mode[\"result\"][\"items\"])\n \n \n+def test_complete_slash_reasoning_includes_current_efforts_and_global_scope():\n+    resp = server.handle_request(\n+        {\"id\": \"1\", \"method\": \"complete.slash\", \"params\": {\"text\": \"/reasoning \"}}\n+    )\n+\n+    values = {item[\"text\"] for item in resp[\"result\"][\"items\"]}\n+    assert {\"max\", \"ultra\", \"--global\"} <= values\n+\n+\n def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):\n     monkeypatch.setattr(server, \"_hermes_home\", tmp_path)\n+    (tmp_path / \"config.yaml\").write_text(\"agent:\\n  reasoning_effort: medium\\n\", encoding=\"utf-8\")\n     agent = types.SimpleNamespace(reasoning_config=None)\n     server._sessions[\"sid\"] = _session(agent=agent)\n ","@@ -4658,11 +4668,42 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat\n         {\n             \"id\": \"1\",\n             \"method\": \"config.set\",\n-            \"params\": {\"session_id\": \"sid\", \"key\": \"reasoning\", \"value\": \"low\"},\n+            \"params\": {\n+                \"session_id\": \"sid\",\n+                \"key\": \"reasoning\",\n+                \"value\": \"low\",\n+            },\n         }\n     )\n     assert resp_effort[\"result\"][\"value\"] == \"low\"\n     assert agent.reasoning_config == {\"enabled\": True, \"effort\": \"low\"}\n+    assert server._sessions[\"sid\"][\"create_reasoning_override\"] == {\"enabled\": True, \"effort\": \"low\"}\n+    assert server._load_cfg()[\"agent\"][\"reasoning_effort\"] == \"medium\"\n+\n+    resp_status = server.handle_request(\n+        {\n+            \"id\": \"5\",\n+            \"method\": \"config.get\",\n+            \"params\": {\"session_id\": \"sid\", \"key\": \"reasoning\"},\n+        }\n+    )\n+    assert resp_status[\"result\"][\"value\"] == \"low\"\n+\n+    resp_global_status = server.handle_request(\n+        {\"id\": \"6\", \"method\": \"config.get\", \"params\": {\"key\": \"reasoning\"}}\n+    )\n+    assert resp_global_status[\"result\"][\"value\"] == \"medium\"\n+\n+    del server._sessions[\"sid\"][\"create_reasoning_override\"]\n+    agent.reasoning_config = {\"enabled\": True, \"effort\": \"high\"}\n+    resp_agent_status = server.handle_request(\n+        {\n+            \"id\": \"7\",\n+            \"method\": \"config.get\",\n+            \"params\": {\"session_id\": \"sid\", \"key\": \"reasoning\"},\n+        }\n+    )\n+    assert resp_agent_status[\"result\"][\"value\"] == \"high\"\n \n     resp_show = server.handle_request(\n         {","@@ -4714,6 +4755,36 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat\n     assert cfg_clamp[\"display\"][\"sections\"][\"thinking\"] == \"collapsed\"\n \n \n+def test_config_set_reasoning_global_scope_clears_session_override(tmp_path, monkeypatch):\n+    monkeypatch.setattr(server, \"_hermes_home\", tmp_path)\n+    (tmp_path / \"config.yaml\").write_text(\"agent:\\n  reasoning_effort: medium\\n\", encoding=\"utf-8\")\n+    agent = types.SimpleNamespace(reasoning_config=None)\n+    server._sessions[\"sid\"] = _session(agent=agent)\n+    server._sessions[\"sid\"][\"create_reasoning_override\"] = {\"enabled\": True, \"effort\": \"low\"}\n+\n+    resp = server.handle_request(\n+        {\n+            \"id\": \"1\",\n+            \"method\": \"config.set\",\n+            \"params\": {\n+                \"session_id\": \"sid\",\n+                \"key\": \"reasoning\",\n+                \"value\": \"high\",\n+                \"scope\": \"global\",\n+            },\n+        }\n+    )\n+\n+    assert resp[\"result\"][\"value\"] == \"high\"\n+    assert server._load_cfg()[\"agent\"][\"reasoning_effort\"] == \"high\"\n+    assert \"create_reasoning_override\" not in server._sessions[\"sid\"]\n+\n+    status = server.handle_request(\n+        {\"id\": \"2\", \"method\": \"config.get\", \"params\": {\"session_id\": \"sid\", \"key\": \"reasoning\"}}\n+    )\n+    assert status[\"result\"][\"value\"] == \"high\"\n+\n+\n def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch):\n     monkeypatch.setattr(server, \"_hermes_home\", tmp_path)\n     agent = types.SimpleNamespace(verbose_logging=False)"]},{"file":"tui_gateway/server.py","hunks":["@@ -3221,6 +3221,7 @@ def _apply_model_switch(\n             is_global_flag,\n             is_session,\n             is_once=one_turn,\n+            explicit_provider=explicit_provider,\n         )\n     )\n     if not model_input:","@@ -11554,6 +11555,8 @@ def _resolve_toggle(current: bool) -> bool:\n             from hermes_constants import parse_reasoning_effort\n \n             arg = str(value or \"\").strip().lower()\n+            scope = str(params.get(\"scope\") or \"\").strip().lower()\n+            global_scope = scope == \"global\"\n             if arg in {\"show\", \"on\"}:\n                 cfg = _load_cfg()\n                 display = (","@@ -11633,23 +11636,25 @@ def _resolve_toggle(current: bool) -> bool:\n             parsed = parse_reasoning_effort(arg)\n             if parsed is None:\n                 return _err(rid, 4002, f\"unknown reasoning value: {value}\")\n-            if session is not None:\n+            if global_scope or session is None:\n+                _write_config_key(\"agent.reasoning_effort\", arg)\n+                if session is not None:\n+                    session.pop(\"create_reasoning_override\", None)\n+            else:\n                 # Session-scoped, like the messaging gateway's `/reasoning\n                 # <level>` (global persistence is `--global` / Settings →\n                 # Model territory). Writing config.yaml here let every\n                 # desktop model-menu selection rewrite the user's global\n                 # agent.reasoning_effort to the preset default.\n                 session[\"create_reasoning_override\"] = parsed\n-                if session.get(\"agent\") is not None:\n-                    session[\"agent\"].reasoning_config = parsed\n-                    _persist_live_session_runtime(session)\n-                    _emit(\n-                        \"session.info\",\n-                        params.get(\"session_id\", \"\"),\n-                        _session_info(session[\"agent\"], session),\n-                    )\n-            else:\n-                _write_config_key(\"agent.reasoning_effort\", arg)\n+            if session and session.get(\"agent\") is not None:\n+                session[\"agent\"].reasoning_config = parsed\n+                _persist_live_session_runtime(session)\n+                _emit(\n+                    \"session.info\",\n+                    params.get(\"session_id\", \"\"),\n+                    _session_info(session[\"agent\"], session),\n+                )\n             return _ok(rid, {\"key\": key, \"value\": arg})\n         except Exception as e:\n             return _err(rid, 5001, str(e))","@@ -12344,19 +12349,23 @@ def _(rid, params: dict) -> dict:\n         )\n     if key == \"reasoning\":\n         cfg = _load_cfg()\n-        effort = \"\"\n-        # Prefer the session's live value — `config.set reasoning` is\n-        # session-scoped, so the global key may not reflect this chat.\n         session = _sessions.get(params.get(\"session_id\", \"\"))\n-        live = getattr((session or {}).get(\"agent\"), \"reasoning_config\", None)\n-        if live is None and session is not None:\n-            live = session.get(\"create_reasoning_override\")\n-        if isinstance(live, dict):\n-            if live.get(\"enabled\") is False:\n+        reasoning_config = None\n+        if session is not None:\n+            if isinstance(session.get(\"create_reasoning_override\"), dict):\n+                reasoning_config = session.get(\"create_reasoning_override\")\n+            else:\n+                agent = session.get(\"agent\")\n+                agent_reasoning = getattr(agent, \"reasoning_config\", None)\n+                if isinstance(agent_reasoning, dict):\n+                    reasoning_config = agent_reasoning\n+\n+        if isinstance(reasoning_config, dict):\n+            if reasoning_config.get(\"enabled\") is False:\n                 effort = \"none\"\n             else:\n-                effort = str(live.get(\"effort\", \"\") or \"\")\n-        if not effort:\n+                effort = str(reasoning_config.get(\"effort\") or \"medium\")\n+        else:\n             raw_effort = (cfg.get(\"agent\") or {}).get(\"reasoning_effort\", \"\")\n             if raw_effort is False:\n                 # YAML `reasoning_effort: false`/`off`/`no` — thinking"]},{"file":"ui-tui/src/__tests__/createSlashHandler.test.ts","hunks":["@@ -267,6 +267,55 @@ describe('createSlashHandler', () => {\n     })\n   })\n \n+  it('reads /reasoning status for the active session', () => {\n+    patchUiState({ sid: 'sid-abc' })\n+    const ctx = buildCtx()\n+\n+    expect(createSlashHandler(ctx)('/reasoning')).toBe(true)\n+    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', {\n+      key: 'reasoning',\n+      session_id: 'sid-abc'\n+    })\n+  })\n+\n+  it.each(['low', 'max', 'ultra'])('sends plain /reasoning %s without a scope (session default)', effort => {\n+    patchUiState({ sid: 'sid-abc' })\n+    const ctx = buildCtx()\n+\n+    expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true)\n+    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {\n+      key: 'reasoning',\n+      session_id: 'sid-abc',\n+      value: effort\n+    })\n+  })\n+\n+  it('sends /reasoning <level> --global as global config.set', () => {\n+    patchUiState({ sid: 'sid-abc' })\n+    const ctx = buildCtx()\n+\n+    expect(createSlashHandler(ctx)('/reasoning high --global')).toBe(true)\n+    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {\n+      key: 'reasoning',\n+      scope: 'global',\n+      session_id: 'sid-abc',\n+      value: 'high'\n+    })\n+  })\n+\n+  it('strips /reasoning session flags before config.set', () => {\n+    patchUiState({ sid: 'sid-abc' })\n+    const ctx = buildCtx()\n+\n+    expect(createSlashHandler(ctx)('/reasoning low --session')).toBe(true)\n+    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {\n+      key: 'reasoning',\n+      scope: 'session',\n+      session_id: 'sid-abc',\n+      value: 'low'\n+    })\n+  })\n+\n   it('opens the skills hub locally for bare /skills', () => {\n     const ctx = buildCtx()\n "]},{"file":"ui-tui/src/app/slash/commands/session.ts","hunks":["@@ -23,6 +23,8 @@ import type { SlashCommand } from '../types.js'\n const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'\n \n const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\\\s)${TUI_SESSION_MODEL_FLAG}(?:\\\\s|$)`)\n+const REASONING_SESSION_FLAGS = new Set(['--session'])\n+const REASONING_GLOBAL_FLAGS = new Set(['--global'])\n \n const modelValueForConfigSet = (arg: string) => {\n   const trimmed = arg.trim()","@@ -38,6 +40,37 @@ const modelValueForConfigSet = (arg: string) => {\n   return trimmed\n }\n \n+const reasoningConfigPayload = (arg: string, sid: string) => {\n+  const parts = arg.trim().split(/\\s+/).filter(Boolean)\n+  let scope = ''\n+  const valueParts: string[] = []\n+\n+  for (const part of parts) {\n+    const flag = part.toLowerCase()\n+    if (REASONING_GLOBAL_FLAGS.has(flag)) {\n+      scope = 'global'\n+      continue\n+    }\n+    if (REASONING_SESSION_FLAGS.has(flag)) {\n+      // Session scope is the default; accept the flag for parity with /model.\n+      if (!scope) {\n+        scope = 'session'\n+      }\n+      continue\n+    }\n+    valueParts.push(part)\n+  }\n+\n+  const value = valueParts.join(' ')\n+\n+  return {\n+    key: 'reasoning',\n+    session_id: sid,\n+    value,\n+    ...(scope ? { scope } : {})\n+  }\n+}\n+\n export const sessionCommands: SlashCommand[] = [\n   {\n     aliases: ['bg', 'btw'],","@@ -445,7 +478,7 @@ export const sessionCommands: SlashCommand[] = [\n     run: (arg, ctx) => {\n       if (!arg) {\n         return ctx.gateway\n-          .rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning' })\n+          .rpc<ConfigGetValueResponse>('config.get', { key: 'reasoning', session_id: ctx.sid })\n           .then(\n             ctx.guarded<ConfigGetValueResponse>(\n               r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)","@@ -453,29 +486,31 @@ export const sessionCommands: SlashCommand[] = [\n           )\n       }\n \n-      ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }).then(\n-        ctx.guarded<ConfigSetResponse>(r => {\n-          if (!r.value) {\n-            return\n-          }\n+      ctx.gateway\n+        .rpc<ConfigSetResponse>('config.set', reasoningConfigPayload(arg, ctx.sid ?? ''))\n+        .then(\n+          ctx.guarded<ConfigSetResponse>(r => {\n+            if (!r.value) {\n+              return\n+            }\n \n-          if (r.value === 'hide') {\n-            patchUiState(state => ({\n-              ...state,\n-              sections: { ...state.sections, thinking: 'hidden' },\n-              showReasoning: false\n-            }))\n-          } else if (r.value === 'show') {\n-            patchUiState(state => ({\n-              ...state,\n-              sections: { ...state.sections, thinking: 'expanded' },\n-              showReasoning: true\n-            }))\n-          }\n+            if (r.value === 'hide') {\n+              patchUiState(state => ({\n+                ...state,\n+                sections: { ...state.sections, thinking: 'hidden' },\n+                showReasoning: false\n+              }))\n+            } else if (r.value === 'show') {\n+              patchUiState(state => ({\n+                ...state,\n+                sections: { ...state.sections, thinking: 'expanded' },\n+                showReasoning: true\n+              }))\n+            }\n \n-          ctx.transcript.sys(`reasoning: ${r.value}`)\n-        })\n-      )\n+            ctx.transcript.sys(`reasoning: ${r.value}`)\n+          })\n+        )\n     }\n   },\n "]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -199,7 +199,7 @@ Switch between already-configured models without leaving a session:\n /model openrouter:anthropic/claude-sonnet-4  # Switch back to cloud\n ```\n \n-By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml`:\n+By default, `/model` changes apply **to the current session only**. Add `--global` to persist the change to `config.yaml` (or set `model.persist_switch_by_default: true` to make every switch persist):\n \n ```\n /model claude-sonnet-4 --global     # Switch and save as new default","@@ -209,7 +209,7 @@ By default, `/model` changes apply **to the current session only**. Add `--globa\n If you've only configured OpenRouter, `/model` will only show OpenRouter models. To add another provider (Anthropic, DeepSeek, Copilot, etc.), exit your session and run `hermes model` from the terminal.\n :::\n \n-Provider and base URL changes are persisted to `config.yaml` automatically. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.\n+On a `--global` switch, provider and base URL changes are persisted to `config.yaml` alongside the model. When switching away from a custom endpoint, the stale base URL is cleared to prevent it leaking into other providers.\n \n ## `hermes gateway`\n "]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -1323,13 +1323,17 @@ native Anthropic provider already controls effort directly and is unaffected.\n You can also change the reasoning effort at runtime with the `/reasoning` command:\n \n ```\n-/reasoning           # Show current effort level and display state\n-/reasoning high      # Set reasoning effort to high\n-/reasoning none      # Disable reasoning\n-/reasoning show      # Show model thinking above each response\n-/reasoning hide      # Hide model thinking\n+/reasoning                # Show current effort level and display state\n+/reasoning high           # Set reasoning effort to high (this session only)\n+/reasoning high --global  # Set effort and persist to config.yaml\n+/reasoning none           # Disable reasoning (this session only)\n+/reasoning show           # Show model thinking above each response\n+/reasoning hide           # Hide model thinking\n ```\n \n+Effort changes are session-scoped by default; add `--global` to save the\n+new level as your `agent.reasoning_effort` default.\n+\n #### Per-Model Reasoning Overrides\n \n You can set different reasoning effort levels for different models. This is useful when you want high reasoning for complex models but medium for faster ones:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}