mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
fix(commands): keep slash command ownership ahead of bundles (#4087)
This commit is contained in:
+10
-11
@@ -76,16 +76,6 @@ function getMatchingCommands(prefix){
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
for(const bundle of _bundleCommandCache){
|
||||
if(!bundle.name.startsWith(q)||seen.has(bundle.name)||reserved.has(bundle.name))continue;
|
||||
matches.push(bundle);
|
||||
seen.add(bundle.name);
|
||||
}
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
}
|
||||
// Include agent/plugin commands from /api/commands metadata
|
||||
for(const cmd of (_agentCommandCache||[])){
|
||||
const name=String(cmd&&cmd.name||'').toLowerCase();
|
||||
@@ -98,6 +88,16 @@ function getMatchingCommands(prefix){
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
for(const bundle of _bundleCommandCache){
|
||||
if(!bundle.name.startsWith(q)||seen.has(bundle.name)||reserved.has(bundle.name))continue;
|
||||
matches.push(bundle);
|
||||
seen.add(bundle.name);
|
||||
}
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
@@ -1551,7 +1551,6 @@ function _skillCommandSlug(name){
|
||||
function _getReservedSlashCommandSlugs(){
|
||||
const reserved=new Set(COMMANDS.map(c=>String(c&&c.name||'').trim().toLowerCase()).filter(Boolean));
|
||||
for(const cmd of (_agentCommandCache||[])){
|
||||
if(!(cmd&&cmd.cli_only)) continue;
|
||||
const names=[cmd.name].concat(Array.isArray(cmd&&cmd.aliases)?cmd.aliases:[]);
|
||||
for(const name of names){
|
||||
const slug=_skillCommandSlug(name);
|
||||
|
||||
+20
-20
@@ -999,26 +999,6 @@ async function send(){
|
||||
}
|
||||
}
|
||||
if(_parsedCmd&&!_cmd){
|
||||
const _bundleCmd=typeof getBundleCommandMetadata==='function'
|
||||
? await getBundleCommandMetadata(_parsedCmd.name)
|
||||
: null;
|
||||
if(_bundleCmd){
|
||||
try{
|
||||
const _bundleResolved=typeof resolveBundleCommand==='function'
|
||||
? await resolveBundleCommand(text,_bundleCmd)
|
||||
: null;
|
||||
const _bundleMessage=String(_bundleResolved&&_bundleResolved.message||'').trim();
|
||||
if(!_bundleMessage) throw new Error('Bundle command runtime returned no invocation text.');
|
||||
_slashDisplayTextOverride=text;
|
||||
text=_bundleMessage;
|
||||
}catch(e){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
|
||||
S.messages.push({role:'assistant',content:`Bundle command error: ${e&&e.message||e}`,_ts:Date.now()/1000});
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
const _agentCmd=typeof getAgentCommandMetadata==='function'
|
||||
? await getAgentCommandMetadata(_parsedCmd.name)
|
||||
: null;
|
||||
@@ -1060,6 +1040,26 @@ async function send(){
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function'
|
||||
? await getBundleCommandMetadata(_parsedCmd.name)
|
||||
: null;
|
||||
if(_bundleCmd){
|
||||
try{
|
||||
const _bundleResolved=typeof resolveBundleCommand==='function'
|
||||
? await resolveBundleCommand(text,_bundleCmd)
|
||||
: null;
|
||||
const _bundleMessage=String(_bundleResolved&&_bundleResolved.message||'').trim();
|
||||
if(!_bundleMessage) throw new Error('Bundle command runtime returned no invocation text.');
|
||||
_slashDisplayTextOverride=text;
|
||||
text=_bundleMessage;
|
||||
}catch(e){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
S.messages.push({role:'user',content:text,_ts:Date.now()/1000});
|
||||
S.messages.push({role:'assistant',content:`Bundle command error: ${e&&e.message||e}`,_ts:Date.now()/1000});
|
||||
renderMessages();
|
||||
$('msg').value='';autoResize();hideCmdDropdown();return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
|
||||
@@ -103,6 +103,7 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'browser',
|
||||
description: 'Attach browser tools',
|
||||
category: 'Tools',
|
||||
aliases: ['browse'],
|
||||
cli_only: true,
|
||||
gateway_only: false
|
||||
@@ -110,6 +111,7 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'handoff',
|
||||
description: 'Hand work to another agent',
|
||||
category: 'Tools',
|
||||
aliases: ['delegate_work'],
|
||||
cli_only: true,
|
||||
gateway_only: false
|
||||
@@ -117,6 +119,7 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'model',
|
||||
description: 'Change model',
|
||||
category: 'Tools',
|
||||
aliases: [],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
@@ -124,6 +127,7 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'codex-runtime',
|
||||
description: 'Toggle Codex app-server runtime',
|
||||
category: 'Tools',
|
||||
aliases: ['codex_runtime'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
@@ -131,9 +135,26 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'reload-skills',
|
||||
description: 'Re-scan installed skills',
|
||||
category: 'Tools',
|
||||
aliases: ['reload_skills'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'triage-review',
|
||||
description: 'Run runtime triage review',
|
||||
category: 'Tools',
|
||||
aliases: ['triage_review'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}},
|
||||
{{
|
||||
name: 'plugin-review',
|
||||
description: 'Run plugin review',
|
||||
category: 'Plugin',
|
||||
aliases: ['plugin_review'],
|
||||
cli_only: false,
|
||||
gateway_only: false
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -150,6 +171,18 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
description: 'Bundle should beat a same-slug plain skill',
|
||||
skill_count: 3,
|
||||
source: 'bundle'
|
||||
}},
|
||||
{{
|
||||
name: 'triage-review',
|
||||
description: 'Bundle collision should stay hidden behind runtime slash names',
|
||||
skill_count: 4,
|
||||
source: 'bundle'
|
||||
}},
|
||||
{{
|
||||
name: 'plugin-review',
|
||||
description: 'Bundle collision should stay hidden behind plugin slash names',
|
||||
skill_count: 5,
|
||||
source: 'bundle'
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -166,6 +199,14 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'incident review',
|
||||
description: 'Non-colliding skills should still autocomplete'
|
||||
}},
|
||||
{{
|
||||
name: 'triage review',
|
||||
description: 'Runtime collisions should stay hidden from slash autocomplete'
|
||||
}},
|
||||
{{
|
||||
name: 'plugin review',
|
||||
description: 'Plugin collisions should stay hidden from slash autocomplete'
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -262,6 +303,8 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
const handoff = await getSlashAutocompleteMatches('/handoff');
|
||||
const delegate = await getSlashAutocompleteMatches('/delegate');
|
||||
const incident = await getSlashAutocompleteMatches('/incident');
|
||||
const triage = await getSlashAutocompleteMatches('/triage');
|
||||
const plugin = await getSlashAutocompleteMatches('/plugin');
|
||||
const skills = await getSlashAutocompleteMatches('/skills');
|
||||
const use = await getSlashAutocompleteMatches('/use');
|
||||
return {
|
||||
@@ -269,6 +312,10 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
delegate_names: delegate.map(item => item.name),
|
||||
incident_names: incident.map(item => item.name),
|
||||
incident_sources: incident.map(item => item.source),
|
||||
triage_names: triage.map(item => item.name),
|
||||
triage_sources: triage.map(item => item.source),
|
||||
plugin_names: plugin.map(item => item.name),
|
||||
plugin_sources: plugin.map(item => item.source),
|
||||
skills_names: skills.map(item => item.name),
|
||||
use_names: use.map(item => item.name)
|
||||
};
|
||||
@@ -279,6 +326,10 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
assert result["delegate_names"] == []
|
||||
assert result["incident_names"] == ["incident-review"]
|
||||
assert result["incident_sources"] == ["bundle"]
|
||||
assert result["triage_names"] == ["triage-review"]
|
||||
assert result["triage_sources"] == ["agent"]
|
||||
assert result["plugin_names"] == ["plugin-review"]
|
||||
assert result["plugin_sources"] == ["plugin"]
|
||||
assert "skills" in result["skills_names"]
|
||||
assert "use" in result["use_names"]
|
||||
|
||||
@@ -302,12 +353,25 @@ def test_send_intercepts_bundle_commands_before_agent_round_trip():
|
||||
assert normal_send_idx != -1
|
||||
intercept = MESSAGES_JS[intercept_idx:normal_send_idx]
|
||||
|
||||
assert "await getBundleCommandMetadata(_parsedCmd.name)" in intercept
|
||||
assert "const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function'" in intercept
|
||||
assert "await resolveBundleCommand(text,_bundleCmd)" in intercept
|
||||
assert "_slashDisplayTextOverride=text;" in intercept
|
||||
assert "text=_bundleMessage;" in intercept
|
||||
|
||||
|
||||
def test_send_consults_agent_metadata_before_bundle_resolution():
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
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]
|
||||
|
||||
agent_idx = intercept.find("await getAgentCommandMetadata(_parsedCmd.name)")
|
||||
bundle_idx = intercept.find("await getBundleCommandMetadata(_parsedCmd.name)")
|
||||
assert agent_idx != -1
|
||||
assert bundle_idx != -1
|
||||
assert agent_idx < bundle_idx
|
||||
|
||||
|
||||
def test_send_intercepts_reload_mcp_agent_command_before_agent_round_trip():
|
||||
intercept_idx = MESSAGES_JS.find("Slash command intercept")
|
||||
normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx)
|
||||
|
||||
@@ -38,10 +38,18 @@ def test_bundle_routes_are_wired_through_dedicated_endpoints():
|
||||
def test_frontend_bundle_dispatch_uses_dedicated_metadata_and_resolve_calls():
|
||||
assert "api('/api/commands/bundles')" in COMMANDS_JS
|
||||
assert "api('/api/commands/bundles/resolve'" in COMMANDS_JS
|
||||
assert "await getBundleCommandMetadata(_parsedCmd.name)" in MESSAGES_JS
|
||||
assert "const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function'" in MESSAGES_JS
|
||||
assert "await resolveBundleCommand(text,_bundleCmd)" in MESSAGES_JS
|
||||
|
||||
|
||||
def test_frontend_checks_agent_ownership_before_bundle_resolution():
|
||||
agent_idx = MESSAGES_JS.find("await getAgentCommandMetadata(_parsedCmd.name)")
|
||||
bundle_idx = MESSAGES_JS.find("await getBundleCommandMetadata(_parsedCmd.name)")
|
||||
assert agent_idx != -1
|
||||
assert bundle_idx != -1
|
||||
assert agent_idx < bundle_idx
|
||||
|
||||
|
||||
def test_list_command_bundles_returns_bundle_metadata(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
|
||||
@@ -67,13 +67,24 @@ def test_builtin_commands_take_precedence_over_skill_slug_collisions():
|
||||
|
||||
|
||||
def test_bundle_entries_merge_before_plain_skill_entries():
|
||||
agent_loop = COMMANDS_JS.find("for(const cmd of (_agentCommandCache||[])){")
|
||||
bundle_loop = COMMANDS_JS.find("for(const bundle of _bundleCommandCache){")
|
||||
skill_loop = COMMANDS_JS.find("for(const skill of _skillCommandCache){")
|
||||
assert agent_loop != -1
|
||||
assert bundle_loop != -1
|
||||
assert skill_loop != -1
|
||||
assert agent_loop < bundle_loop
|
||||
assert bundle_loop < skill_loop
|
||||
|
||||
|
||||
def test_reserved_slugs_include_all_agent_and_plugin_aliases():
|
||||
reserved_idx = COMMANDS_JS.find("function _getReservedSlashCommandSlugs(){")
|
||||
assert reserved_idx != -1
|
||||
reserved = COMMANDS_JS[reserved_idx : reserved_idx + 500]
|
||||
assert "const names=[cmd.name].concat(Array.isArray(cmd&&cmd.aliases)?cmd.aliases:[]);" in reserved
|
||||
assert "cmd&&cmd.cli_only" not in reserved
|
||||
|
||||
|
||||
def test_typing_slash_primes_async_skill_command_loading():
|
||||
assert "ensureSkillCommandsLoadedForAutocomplete" in BOOT_JS
|
||||
assert "ensureSkillCommandsLoadedForAutocomplete();" in BOOT_JS
|
||||
|
||||
Reference in New Issue
Block a user