diff --git a/static/commands.js b/static/commands.js index 49b484cd..88879116 100644 --- a/static/commands.js +++ b/static/commands.js @@ -48,6 +48,105 @@ function parseCommand(text){ return {name,args}; } +const DESKTOP_COMPANION_EXTENSION_ID='desktop-companion'; +const DESKTOP_COMPANION_NAME='Desktop Companion'; +const DESKTOP_COMPANION_INSTALL_PATH='Settings -> Extensions -> Gallery -> Desktop Companion'; +const DESKTOP_COMPANION_SETUP_GUIDE_URL='https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install'; +const DESKTOP_COMPANION_LOCAL_APP_LABEL='Desktop Companion app'; + +function _getDesktopCompanionStatusGlobal(){ + if(typeof window==='undefined') return null; + return window.__HERMES_WEBUI_DESKTOP_COMPANION_STATUS__||null; +} + +function _getDesktopCompanionExtensionStatus(status){ + return Array.isArray(status&&status.extensions) + ? status.extensions.find(ext=>String(ext&&ext.id||'')===DESKTOP_COMPANION_EXTENSION_ID)||null + : null; +} + +function _petSlashCommandArgs(rawCommandText){ + const parsed=parseCommand(String(rawCommandText||'').trim()); + return parsed&&parsed.name==='pet' ? parsed.args : String(rawCommandText||'').trim().replace(/^\/pet\b\s*/i,'').trim(); +} + +function _desktopCompanionMissingMessage(){ + return `${DESKTOP_COMPANION_NAME} is not installed yet. + +Install it from ${DESKTOP_COMPANION_INSTALL_PATH}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}. + +The guide also shows how to start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`; +} + +function _desktopCompanionDisabledMessage(){ + return `${DESKTOP_COMPANION_NAME} is installed but disabled. + +Enable it in Settings -> Extensions, then reload WebUI if you just changed that and start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}. + +Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`; +} + +function _desktopCompanionReloadMessage(){ + return `${DESKTOP_COMPANION_NAME} is enabled, but the adapter status is not loaded yet. + +Reload WebUI if you just enabled it, then start the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}.`; +} + +function _desktopCompanionConnectMessage(){ + return `${DESKTOP_COMPANION_NAME} is enabled, but the local app is not connected yet. + +Start or connect the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then retry /pet. + +Setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}`; +} + +function _desktopCompanionUnavailableMessage(){ + return `${DESKTOP_COMPANION_NAME} is installed and connected, but /pet is not available yet in this Desktop Companion version. + +Update the ${DESKTOP_COMPANION_LOCAL_APP_LABEL}, then follow the setup guide: ${DESKTOP_COMPANION_SETUP_GUIDE_URL}.`; +} + +async function handlePetSlashCommand(rawCommandText,meta){ + const command=String(rawCommandText||''); + const commandName=String((meta&&meta.name)||'pet').trim()||'pet'; + const args=_petSlashCommandArgs(command); + let status=null; + try{ + status=await api('/api/extensions/status'); + }catch(_e){ + status=null; + } + const companion=_getDesktopCompanionExtensionStatus(status); + if(!companion){ + return {handled:false,message:_desktopCompanionMissingMessage()}; + } + if(companion.effective_enabled!==true){ + return {handled:false,message:_desktopCompanionDisabledMessage()}; + } + const companionStatus=_getDesktopCompanionStatusGlobal(); + if(!companionStatus){ + return {handled:false,message:_desktopCompanionReloadMessage()}; + } + if(companionStatus.connected!==true){ + return {handled:false,message:_desktopCompanionConnectMessage()}; + } + const hook=typeof window!=='undefined'&&window.__hermesHandlePetSlashCommand; + if(typeof hook==='function'){ + try{ + const result=await hook({ + command, + args, + source:'webui-slash-command', + metadata:{name:commandName}, + }); + if(result===true||(result&&result.handled===true)){ + return {handled:true,message:''}; + } + }catch(_e){} + } + return {handled:false,message:_desktopCompanionUnavailableMessage()}; +} + function executeCommand(text){ const parsed=parseCommand(text); if(!parsed)return null; @@ -76,11 +175,22 @@ function getMatchingCommands(prefix){ }); seen.add(name); } + if('pet'.startsWith(q)&&!seen.has('pet')){ + const petMeta=Array.isArray(_agentCommandCache) + ? _agentCommandCache.find(cmd=>String(cmd&&cmd.name||'').toLowerCase()==='pet') + : null; + matches.push({ + name:'pet', + desc:String((petMeta&&petMeta.description)||'Desktop Companion command').trim()||'Desktop Companion command', + source:'agent', + }); + seen.add('pet'); + } // Include agent/plugin commands from /api/commands metadata for(const cmd of (_agentCommandCache||[])){ const name=String(cmd&&cmd.name||'').toLowerCase(); if(!name.startsWith(q)||seen.has(name))continue; - if(cmd.cli_only)continue; + if(cmd.cli_only&&name!=='pet')continue; matches.push({ name, desc:String(cmd&&cmd.description||'').trim()||'Agent command', diff --git a/static/messages.js b/static/messages.js index de17fa88..c85d6e58 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1351,6 +1351,23 @@ async function send(){ } } if(_parsedCmd&&!_cmd){ + if(_parsedCmd.name==='pet'){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + let _petOutput=null; + try{ + _petOutput=typeof handlePetSlashCommand==='function' + ? await handlePetSlashCommand(text,{name:'pet'}) + : {handled:false,message:'Desktop Companion is unavailable in WebUI.'}; + }catch(e){ + _petOutput={handled:false,message:`Desktop Companion command error: ${e&&e.message||e}`}; + } + if(_petOutput&&_petOutput.message){ + S.messages.push({role:'assistant',content:String(_petOutput.message),_ts:Date.now()/1000}); + } + renderMessages(); + $('msg').value='';autoResize();hideCmdDropdown();return; + } const _agentCmd=typeof getAgentCommandMetadata==='function' ? await getAgentCommandMetadata(_parsedCmd.name) : null; diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index c8da5c7e..35e0d01d 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -300,6 +300,8 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): await loadAgentCommandMetadata(true); await loadBundleCommands(true); await loadSkillCommands(true); + const pet = await getSlashAutocompleteMatches('/pet'); + const browser = await getSlashAutocompleteMatches('/bro'); const handoff = await getSlashAutocompleteMatches('/handoff'); const delegate = await getSlashAutocompleteMatches('/delegate'); const incident = await getSlashAutocompleteMatches('/incident'); @@ -308,6 +310,9 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): const skills = await getSlashAutocompleteMatches('/skills'); const use = await getSlashAutocompleteMatches('/use'); return { + pet_names: pet.map(item => item.name), + pet_sources: pet.map(item => item.source), + browser_names: browser.map(item => item.name), handoff_names: handoff.map(item => item.name), delegate_names: delegate.map(item => item.name), incident_names: incident.map(item => item.name), @@ -322,6 +327,9 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): """ ) + assert result["pet_names"] == ["pet"] + assert result["pet_sources"] == ["agent"] + assert result["browser_names"] == [] assert result["handoff_names"] == [] assert result["delegate_names"] == [] assert result["incident_names"] == ["incident-review"] diff --git a/tests/test_issue4843_pet_webui_command.py b/tests/test_issue4843_pet_webui_command.py new file mode 100644 index 00000000..a593d981 --- /dev/null +++ b/tests/test_issue4843_pet_webui_command.py @@ -0,0 +1,244 @@ +"""Regression tests for the WebUI /pet handoff.""" + +import json +from pathlib import Path +import subprocess +import tempfile +import textwrap + + +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 _run_pet_js(*, status, adapter_status=None, hook_result=None, hook_throws=False, command="/pet feed tuna"): + hook_setup = "" + if hook_throws: + hook_setup += textwrap.dedent( + """ + ctx.window.__hermesHandlePetSlashCommand = async payload => { + hookCalls.push(payload); + throw new Error('hook failed'); + }; + """ + ) + elif hook_result is not None: + hook_setup += textwrap.dedent( + f""" + ctx.window.__hermesHandlePetSlashCommand = async payload => {{ + hookCalls.push(payload); + return {json.dumps(hook_result)}; + }}; + """ + ) + + script = textwrap.dedent( + f""" + const vm = require('vm'); + const hookCalls = []; + const window = {{ + __HERMES_WEBUI_DESKTOP_COMPANION_STATUS__: {json.dumps(adapter_status)}, + }}; + window.window = window; + const ctx = {{ + console, + window, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: key => key, + api: async path => {{ + if (path === '/api/extensions/status') return {json.dumps(status)}; + throw new Error('unexpected api path: ' + path); + }}, + }}; + {hook_setup} + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ return await handlePetSlashCommand({json.dumps(command)}, {{name:'pet'}}); }})()`, ctx); + process.stdout.write(JSON.stringify({{result, hookCalls}})); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = Path(handle.name) + try: + proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True) + finally: + script_path.unlink(missing_ok=True) + return json.loads(proc.stdout) + + +def test_pet_help_routes_to_install_guidance_when_companion_is_missing(): + result = _run_pet_js( + status={"enabled": False, "extensions": [], "gallery_installed": {}}, + adapter_status=None, + ) + + assert result["result"]["handled"] is False + message = result["result"]["message"] + assert "Desktop Companion is not installed yet." in message + assert "Settings -> Extensions -> Gallery -> Desktop Companion" in message + assert "https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install" in message + assert "Desktop Companion app" in message + + +def test_pet_help_routes_to_enable_guidance_when_companion_is_disabled(): + result = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": False, + "user_disabled": True, + "status": "user_disabled", + } + ], + }, + adapter_status=None, + ) + + assert result["result"]["handled"] is False + message = result["result"]["message"] + assert "Desktop Companion is installed but disabled." in message + assert "Enable it in Settings -> Extensions" in message + assert "Desktop Companion app" in message + assert "https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install" in message + + +def test_pet_help_routes_to_reload_and_start_guidance_when_adapter_status_is_missing(): + result = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": True, + "user_disabled": False, + "status": "enabled", + } + ], + }, + adapter_status=None, + ) + + assert result["result"]["handled"] is False + message = result["result"]["message"] + assert "adapter status is not loaded yet" in message + assert "Reload WebUI if you just enabled it" in message + assert "Desktop Companion app" in message + + +def test_pet_help_routes_to_connect_guidance_when_adapter_is_not_connected(): + result = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": True, + "user_disabled": False, + "status": "enabled", + } + ], + }, + adapter_status={"connected": False}, + ) + + assert result["result"]["handled"] is False + message = result["result"]["message"] + assert "local app is not connected yet" in message + assert "Start or connect the Desktop Companion app" in message + assert "Setup guide:" in message + + +def test_pet_help_hands_off_to_desktop_companion_hook_when_connected(): + result = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": True, + "user_disabled": False, + "status": "enabled", + } + ], + }, + adapter_status={"connected": True}, + hook_result={"handled": True, "message": "mascot handled"}, + command="/pet feed tuna ", + ) + + assert result["result"] == {"handled": True, "message": ""} + assert result["hookCalls"] == [ + { + "command": "/pet feed tuna ", + "args": "feed tuna", + "source": "webui-slash-command", + "metadata": {"name": "pet"}, + } + ] + + +def test_pet_help_falls_back_when_hook_is_missing_or_fails(): + absent = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": True, + "user_disabled": False, + "status": "enabled", + } + ], + }, + adapter_status={"connected": True}, + ) + failed = _run_pet_js( + status={ + "enabled": True, + "extensions": [ + { + "id": "desktop-companion", + "name": "Desktop Companion", + "effective_enabled": True, + "user_disabled": False, + "status": "enabled", + } + ], + }, + adapter_status={"connected": True}, + hook_throws=True, + ) + + for result in (absent, failed): + message = result["result"]["message"] + assert result["result"]["handled"] is False + assert "Desktop Companion is installed and connected" in message + assert "/pet is not available yet in this Desktop Companion version" in message + assert "Update the Desktop Companion app" in message + assert "https://github.com/franksong2702/hermes-webui-desktop-companion#after-gallery-install" in message + + +def test_pet_slash_intercept_bypasses_generic_agent_execution(): + intercept_idx = MESSAGES_JS.find("Slash command intercept") + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + assert intercept_idx != -1 + assert normal_send_idx != -1 + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "if(_parsedCmd.name==='pet')" in intercept + assert "handlePetSlashCommand(text,{name:'pet'})" in intercept + assert "executeAgentCommand(text,_agentCmd||{name:_agentCmdName})" in intercept