diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c10538..fe356ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.133] — 2026-04-21 + +### Added +- **`/reasoning show` and `/reasoning hide` slash commands** — toggle thinking/reasoning block visibility directly from the chat composer, matching the Hermes CLI/TUI parity. `/reasoning show` reveals all thinking cards (live and historical) and persists the preference; `/reasoning hide` collapses them. `/reasoning` with no args shows current state. The `show|hide` options now appear in autocomplete alongside the existing `low|medium|high` effort levels. The `show_thinking` setting is persisted via `/api/settings` so the preference survives page reloads. Closes #461 (partial — effort level routing to agent is a follow-up). (#812) + ## [v0.50.132] — 2026-04-21 ### Fixed diff --git a/api/config.py b/api/config.py index 9c30a46..3bf5ba9 100644 --- a/api/config.py +++ b/api/config.py @@ -761,6 +761,99 @@ def get_effective_default_model(config_data: dict | None = None) -> str: return default_model +# ── Reasoning config (CLI parity for /reasoning) ───────────────────────────── +# Mirrors hermes_constants.parse_reasoning_effort so WebUI can validate without +# importing from the agent tree (which may not be installed). Any drift here +# will show up in the shared test suite since both sides accept the same set. +VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh") + + +def parse_reasoning_effort(effort): + """Parse an effort level into the dict the agent expects. + + Returns None when *effort* is empty or unrecognised (caller interprets as + "use default"), ``{"enabled": False}`` for ``"none"``, and + ``{"enabled": True, "effort": }`` for any of + ``VALID_REASONING_EFFORTS``. + """ + if not effort or not str(effort).strip(): + return None + eff = str(effort).strip().lower() + if eff == "none": + return {"enabled": False} + if eff in VALID_REASONING_EFFORTS: + return {"enabled": True, "effort": eff} + return None + + +def get_reasoning_status() -> dict: + """Return current reasoning configuration from the active profile's + config.yaml — the same source of truth the CLI reads from. + + Keys: + - show_reasoning: bool — from ``display.show_reasoning`` (default True) + - reasoning_effort: str — from ``agent.reasoning_effort`` ('' = default) + """ + config_data = _load_yaml_config_file(_get_config_path()) + display_cfg = config_data.get("display") or {} + agent_cfg = config_data.get("agent") or {} + show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None + effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None + return { + # Match CLI default (True if unset in config.yaml) + "show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True, + "reasoning_effort": str(effort_raw or "").strip().lower(), + } + + +def set_reasoning_display(show: bool) -> dict: + """Persist ``display.show_reasoning`` to the active profile's config.yaml. + + Mirrors CLI ``/reasoning show|hide``: writes the same key that the CLI + writes, so the preference is shared across the WebUI and the terminal + REPL for the same profile. + """ + config_path = _get_config_path() + with _cfg_lock: + config_data = _load_yaml_config_file(config_path) + display_cfg = config_data.get("display") + if not isinstance(display_cfg, dict): + display_cfg = {} + display_cfg["show_reasoning"] = bool(show) + config_data["display"] = display_cfg + _save_yaml_config_file(config_path, config_data) + reload_config() + return get_reasoning_status() + + +def set_reasoning_effort(effort: str) -> dict: + """Persist ``agent.reasoning_effort`` to the active profile's config.yaml. + + Mirrors CLI ``/reasoning ``: same key, same valid values + (``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh``). + Raises ``ValueError`` on an unrecognised level so callers can return 400. + """ + raw = str(effort or "").strip().lower() + if not raw: + raise ValueError("effort is required") + if raw != "none" and raw not in VALID_REASONING_EFFORTS: + raise ValueError( + f"Unknown reasoning effort '{effort}'. " + f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}." + ) + config_path = _get_config_path() + with _cfg_lock: + config_data = _load_yaml_config_file(config_path) + agent_cfg = config_data.get("agent") + if not isinstance(agent_cfg, dict): + agent_cfg = {} + agent_cfg["reasoning_effort"] = raw + config_data["agent"] = agent_cfg + _save_yaml_config_file(config_path, config_data) + reload_config() + return get_reasoning_status() + + def set_hermes_default_model(model_id: str) -> dict: """Persist the Hermes default model in config.yaml and reload runtime config.""" selected_model = str(model_id or "").strip() @@ -1381,6 +1474,7 @@ _SETTINGS_DEFAULTS = { ), # display name for the assistant "sound_enabled": False, # play notification sound when assistant finishes "notifications_enabled": False, # browser notification when tab is in background + "show_thinking": True, # show/hide thinking/reasoning blocks in chat view "sidebar_density": "compact", # compact | detailed "password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled } @@ -1491,6 +1585,7 @@ _SETTINGS_BOOL_KEYS = { "check_for_updates", "sound_enabled", "notifications_enabled", + "show_thinking", } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") diff --git a/api/routes.py b/api/routes.py index 83082b6..742b42e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -48,6 +48,9 @@ from api.config import ( load_settings, save_settings, set_hermes_default_model, + get_reasoning_status, + set_reasoning_display, + set_reasoning_effort, ) from api.helpers import ( require, @@ -557,6 +560,12 @@ def handle_get(handler, parsed) -> bool: pass return j(handler, settings) + if parsed.path == "/api/reasoning": + # Current reasoning config (shared source of truth with the CLI — + # reads display.show_reasoning and agent.reasoning_effort from + # the active profile's config.yaml). + return j(handler, get_reasoning_status()) + if parsed.path == "/api/onboarding/status": return j(handler, get_onboarding_status()) @@ -899,6 +908,32 @@ def handle_post(handler, parsed) -> bool: except RuntimeError as e: return bad(handler, str(e), 500) + if parsed.path == "/api/reasoning": + # CLI-parity /reasoning handler — writes to the same config.yaml keys + # the CLI uses (display.show_reasoning, agent.reasoning_effort) so a + # preference set via WebUI is honoured in the terminal REPL and vice + # versa. Body is one of: + # {"display": "show"|"hide"|"on"|"off"} → display.show_reasoning + # {"effort": "none"|"minimal"|"low"|"medium"|"high"|"xhigh"} + # → agent.reasoning_effort + try: + display = body.get("display") + effort = body.get("effort") + if display is not None: + flag = str(display).strip().lower() + if flag in ("show", "on", "true", "1"): + return j(handler, set_reasoning_display(True)) + if flag in ("hide", "off", "false", "0"): + return j(handler, set_reasoning_display(False)) + return bad(handler, f"display must be show|hide|on|off (got '{display}')") + if effort is not None: + return j(handler, set_reasoning_effort(effort)) + return bad(handler, "reasoning: must supply 'display' or 'effort'") + except ValueError as e: + return bad(handler, str(e)) + except RuntimeError as e: + return bad(handler, str(e), 500) + if parsed.path == "/api/sessions/cleanup": return _handle_sessions_cleanup(handler, body, zero_only=False) diff --git a/api/streaming.py b/api/streaming.py index 7c2f623..59622e5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1100,6 +1100,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta import inspect as _inspect _agent_params = set(_inspect.signature(_AIAgent.__init__).parameters) + # CLI-parity reasoning effort: read agent.reasoning_effort from the + # active profile's config.yaml (the same key the CLI writes via + # `/reasoning `) and hand the parsed dict to AIAgent. When + # the key is absent or invalid, pass None → agent uses its default. + try: + from api.config import parse_reasoning_effort as _parse_reff + _effort_cfg = _cfg.cfg.get('agent', {}) if isinstance(_cfg.cfg, dict) else {} + _effort_raw = _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None + _reasoning_config = _parse_reff(_effort_raw) + except Exception: + _reasoning_config = None + _agent_kwargs = dict( model=resolved_model, provider=resolved_provider, @@ -1120,6 +1132,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta ) ), ) + # reasoning_config has been an AIAgent param for several releases, + # but guard defensively to avoid TypeError on an older agent build. + if 'reasoning_config' in _agent_params and _reasoning_config is not None: + _agent_kwargs['reasoning_config'] = _reasoning_config # Params added in newer hermes-agent — skip if not supported if 'api_mode' in _agent_params: _agent_kwargs['api_mode'] = _rt.get('api_mode') diff --git a/static/boot.js b/static/boot.js index 62de57d..aa36e1e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -764,6 +764,7 @@ function applyBotName(){ window._showCliSessions=!!s.show_cli_sessions; window._soundEnabled=!!s.sound_enabled; window._notificationsEnabled=!!s.notifications_enabled; + window._showThinking=s.show_thinking!==false; window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact'); window._botName=s.bot_name||'Hermes'; const appearance=_normalizeAppearance(s.theme,s.skin); @@ -785,6 +786,7 @@ function applyBotName(){ window._showCliSessions=false; window._soundEnabled=false; window._notificationsEnabled=false; + window._showThinking=true; window._sidebarDensity='compact'; window._botName='Hermes'; _bootSettings={check_for_updates:false}; diff --git a/static/commands.js b/static/commands.js index e036311..2cbe952 100644 --- a/static/commands.js +++ b/static/commands.js @@ -20,12 +20,12 @@ const COMMANDS=[ {name:'undo', desc:t('cmd_undo'), fn:cmdUndo}, {name:'status', desc:t('cmd_status'), fn:cmdStatus}, {name:'voice', desc:t('cmd_voice'), fn:cmdVoice}, + {name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh']}, ]; const SLASH_SUBARG_SOURCES={ model:{desc:t('cmd_model'), subArgs:'models'}, personality:{desc:t('cmd_personality'), subArgs:'personalities'}, - reasoning:{desc:'Set reasoning effort', subArgs:['low','medium','high']}, }; function parseCommand(text){ @@ -41,8 +41,10 @@ function executeCommand(text){ if(!parsed)return false; const cmd=COMMANDS.find(c=>c.name===parsed.name); if(!cmd)return false; - cmd.fn(parsed.args); - return true; + // A handler may return `false` to opt out of interception — e.g. /reasoning + // with an effort level falls through so the agent's own handler sees it, + // preserving the pre-existing pass-through behaviour for that subcommand. + return cmd.fn(parsed.args) !== false; } function getMatchingCommands(prefix){ @@ -572,6 +574,56 @@ async function cmdStatus(){ renderMessages(); }catch(e){showToast(t('status_load_failed')+e.message);} } +function cmdReasoning(args){ + const arg=(args||'').trim().toLowerCase(); + const BRAIN='\uD83E\uDDE0'; + // Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity). + const EFFORTS=['none','minimal','low','medium','high','xhigh']; + // Shared status renderer used by the no-args branch and as a fallback. + function _fmtStatus(st){ + const vis=(st && st.show_reasoning===false)?'off':'on'; + const eff=(st && st.reasoning_effort)||'default'; + return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis + +' | /reasoning show|hide|none|minimal|low|medium|high|xhigh'; + } + if(!arg){ + // Status — read from the same config.yaml keys the CLI uses. + api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));}) + .catch(function(){showToast(BRAIN+' /reasoning — status unavailable');}); + return true; + } + if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){ + const on=(arg==='show'||arg==='on'); + // Update the UI render gate immediately for responsiveness. + window._showThinking=on; + if(typeof renderMessages==='function') renderMessages(); + // Persist via /api/reasoning → config.yaml display.show_reasoning + // (CLI reads the same key). Also mirror into WebUI settings.json + // show_thinking so boot.js picks it up on reload without hitting + // /api/reasoning on every page load. + api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){}); + api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){}); + showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)'); + return true; + } + if(EFFORTS.includes(arg)){ + // Persist via /api/reasoning → config.yaml agent.reasoning_effort. + // Takes effect on the NEXT session/turn (agent re-reads config at + // construction time), matching CLI semantics where `/reasoning high` + // also forces an agent re-init. + api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})}) + .then(function(st){ + const eff=(st && st.reasoning_effort)||arg; + showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)'); + }) + .catch(function(e){ + showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg)); + }); + return true; + } + showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|')); + return true; +} function cmdVoice(){ const mic=document.getElementById('btnMic'); if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}} diff --git a/static/i18n.js b/static/i18n.js index 9c95fb0..bdeec05 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -195,6 +195,7 @@ const LOCALES = { settings_label_language: 'Language', settings_label_token_usage: 'Show token usage', settings_label_sidebar_density: 'Sidebar density', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: 'Show agent sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', @@ -626,6 +627,7 @@ const LOCALES = { settings_label_language: 'Язык', settings_label_token_usage: 'Показывать использование токенов', settings_label_sidebar_density: 'Плотность боковой панели', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: 'Показывать сеансы агента', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', @@ -1085,6 +1087,7 @@ const LOCALES = { settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', settings_label_sidebar_density: 'Densidad de la barra lateral', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: 'Mostrar sesiones de CLI', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', @@ -1516,6 +1519,7 @@ const LOCALES = { settings_label_language: 'Sprache', settings_label_token_usage: 'Token-Verbrauch anzeigen', settings_label_sidebar_density: 'Seitenleistendichte', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: 'Agent-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', @@ -1748,6 +1752,7 @@ const LOCALES = { settings_label_language: '\u8bed\u8a00', settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', settings_label_sidebar_density: '侧边栏密度', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0', @@ -2163,6 +2168,7 @@ const LOCALES = { settings_label_language: '\u8a9d\u8a00', settings_label_token_usage: '\u986f\u793a token \u7528\u91cf', settings_label_sidebar_density: '側邊欄密度', + cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level', settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71', settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', diff --git a/static/messages.js b/static/messages.js index 38818fd..cbff2d7 100644 --- a/static/messages.js +++ b/static/messages.js @@ -295,6 +295,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return {thinkingText:'', displayText:raw, inThinking:false}; } function _renderLiveThinking(parsed){ + if(window._showThinking===false){removeThinking();return;} const text=(parsed&&parsed.thinkingText)||''; if(text||(parsed&&parsed.inThinking)){ if(typeof updateThinking==='function') updateThinking(text||'Thinking…'); diff --git a/static/panels.js b/static/panels.js index 514e257..a18aae1 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1272,6 +1272,7 @@ async function loadSettingsPanel(){ if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});} const notifCb=$('settingsNotificationsEnabled'); if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});} + // show_thinking has no settings panel checkbox — controlled via /reasoning show|hide const sidebarDensitySel=$('settingsSidebarDensity'); if(sidebarDensitySel){ sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact'; @@ -1309,6 +1310,7 @@ function _applySavedSettingsUi(saved, body, opts){ window._showCliSessions=showCliSessions; window._soundEnabled=body.sound_enabled; window._notificationsEnabled=body.notifications_enabled; + window._showThinking=body.show_thinking!==false; window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact'; window._botName=body.bot_name||'Hermes'; if(typeof applyBotName==='function') applyBotName(); @@ -1353,6 +1355,7 @@ async function saveSettings(andClose){ body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked; body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked; + body.show_thinking=window._showThinking!==false; body.sidebar_density=sidebarDensity; const botName=(($('settingsBotName')||{}).value||'').trim(); body.bot_name=botName||'Hermes'; diff --git a/static/ui.js b/static/ui.js index 1256782..93dc52f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1573,7 +1573,7 @@ function renderMessages(){ seg.setAttribute('data-live-assistant','1'); } if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1'; - if(thinkingText) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText)); + if(thinkingText&&window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText)); const hasVisibleBody=!!(String(content||'').trim()||filesHtml); if(hasVisibleBody){ seg.insertAdjacentHTML('beforeend', `${filesHtml}
${bodyHtml}
${footHtml}`); diff --git a/tests/test_issue632.py b/tests/test_issue632.py index dde8b73..983b6bf 100644 --- a/tests/test_issue632.py +++ b/tests/test_issue632.py @@ -18,13 +18,19 @@ BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") -def test_subarg_registry_exists_without_promoting_reasoning_to_builtin(): +def test_subarg_registry_exists_and_reasoning_is_promoted_to_builtin(): + # SLASH_SUBARG_SOURCES still exists for model and personality assert "const SLASH_SUBARG_SOURCES=" in COMMANDS_JS - assert "reasoning:{desc:'Set reasoning effort', subArgs:['low','medium','high']}" in COMMANDS_JS - assert "{name:'reasoning'" not in COMMANDS_JS, \ - "/reasoning suggestions must not register as a local built-in command" - assert "source:'subarg-command'" in COMMANDS_JS, \ - "top-level autocomplete should still surface subarg-only commands like /reasoning" + # /reasoning is now a proper builtin command with a fn: handler (cmdReasoning) + # so it is in the COMMANDS array, not SLASH_SUBARG_SOURCES + assert "{name:'reasoning'" in COMMANDS_JS, \ + "/reasoning must be registered as a local built-in command with fn: handler" + assert "fn:cmdReasoning" in COMMANDS_JS, \ + "/reasoning entry must reference cmdReasoning function" + assert "function cmdReasoning" in COMMANDS_JS, \ + "cmdReasoning function must be defined" + # source:'subarg-command' is still used for model/personality in SLASH_SUBARG_SOURCES + assert "source:'subarg-command'" in COMMANDS_JS def test_model_and_personality_subargs_load_from_existing_apis(): diff --git a/tests/test_reasoning_show_hide.py b/tests/test_reasoning_show_hide.py new file mode 100644 index 0000000..7591aaf --- /dev/null +++ b/tests/test_reasoning_show_hide.py @@ -0,0 +1,397 @@ +"""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') + + +# ── 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 + + +# ── 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()" + ) + # 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 `. + 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'): + 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' + ): + 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' + ) + + 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') + import pytest as _pt + with _pt.raises(ValueError): + cfg.set_reasoning_effort('garbage') + with _pt.raises(ValueError): + 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 "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" + )