fix(reasoning): default /reasoning <level> to session scope in CLI and TUI

Parity with the gateway /reasoning handler and the new /model default:
a bare /reasoning <level> now applies to the current session only;
--global persists agent.reasoning_effort to config.yaml. --session is
still accepted as an explicit alias for the default. Display toggles
(show/hide/full/clamp) remain persistent as before — they are user
preferences, not conversation state.

Builds on YAMAGUCHI Seiji's #51158 (session-scope plumbing + /new reset,
cherry-picked as the previous commit) with the default flipped to match
the session-first policy. Fixes the CLI half of #54084.
This commit is contained in:
Teknium 2026-07-20 01:25:38 -07:00
parent 8590c2d0d9
commit dc0dbc9387
6 changed files with 59 additions and 25 deletions

View file

@ -2515,8 +2515,8 @@ class CLICommandsMixin:
Usage:
/reasoning Show current effort level and display state
/reasoning <level> Set effort (none, minimal, low, medium, high, xhigh, max, ultra)
/reasoning <level> --session Set reasoning effort for this session only
/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)
@ -2538,14 +2538,20 @@ class CLICommandsMixin:
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> [--session]{_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()
explicit_session = "--session" in arg_tokens
if explicit_session:
arg = " ".join(token for token in arg_tokens if token != "--session")
# 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"}:
@ -2585,23 +2591,23 @@ class CLICommandsMixin:
_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: saved by default, --session for a temporary override{_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 not explicit_session and 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 not explicit_session:
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.

View file

@ -153,8 +153,8 @@ COMMAND_REGISTRY: list[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] [--session]",
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--session")),
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")),

View file

@ -154,8 +154,20 @@ class TestHandleReasoningCommand(unittest.TestCase):
level = rc.get("effort", "medium")
self.assertEqual(level, "xhigh")
def test_effort_defaults_to_global_save(self):
"""Plain /reasoning <level> keeps the existing config-writing behavior."""
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
@ -163,7 +175,7 @@ class TestHandleReasoningCommand(unittest.TestCase):
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")
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")
@ -171,7 +183,7 @@ class TestHandleReasoningCommand(unittest.TestCase):
self.assertIsNone(stub.agent)
def test_effort_session_flag_does_not_persist_config(self):
"""--session opts into a temporary session-only effort override."""
"""--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"})

View file

@ -4649,13 +4649,13 @@ 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_session_scope():
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", "--session"} <= values
assert {"max", "ultra", "--global"} <= values
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):

View file

@ -278,19 +278,31 @@ describe('createSlashHandler', () => {
})
})
it.each(['low', 'max', 'ultra'])('sends plain /reasoning %s as global config.set', effort => {
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',
scope: 'global',
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()

View file

@ -24,7 +24,7 @@ const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your bal
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const REASONING_SESSION_FLAGS = new Set(['--session'])
const REASONING_EFFORT_VALUES = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
const REASONING_GLOBAL_FLAGS = new Set(['--global'])
const modelValueForConfigSet = (arg: string) => {
const trimmed = arg.trim()
@ -47,17 +47,21 @@ const reasoningConfigPayload = (arg: string, sid: 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)) {
scope = 'session'
// Session scope is the default; accept the flag for parity with /model.
if (!scope) {
scope = 'session'
}
continue
}
valueParts.push(part)
}
const value = valueParts.join(' ')
if (!scope && REASONING_EFFORT_VALUES.has(value.toLowerCase())) {
scope = 'global'
}
return {
key: 'reasoning',