feat(reasoning): full /reasoning CLI parity — show|hide + effort levels via config.yaml (#812)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #461 Adds full /reasoning CLI parity to the WebUI slash command system: - /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js - /reasoning hide|off → same in reverse; re-renders immediately - /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics) - /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml - Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh - Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across - Boot hydration: window._showThinking initialised from settings.json show_thinking on page load - Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError 28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": <level>}`` 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 <level>``: 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})?$")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 <level>`) 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')
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(_){}}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
|
||||
@@ -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():
|
||||
|
||||
397
tests/test_reasoning_show_hide.py
Normal file
397
tests/test_reasoning_show_hide.py
Normal file
@@ -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 <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'):
|
||||
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"
|
||||
)
|
||||
Reference in New Issue
Block a user