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