diff --git a/static/commands.js b/static/commands.js index 14bc8d1c9..2fbd4c183 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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); diff --git a/static/messages.js b/static/messages.js index d568c5423..293a40369 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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();} diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 942512ada..96e37f0e0 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -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) diff --git a/tests/test_issue4087_skill_bundles.py b/tests/test_issue4087_skill_bundles.py index f07fc79db..86e69dda4 100644 --- a/tests/test_issue4087_skill_bundles.py +++ b/tests/test_issue4087_skill_bundles.py @@ -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 = {} diff --git a/tests/test_sprint47.py b/tests/test_sprint47.py index c70cfd10b..c3c20a0f8 100644 --- a/tests/test_sprint47.py +++ b/tests/test_sprint47.py @@ -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