From 5c5ca7d2ef381a2e5bf5c42fda73d912f6d2b802 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 1 May 2026 04:46:30 +0000 Subject: [PATCH] Intercept CLI-only slash commands in WebUI (#1382) --- static/commands.js | 40 ++++++ static/messages.js | 12 ++ tests/test_cli_only_slash_commands.py | 194 ++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 tests/test_cli_only_slash_commands.py diff --git a/static/commands.js b/static/commands.js index 431c079d..5a4f79c0 100644 --- a/static/commands.js +++ b/static/commands.js @@ -84,6 +84,8 @@ let _slashModelCache=null; let _slashModelCachePromise=null; let _slashPersonalityCache=null; let _slashPersonalityCachePromise=null; +let _agentCommandCache=null; +let _agentCommandCachePromise=null; function _normalizeSlashSubArg(value){ return String(value||'').trim(); @@ -162,6 +164,44 @@ function _getSlashSubArgOptions(spec){ return Promise.resolve([]); } +async function loadAgentCommandMetadata(force=false){ + if(_agentCommandCache&&!force) return _agentCommandCache; + if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise; + _agentCommandCachePromise=(async()=>{ + try{ + const data=await api('/api/commands'); + _agentCommandCache=Array.isArray(data&&data.commands)?data.commands:[]; + }catch(_){ + _agentCommandCache=[]; + }finally{ + _agentCommandCachePromise=null; + } + return _agentCommandCache; + })(); + return _agentCommandCachePromise; +} + +async function getAgentCommandMetadata(name){ + const needle=String(name||'').trim().toLowerCase(); + if(!needle) return null; + const commands=await loadAgentCommandMetadata(); + return commands.find(cmd=>{ + if(String(cmd&&cmd.name||'').toLowerCase()===needle) return true; + return Array.isArray(cmd&&cmd.aliases)&&cmd.aliases.some(a=>String(a||'').toLowerCase()===needle); + })||null; +} + +function cliOnlyCommandResponse(cmdName, meta){ + const name=String((meta&&meta.name)||cmdName||'').trim(); + const desc=String((meta&&meta.description)||'').trim(); + const detail=desc?`\n\n${desc}`:''; + let extra=''; + if(name==='browser'){ + extra='\n\nBrowser tools in WebUI must be configured server-side with the agent/browser environment. Once configured, ask the model to use browser tools directly; `/browser` itself only works in `hermes chat`.'; + } + return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`; +} + function _parseSlashAutocomplete(text){ if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null; const raw=text.slice(1); diff --git a/static/messages.js b/static/messages.js index 7bbf8c19..c05d4dd5 100644 --- a/static/messages.js +++ b/static/messages.js @@ -132,6 +132,18 @@ async function send(){ $('msg').value='';autoResize();hideCmdDropdown();return; } } + if(_parsedCmd&&!_cmd){ + const _agentCmd=typeof getAgentCommandMetadata==='function' + ? await getAgentCommandMetadata(_parsedCmd.name) + : null; + if(_agentCmd&&_agentCmd.cli_only){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + S.messages.push({role:'assistant',content:cliOnlyCommandResponse(_parsedCmd.name,_agentCmd),_ts:Date.now()/1000}); + renderMessages(); + $('msg').value='';autoResize();hideCmdDropdown();return; + } + } } if(!S.session){await newSession();await renderSessionList();} diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py new file mode 100644 index 00000000..b1449d32 --- /dev/null +++ b/tests/test_cli_only_slash_commands.py @@ -0,0 +1,194 @@ +"""Regression tests for WebUI handling of Hermes CLI-only slash commands.""" + +import json +from pathlib import Path +import subprocess +import textwrap +from types import SimpleNamespace + +from api.commands import list_commands + + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def test_api_commands_exposes_cli_only_metadata_for_webui_intercept(): + """CLI-only commands must remain visible so the frontend can explain them.""" + registry = [ + SimpleNamespace( + name="browser", + description="Attach browser tools", + category="tools", + aliases=["browse"], + args_hint="connect", + subcommands=["connect"], + cli_only=True, + gateway_only=False, + ) + ] + + body = list_commands(registry) + + assert body == [ + { + "name": "browser", + "description": "Attach browser tools", + "category": "tools", + "aliases": ["browse"], + "args_hint": "connect", + "subcommands": ["connect"], + "cli_only": True, + "gateway_only": False, + } + ] + + +def test_frontend_fetches_agent_command_metadata_lazily(): + assert "async function loadAgentCommandMetadata" in COMMANDS_JS + assert "api('/api/commands')" in COMMANDS_JS + assert "_agentCommandCache" in COMMANDS_JS + + +def test_frontend_matches_agent_command_aliases(): + helper_idx = COMMANDS_JS.find("async function getAgentCommandMetadata") + assert helper_idx != -1 + helper = COMMANDS_JS[helper_idx : helper_idx + 700] + assert "cmd.aliases" in helper + assert "some(a=>String(a||'').toLowerCase()===needle)" in helper + + +def test_cli_only_response_mentions_webui_and_cli_scope(): + assert "function cliOnlyCommandResponse" in COMMANDS_JS + assert "Hermes CLI-only command" in COMMANDS_JS + assert "cannot run inside the WebUI" in COMMANDS_JS + + +def test_browser_cli_only_response_explains_server_side_browser_tools(): + response_idx = COMMANDS_JS.find("function cliOnlyCommandResponse") + response = COMMANDS_JS[response_idx : response_idx + 900] + assert "if(name==='browser')" in response + assert "configured server-side" in response + assert "`/browser` itself only works in `hermes chat`" in response + + +def _run_commands_js(script_body: str) -> dict: + script = textwrap.dedent( + f""" + const vm = require('vm'); + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path !== '/api/commands') throw new Error('unexpected api path: ' + path); + return {{ + commands: [ + {{ + name: 'browser', + description: 'Attach browser tools', + aliases: ['browse'], + cli_only: true, + gateway_only: false + }}, + {{ + name: 'model', + description: 'Change model', + aliases: [], + cli_only: false, + gateway_only: false + }} + ] + }}; + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ {script_body} }})()`, ctx); + process.stdout.write(JSON.stringify(result)); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(proc.stdout) + + +def test_agent_command_metadata_helper_resolves_name_and_alias(): + result = _run_commands_js( + """ + const byName = await getAgentCommandMetadata('browser'); + const byAlias = await getAgentCommandMetadata('browse'); + const unknown = await getAgentCommandMetadata('does-not-exist'); + return { + by_name: byName && byName.name, + by_alias: byAlias && byAlias.name, + cli_only: byAlias && byAlias.cli_only === true, + unknown: unknown === null + }; + """ + ) + + assert result == { + "by_name": "browser", + "by_alias": "browser", + "cli_only": True, + "unknown": True, + } + + +def test_cli_only_response_helper_uses_canonical_command_name(): + result = _run_commands_js( + """ + const meta = await getAgentCommandMetadata('browse'); + return { + response: cliOnlyCommandResponse('browse', meta) + }; + """ + ) + + assert "`/browser` is a Hermes CLI-only command" in result["response"] + assert "Attach browser tools" in result["response"] + assert "configured server-side" in result["response"] + + +def test_send_intercepts_cli_only_commands_before_agent_round_trip(): + intercept_idx = MESSAGES_JS.find("Slash command intercept") + assert intercept_idx != -1 + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + assert normal_send_idx != -1 + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "await getAgentCommandMetadata(_parsedCmd.name)" in intercept + assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept + assert "cliOnlyCommandResponse(_parsedCmd.name,_agentCmd)" in intercept + assert "return;" in intercept + + +def test_unknown_slash_commands_still_fall_through_to_agent(): + """Only known cli_only commands should be intercepted.""" + intercept_idx = MESSAGES_JS.find("Slash command intercept") + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept + assert "if(_parsedCmd&&!_cmd)" in intercept + assert "if(!_agentCmd" not in intercept + assert "else" not in intercept[intercept.find("if(_agentCmd&&_agentCmd.cli_only)") :] + + +def test_builtin_command_opt_outs_do_not_hit_agent_metadata_lookup(): + """Built-in fall-through commands like /reasoning high keep their old path.""" + intercept_idx = MESSAGES_JS.find("Slash command intercept") + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + optout_idx = intercept.find("if(_cmd.fn(_parsedCmd.args)===false)") + metadata_idx = intercept.find("await getAgentCommandMetadata(_parsedCmd.name)") + + assert optout_idx != -1 + assert metadata_idx != -1 + assert "if(_parsedCmd&&!_cmd)" in intercept[optout_idx:metadata_idx + 120]