Files
hermes-agent/tests/tui_gateway/test_model_switch_reasoning_flag.py
T
teknium1 2c0bec33f9 feat(model-pickers): reasoning effort selection on every model picker
The Desktop composer got a reasoning-effort pill this morning; every other place a
model is picked still left the effort to a separate command (`/reasoning`) or a
hand edit of config.yaml. `hermes model` had one effort step for Copilot only, and
its auxiliary-model menu had none at all even though every aux block already reads
`auxiliary.<task>.reasoning_effort`.

One request now carries a model pick AND its effort on every surface:

- `hermes_cli/model_switch.py`: the single `/model` parser accepts `--reasoning
  <level>` (validated against `parse_reasoning_effort`; unknown level ->
  `MODEL_SWITCH_ERR_BAD_REASONING`; Unicode-dash normalized like the other flags).
  `ModelSwitchRequest.reasoning_effort` rides with the pick.
- Classic CLI (`cli_model_switch_mixin`, `cli_tui_mixin`): `/model X --reasoning
  high` applies the effort AFTER the agent swap (`switch_model` re-resolves
  `reasoning_config` from config.yaml, so an earlier write is clobbered) with the
  pick's scope (session; config on `--global`; `--once` snapshots and restores it).
  The `/model` picker gains a third stage, "Reasoning effort for <model>", built
  from `VALID_REASONING_EFFORTS` + none + "Keep current effort"; hidden when the
  inventory capability map says the route has no reasoning control.
- TUI gateway (`tui_gateway/model_switch.py`, serves Ink TUI + Desktop):
  `config.set model "X --reasoning high"` applies after the swap; session pin
  (`create_reasoning_override`) by default, `agent.reasoning_effort` on --global,
  one-turn restore carries `reasoning_config`; re-emits `session_info` so the
  status bar shows the new effort.
- Ink TUI `ModelPicker`: step 3/3 (same rows, same capability gate) emitting
  `<model> --provider <slug> --reasoning <level> <scope>`; the new-session draft
  label strips the flag like `--provider`.
- Messaging gateway `/model`: `--reasoning` goes through the existing
  `_apply_reasoning_selection` (the `/reasoning` applier) with the pick's scope.
- `hermes model`: one shared post-pick effort step for the MAIN model (replaces
  the Copilot-only inline prompt; Copilot keeps its per-model level set via
  `github_model_reasoning_efforts`, other routes get the ladder, catalog
  `supports_reasoning=False` skips it) plus a "Reasoning effort for the current
  model..." row. The auxiliary menu's provider->model and custom-endpoint flows end
  with the same step (+ "Provider default"), stored as
  `auxiliary.<task>.reasoning_effort` / `delegation.reasoning_effort`, shown in
  the task list ("openrouter · model · high"), cleared by "Reset all to auto";
  tasks whose block omits the key by design (MoA slots, memory_query_rewrite) skip
  it.

Live (temp HERMES_HOME, stub key, no model call):
- `hermes model` -> aux -> Vision -> OpenRouter -> model: before ends at
  "Vision: openrouter · <m>", no key written; after adds "Select reasoning effort"
  and saves `reasoning_effort: high`.
- `hermes model` -> DeepSeek -> model: before no effort step; after the step
  writes `agent.reasoning_effort: xhigh`.
- tui_gateway stdio: `config.set model "... --reasoning high --session"` before
  errors "Model names cannot contain spaces"; after switches and `config.get
  reasoning` returns high; bad level -> the canonical error text.
- classic CLI `process_command`: before the same spaces error; after "Reasoning
  effort: high" under the switch summary, `--global` writes config.
- `hermes --tui` PTY: /model -> step 1/3 -> 2/3 -> 3/3 -> high; transcript
  "reasoning: high", status bar "fable 5.1 high".
2026-09-13 16:43:50 -07:00

65 lines
2.8 KiB
Python

"""``config.set model "X --reasoning <level>"`` on the TUI gateway: the effort rides with the pick.
Applied AFTER the live swap (``agent.switch_model`` re-resolves ``reasoning_config`` from
config.yaml) and scoped like the pick: a session pin by default, ``agent.reasoning_effort`` on
``--global``. The Ink TUI picker and the classic CLI both emit this exact shape.
"""
from types import SimpleNamespace
import pytest
import tui_gateway.server as server
class _Agent:
def __init__(self):
self.model, self.provider, self.base_url, self.api_key, self.api_mode = "old", "nous", "", "", ""
self.reasoning_config = {"enabled": True, "effort": "medium"}
def switch_model(self, **_kw):
self.reasoning_config = {"enabled": True, "effort": "medium"} # re-resolved from config
@pytest.fixture
def _quiet_switch(monkeypatch):
result = SimpleNamespace(
success=True, new_model="new/model", target_provider="nous", base_url="", api_key="key",
api_mode="chat_completions", warning_message="", model_info=None, error_message="",
runtime_capabilities=None)
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kw: result)
monkeypatch.setattr("hermes_cli.model_switch.persist_model_selection", lambda _r: None)
monkeypatch.setattr("hermes_cli.model_cost_guard.expensive_model_warning", lambda *a, **k: None)
for name in ("_restart_slash_worker", "_persist_live_session_runtime", "_persist_live_session_system_prompt",
"_append_model_switch_marker", "_emit_session_info"):
monkeypatch.setattr(server, name, lambda *a, **k: None)
written = {}
monkeypatch.setattr(server, "_write_config_key", lambda k, v: written.__setitem__(k, v))
return written
def test_reasoning_flag_survives_the_swap_and_pins_the_session(_quiet_switch):
agent = _Agent()
session = {"agent": agent}
out = server._apply_model_switch("sid", session, "new/model --provider nous --reasoning high --session")
assert out["value"] == "new/model"
assert agent.reasoning_config == {"enabled": True, "effort": "high"}
assert session["create_reasoning_override"] == {"enabled": True, "effort": "high"}
assert "agent.reasoning_effort" not in _quiet_switch
def test_reasoning_flag_with_global_writes_config_and_drops_the_pin(_quiet_switch):
agent = _Agent()
session = {"agent": agent, "create_reasoning_override": {"enabled": True, "effort": "low"}}
server._apply_model_switch("sid", session, "new/model --provider nous --reasoning none --global")
assert agent.reasoning_config == {"enabled": False}
assert _quiet_switch["agent.reasoning_effort"] == "none"
assert "create_reasoning_override" not in session
with pytest.raises(ValueError, match="--reasoning takes"):
server._apply_model_switch("sid", {"agent": _Agent()}, "new/model --reasoning turbo")