feat(#4843): add WebUI pet extension handoff

This commit is contained in:
Rod Boev 2026-06-28 19:32:42 -04:00
parent 488894e755
commit 25d213cfe9
4 changed files with 380 additions and 1 deletions

View file

@ -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',

View file

@ -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;

View file

@ -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"]

View file

@ -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