Files
hermes-webui/tests/test_reasoning_show_hide.py
T
d195b094bb Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243)
* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai

Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models because
_candidate_supports_reasoning has an unconditional glm token match and
_filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog
models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.

Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.

The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.

State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.

* fix(reasoning): close ZAI coercion gap + harden test assertions

Address Greptile review on #6219 (round 1):

1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
   fallback made the assertion always-true, so a regression stripping 'none'
   for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
   (mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
   test now genuinely exercises the preservation branch.

2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
   'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
   rule' (preserving the configured effort verbatim per #3505), so a stored
   'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
   to Z.AI unchanged and silently ignored — contradicting the PR's stated
   UI/coercion agreement invariant.

   Root cause: the ZAI gate returns [] to mean 'known-empty' (no
   reasoning_effort at all), but the coercion path treated all [] as
   'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
   into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
   both the filter and coercion, then special-casing the known-False result in
   coerce_reasoning_effort_for_model to return '' (send no field). The #3505
   preserve-verbatim behavior for genuinely-unknown models on non-zai providers
   is unchanged.

Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim

State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.

* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty

Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.

Fix decouples thinking-toggle capability from the effort ladder:

Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
  (GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
  (non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
  contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
  False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
  OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
  so their chip-visibility behavior is unchanged.

Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
  true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
  Empty efforts + toggle=True keeps the chip visible with just the None/On
  control (the existing _applyReasoningOptions already shows 'none' when the
  ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
  supports_thinking_toggle:false alongside the empty efforts so the chip hides
  during the unknown-state window, matching the prior reset contract.

Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
  toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
  toggle=True stays visible, toggle=False hides, effort ladder alone is
  sufficient, absent field keeps prior behavior

State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.

* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none

Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.

Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.

Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
  agent.reasoning_effort so the provider default takes effect). Invalid values
  still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
  effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
  an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
  (if(effort)) — the old check silently ignored data-effort='' clicks, which
  would have left the Default button dead even after the HTML/backend changes.

Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.

Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
  BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
  = thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
  skipping the 'none' reattachment entirely.

Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
  still accepts 'none'; resolve does not reattach 'none' for forced but DOES
  for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
  raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
  both Default and None for thinking-tier (two-state), Default+None+ladder for
  effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
  reflect the new contract (empty accepted, garbage rejected).

Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.

State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).

* CHANGELOG: GLM per-version reasoning controls (#6219)

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-18 05:44:33 -07:00

468 lines
22 KiB
Python

"""Tests for /reasoning show|hide slash command and show_thinking setting.
Covers:
- show_thinking in _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS (api/config.py)
- window._showThinking initialised in boot.js (settings and fallback paths)
- window._showThinking guard in ui.js renderMessages thinking card
- _renderLiveThinking guard in messages.js
- cmdReasoning function present in commands.js with show/hide/effort handling
- /reasoning in COMMANDS array (not just SLASH_SUBARG_SOURCES)
- show|hide present as subArgs in COMMANDS entry
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding='utf-8')
def function_body(src, name):
start = src.find(f"function {name}")
assert start != -1, f"{name} not found"
brace = src.find("{", start)
assert brace != -1, f"{name} body not found"
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1:idx]
raise AssertionError(f"{name} body did not close")
# ── api/config.py ─────────────────────────────────────────────────────────────
class TestShowThinkingConfig:
"""show_thinking must appear in defaults and bool keys."""
def test_show_thinking_in_defaults(self):
src = read('api/config.py')
assert '"show_thinking": True' in src, (
"show_thinking must be True in _SETTINGS_DEFAULTS"
)
def test_show_thinking_in_bool_keys(self):
src = read('api/config.py')
assert '"show_thinking"' in src
# Find the _SETTINGS_BOOL_KEYS set and confirm show_thinking is in it
m = re.search(r'_SETTINGS_BOOL_KEYS\s*=\s*\{([^}]+)\}', src, re.DOTALL)
assert m, "_SETTINGS_BOOL_KEYS not found"
assert 'show_thinking' in m.group(1), (
"show_thinking must be in _SETTINGS_BOOL_KEYS"
)
# ── static/boot.js ────────────────────────────────────────────────────────────
class TestBootJsShowThinking:
"""window._showThinking must be set in both the settings and fallback paths."""
def test_settings_path_initialises_show_thinking(self):
src = read('static/boot.js')
# Must read from the settings object, defaulting true when absent
assert 'window._showThinking=s.show_thinking!==false' in src, (
"boot.js must initialise _showThinking from settings (default true)"
)
def test_fallback_path_initialises_show_thinking_true(self):
src = read('static/boot.js')
assert 'window._showThinking=true' in src, (
"boot.js fallback path must default _showThinking to true"
)
# ── static/ui.js ──────────────────────────────────────────────────────────────
class TestUiJsThinkingGate:
"""Historical thinking cards must be gated by window._showThinking."""
def test_thinking_card_gated_in_render_messages(self):
src = read('static/ui.js')
assert 'window._showThinking!==false' in src, (
"ui.js must gate thinkingCardHtml on window._showThinking"
)
# The guard must be on the same line as _thinkingCardHtml insertion
lines = src.splitlines()
for line in lines:
if '_thinkingCardHtml' in line and 'insertAdjacentHTML' in line:
assert 'window._showThinking' in line, (
f"thinking card insertion must be gated: {line.strip()}"
)
break
def test_worklog_reasoning_rows_are_gated_by_show_thinking(self):
src = read('static/ui.js')
# The gate must sit in the ACTUAL render paths that build Worklog reasoning
# rows — _syncWorklogReasonFromAnchor (live + settled) and _appendWorklogReason
# (settled rebuild) — not in the unused _worklogReasonNodeFromText helper.
sync_fn = function_body(src, "_syncWorklogReasonFromAnchor")
assert 'window._showThinking===false' in sync_fn and 'return' in sync_fn, (
"_syncWorklogReasonFromAnchor must bail (and remove any existing row) "
"when thinking display is off"
)
append_fn = function_body(src, "_appendWorklogReason")
assert 'window._showThinking===false' in append_fn and 'return null' in append_fn, (
"_appendWorklogReason must not build a reasoning row when thinking is hidden"
)
def test_show_thinking_gate_does_not_hide_worklog_anchor_text(self):
src = read('static/ui.js')
html_fn = function_body(src, "_worklogReasonHtmlFromText")
assert 'window._showThinking' not in html_fn, (
"the low-level Worklog text renderer is also used for anchor/progress text; "
"only reasoning-source rows should be gated"
)
def test_remove_thinking_prunes_reasoning_rows_but_preserves_tool_or_anchor_rows(self):
src = read('static/ui.js')
fn = function_body(src, "removeThinking")
# The live/settled reasoning rows are tagged data-worklog-anchor-reason="1"
# (by _syncWorklogReasonFromAnchor / _appendWorklogReason); the sweep MUST
# target that attribute, not only the legacy data-worklog-reason-source.
assert '.wl-reason[data-worklog-anchor-reason="1"]' in fn, (
"removeThinking must sweep the actually-rendered reasoning Worklog rows "
'(data-worklog-anchor-reason="1")'
)
assert '.tool-card-row,.agent-activity-thinking,.wl-reason' in fn, (
"empty-group cleanup must preserve groups that still contain tool cards "
"or non-reasoning Worklog anchor rows"
)
assert '.live-worklog[data-live-worklog-shell="1"]' in fn, (
"live Worklog shells must be considered for cleanup after their reasoning "
"rows are removed"
)
# ── static/messages.js ────────────────────────────────────────────────────────
class TestMessagesJsLiveThinkingGate:
"""Live streaming thinking card must be hidden when _showThinking is false."""
def test_live_thinking_gated(self):
src = read('static/messages.js')
assert 'window._showThinking===false' in src, (
"messages.js _renderLiveThinking must early-return when _showThinking is false"
)
# Guard must be inside _renderLiveThinking
m = re.search(r'function _renderLiveThinking\(.*?\{(.*?)^\s*\}',
src, re.DOTALL | re.MULTILINE)
assert m, "_renderLiveThinking not found"
assert 'window._showThinking' in m.group(1)
# ── static/commands.js ────────────────────────────────────────────────────────
class TestReasoningCommand:
"""cmdReasoning must be wired into COMMANDS with show/hide subArgs."""
def test_reasoning_in_commands_array(self):
src = read('static/commands.js')
# Must appear in COMMANDS array (not just SLASH_SUBARG_SOURCES)
m = re.search(r'const COMMANDS\s*=\s*\[(.*?)\];', src, re.DOTALL)
assert m, "COMMANDS array not found"
commands_block = m.group(1)
assert 'reasoning' in commands_block, (
"/reasoning must be in the COMMANDS array with a fn: handler"
)
assert 'fn:cmdReasoning' in commands_block or "fn: cmdReasoning" in commands_block, (
"/reasoning entry must reference cmdReasoning"
)
def test_reasoning_subargs_include_show_hide(self):
src = read('static/commands.js')
m = re.search(r'const COMMANDS\s*=\s*\[(.*?)\];', src, re.DOTALL)
assert m
commands_block = m.group(1)
# Find the reasoning entry
rm = re.search(r"\{name:'reasoning'.*?\}", commands_block, re.DOTALL)
assert rm, "reasoning entry not found in COMMANDS"
entry = rm.group(0)
assert 'show' in entry, "subArgs must include 'show'"
assert 'hide' in entry, "subArgs must include 'hide'"
def test_reasoning_not_only_in_subarg_sources(self):
src = read('static/commands.js')
# It's fine if SLASH_SUBARG_SOURCES is empty or doesn't have reasoning
# (reasoning moved to COMMANDS with a real fn)
m = re.search(r'const SLASH_SUBARG_SOURCES\s*=\s*\{(.*?)\};', src, re.DOTALL)
if m:
subarg_block = m.group(1)
assert 'reasoning' not in subarg_block, (
"reasoning must not remain in SLASH_SUBARG_SOURCES once it has a fn: handler"
)
def test_cmd_reasoning_function_exists(self):
src = read('static/commands.js')
assert 'function cmdReasoning' in src, (
"cmdReasoning function must be defined"
)
def test_cmd_reasoning_handles_show(self):
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m, "cmdReasoning not found"
fn = m.group(0)
# Handler must write to the UI render gate (assignment may use a
# boolean literal or a locally-computed variable) and call renderMessages.
assert re.search(r'window\._showThinking\s*=\s*(?:true|on)\b', fn), (
"cmdReasoning show branch must assign true/on to window._showThinking"
)
assert 'renderMessages' in fn, (
"show/hide branch must call renderMessages()"
)
assert "typeof removeThinking==='function'" in fn and "removeThinking()" in fn, (
"hide/off must prune already-rendered live Thinking and Worklog reasoning rows"
)
# Persistence: POST to /api/reasoning (CLI-shared config.yaml) AND
# /api/settings (boot.js mirror).
assert "api('/api/reasoning'" in fn, (
"show/hide branch must POST to /api/reasoning so config.yaml "
"display.show_reasoning is updated (CLI parity)"
)
assert 'show_thinking' in fn, (
"show/hide branch must also mirror show_thinking into "
"WebUI settings.json for boot.js hydration"
)
def test_cmd_reasoning_handles_hide(self):
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
# The hide branch shares logic with show via a computed `on` variable;
# the combined branch must test for both show|on and hide|off.
assert "arg==='hide'" in fn, "hide branch missing"
assert "arg==='off'" in fn, "off alias missing"
def test_cmd_reasoning_i18n_key_exists(self):
i18n = read('static/i18n.js')
assert 'cmd_reasoning' in i18n, (
"i18n.js must define the cmd_reasoning key"
)
def test_cmd_reasoning_posts_api_endpoints_not_gets(self):
"""Regression: the api() helper spreads its 2nd arg into fetch(), so
passing a plain options object without method:'POST' silently becomes
a GET and the update is dropped. Every mutating call inside
cmdReasoning (/api/reasoning, /api/settings) must be a proper POST."""
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
# Every write call — /api/reasoning and /api/settings — must be a POST.
write_calls = re.findall(
r"api\('/api/(?:reasoning|settings)'\s*,[^)]*\)", fn
)
assert write_calls, "cmdReasoning must POST to /api/reasoning or /api/settings"
for call in write_calls:
assert "method:'POST'" in call or 'method: "POST"' in call, (
f"write call missing method:'POST' — would fall through "
f"to GET and silently drop the update: {call}"
)
assert 'JSON.stringify' in call, (
f"write call missing JSON body: {call}"
)
def test_cmd_reasoning_routes_effort_through_api_reasoning(self):
"""Effort levels (none|minimal|low|medium|high|xhigh) must POST to
/api/reasoning so the agent's config.yaml agent.reasoning_effort is
updated — the same key the CLI writes via `/reasoning <level>`.
Previous design stored in a dead client variable, which did nothing."""
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
assert "api('/api/reasoning'" in fn, (
"cmdReasoning must POST effort levels to /api/reasoning so "
"config.yaml agent.reasoning_effort is updated (CLI parity)"
)
assert "'effort:'" in fn or 'effort:arg' in fn or 'effort: arg' in fn, (
"effort-level branch must send {effort: arg} to /api/reasoning"
)
# Must NOT still hold a dead local-only variable for effort.
assert '_reasoningEffort=' not in fn, (
"cmdReasoning must not keep a dead client-side _reasoningEffort "
"(effort now round-trips through /api/reasoning → config.yaml)"
)
def test_cmd_reasoning_routes_display_through_api_reasoning(self):
"""show|hide|on|off must POST to /api/reasoning (config.yaml
display.show_reasoning — the CLI's key) in addition to mirroring
into WebUI settings.json for boot.js hydration."""
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
assert 'display:arg' in fn or 'display: arg' in fn or "'display'" in fn, (
"show|hide branch must send {display: arg} to /api/reasoning so "
"config.yaml display.show_reasoning matches the CLI's source"
)
def test_cmd_reasoning_supports_all_cli_effort_levels(self):
"""The effort-level set must match hermes_constants.VALID_REASONING_EFFORTS
+ 'none' — i.e. the exact set the CLI accepts in /reasoning."""
src = read('static/commands.js')
m = re.search(r'function cmdReasoning\(.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
for level in ('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'):
assert f"'{level}'" in fn, (
f"cmdReasoning must accept '{level}' (CLI parity with "
f"hermes_constants.parse_reasoning_effort)"
)
def test_reasoning_subargs_match_cli_levels(self):
"""Autocomplete subArgs must expose every CLI effort level + show/hide."""
src = read('static/commands.js')
m = re.search(r"\{name:'reasoning'[^}]*\}", src, re.DOTALL)
assert m, "reasoning COMMANDS entry not found"
entry = m.group(0)
for suggestion in (
'show', 'hide', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
):
assert f"'{suggestion}'" in entry, (
f"reasoning subArgs must include '{suggestion}' for CLI parity"
)
# ── api/config.py — reasoning helpers ────────────────────────────────────────
class TestReasoningConfigHelpers:
"""Validate that api/config.py exposes the CLI-parity helpers and that
they read/write the same keys the CLI uses."""
def test_parse_reasoning_effort_matches_cli_semantics(self):
from api.config import parse_reasoning_effort, VALID_REASONING_EFFORTS
# Empty → None
assert parse_reasoning_effort('') is None
assert parse_reasoning_effort(None) is None
# none → disabled
assert parse_reasoning_effort('none') == {'enabled': False}
# Each valid level → {enabled, effort}
for level in VALID_REASONING_EFFORTS:
assert parse_reasoning_effort(level) == {'enabled': True, 'effort': level}
# Unknown → None (fall back to default)
assert parse_reasoning_effort('garbage') is None
# Case-insensitive + trimmed
assert parse_reasoning_effort(' HIGH ') == {'enabled': True, 'effort': 'high'}
def test_valid_reasoning_efforts_matches_hermes_constants(self):
"""Ensure our mirror stays in sync with hermes_constants."""
from api.config import VALID_REASONING_EFFORTS
# Snapshot-style assertion: if hermes_constants adds a level, this
# test will fail fast so we know to update WebUI too.
assert VALID_REASONING_EFFORTS == (
'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
)
def test_set_reasoning_effort_persists_to_config_yaml(self, tmp_path, monkeypatch):
"""set_reasoning_effort writes agent.reasoning_effort to the active
profile's config.yaml — the same key the CLI writes."""
import api.config as cfg
cfgfile = tmp_path / 'config.yaml'
monkeypatch.setattr(cfg, '_get_config_path', lambda: cfgfile)
cfg.set_reasoning_effort('high')
import yaml as _yaml
data = _yaml.safe_load(cfgfile.read_text(encoding='utf-8'))
assert data.get('agent', {}).get('reasoning_effort') == 'high', (
"agent.reasoning_effort must be persisted to config.yaml"
)
def test_set_reasoning_display_persists_to_config_yaml(self, tmp_path, monkeypatch):
"""set_reasoning_display writes display.show_reasoning to the same
config.yaml the CLI writes."""
import api.config as cfg
cfgfile = tmp_path / 'config.yaml'
monkeypatch.setattr(cfg, '_get_config_path', lambda: cfgfile)
cfg.set_reasoning_display(False)
import yaml as _yaml
data = _yaml.safe_load(cfgfile.read_text(encoding='utf-8'))
assert data.get('display', {}).get('show_reasoning') is False, (
"display.show_reasoning must be persisted to config.yaml"
)
cfg.set_reasoning_display(True)
data = _yaml.safe_load(cfgfile.read_text(encoding='utf-8'))
assert data.get('display', {}).get('show_reasoning') is True
def test_set_reasoning_effort_rejects_invalid(self, tmp_path, monkeypatch):
import api.config as cfg
monkeypatch.setattr(cfg, '_get_config_path', lambda: tmp_path / 'config.yaml')
monkeypatch.setattr(cfg, 'reload_config', lambda: None)
import pytest as _pt
with _pt.raises(ValueError):
cfg.set_reasoning_effort('garbage')
# Empty effort is ACCEPTED as "clear the override" (the Default/On
# re-enable path for thinking-toggle-only models, #6219 round-3). It
# must not raise; it removes agent.reasoning_effort so the provider
# default takes effect.
cfg.set_reasoning_effort('')
def test_get_reasoning_status_defaults_to_show_true(self, tmp_path, monkeypatch):
"""When config.yaml has no display section, show_reasoning defaults
to True (matches CLI default where the setting is opt-in)."""
import api.config as cfg
monkeypatch.setattr(cfg, '_get_config_path', lambda: tmp_path / 'config.yaml')
st = cfg.get_reasoning_status()
assert st['show_reasoning'] is True
assert st['reasoning_effort'] == ''
# ── api/streaming.py — AIAgent receives reasoning_config ──────────────────────
class TestStreamingReasoningWiring:
"""Confirm api/streaming.py reads agent.reasoning_effort from config and
passes parsed reasoning_config to AIAgent (so effort changes take effect
on the next session)."""
def test_streaming_reads_reasoning_effort_from_config(self):
src = read('api/streaming.py')
assert 'parse_reasoning_effort' in src, (
"api/streaming.py must import parse_reasoning_effort to translate "
"config.yaml agent.reasoning_effort into AIAgent reasoning_config"
)
assert 'coerce_reasoning_effort_for_model' in src, (
"api/streaming.py must clamp/drop unsupported model-specific effort "
"levels before sending reasoning_config to the provider"
)
assert "reasoning_config" in src and "'reasoning_config' in _agent_params" in src, (
"api/streaming.py must guard the reasoning_config kwarg with "
"inspect.signature so older hermes-agent builds don't TypeError"
)
# ── api/routes.py — /api/reasoning endpoints ──────────────────────────────────
class TestReasoningRoutes:
def test_get_api_reasoning_route_exists(self):
src = read('api/routes.py')
assert 'parsed.path == "/api/reasoning"' in src, (
"GET /api/reasoning route must exist"
)
assert 'get_reasoning_status' in src, (
"api/routes.py must import and call get_reasoning_status"
)
def test_post_api_reasoning_accepts_display(self):
src = read('api/routes.py')
# The POST branch reads 'display' from body and dispatches to
# set_reasoning_display.
assert 'set_reasoning_display' in src, (
"POST /api/reasoning must route display toggles through "
"set_reasoning_display"
)
def test_post_api_reasoning_accepts_effort(self):
src = read('api/routes.py')
assert 'set_reasoning_effort' in src, (
"POST /api/reasoning must route effort changes through "
"set_reasoning_effort"
)