fix(commands): keep slash command ownership ahead of bundles (#4087)

This commit is contained in:
Rod Boev
2026-06-13 13:47:15 -04:00
parent 840f12a74e
commit 6c7d551b41
5 changed files with 115 additions and 33 deletions
+10 -11
View File
@@ -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
View File
@@ -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();}
+65 -1
View File
@@ -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)
+9 -1
View File
@@ -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 = {}
+11
View File
@@ -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