From 5a3c6c6f05169a98b010321342e57c2be5bc5b9e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 13 Jun 2026 07:46:21 -0400 Subject: [PATCH 1/5] fix(commands): surface skill bundles in WebUI slash commands (#4087) --- api/commands.py | 87 ++++++++++++++++++- api/routes.py | 20 +++++ static/commands.js | 61 ++++++++++++- static/messages.js | 25 +++++- tests/test_cli_only_slash_commands.py | 61 +++++++++++++ tests/test_issue4087_skill_bundles.py | 119 ++++++++++++++++++++++++++ tests/test_sprint47.py | 16 ++++ 7 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue4087_skill_bundles.py diff --git a/api/commands.py b/api/commands.py index 0a3ba5fa..ebae498b 100644 --- a/api/commands.py +++ b/api/commands.py @@ -5,6 +5,7 @@ If hermes-agent is unavailable the endpoint degrades to an empty list so the frontend can still load with WEBUI_ONLY commands. """ from __future__ import annotations +from contextlib import nullcontext import logging import threading from typing import Any @@ -36,6 +37,13 @@ _CODEX_RUNTIME_LOCK = threading.Lock() def _parse_agent_command(command: str) -> tuple[str, str]: """Return ``(canonical_name, arg_string)`` from slash-command text.""" + cmd_base, arg_string = _parse_slash_command(command) + return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base), arg_string + + +def _parse_slash_command(command: str) -> tuple[str, str]: + """Return ``(command_name, arg_string)`` from slash-command text.""" + raw = str(command or "").strip() if not raw: raise ValueError("command is required") @@ -46,7 +54,17 @@ def _parse_agent_command(command: str) -> tuple[str, str]: if not cmd_base: raise ValueError("command is required") - return _AGENT_COMMAND_ALIASES.get(cmd_base, cmd_base), cmd_parts[1] if len(cmd_parts) > 1 else "" + return cmd_base, cmd_parts[1] if len(cmd_parts) > 1 else "" + + +def _bundle_profile_context(purpose: str): + """Resolve the active-profile env wrapper used by bundle APIs.""" + + try: + from api.profiles import profile_env_for_active_request + except Exception: + return nullcontext() + return profile_env_for_active_request(purpose, logger_override=logger) def _normalize_agent_command_name(command: str) -> str: @@ -113,6 +131,73 @@ def list_commands(_registry=None) -> list[dict[str, Any]]: return out +def list_command_bundles() -> list[dict[str, Any]]: + """Return installed skill bundles for the active WebUI profile.""" + + try: + from agent.skill_bundles import list_bundles as _list_bundles + except ImportError: + logger.debug("agent.skill_bundles not importable -- /api/commands/bundles returns []") + return [] + + try: + with _bundle_profile_context("/api/commands/bundles"): + bundles = _list_bundles() or [] + except Exception: + logger.warning("Failed to list skill bundles", exc_info=True) + return [] + + out: list[dict[str, Any]] = [] + for bundle in bundles: + slug = str((bundle or {}).get("slug", "")).strip().lower() + if not slug: + continue + skills = list((bundle or {}).get("skills") or []) + out.append({ + "name": slug, + "description": str((bundle or {}).get("description") or "").strip() or "Skill bundle", + "skill_count": len(skills), + "source": "bundle", + }) + return out + + +def resolve_bundle_command(command: str) -> dict[str, Any]: + """Expand a bundle slash command into the backend invocation payload.""" + + bundle_name, user_instruction = _parse_slash_command(command) + try: + from agent.skill_bundles import ( + build_bundle_invocation_message, + resolve_bundle_command_key, + ) + except ImportError as exc: + logger.warning("Skill bundle runtime unavailable", exc_info=True) + raise RuntimeError("Skill bundle runtime unavailable") from exc + + with _bundle_profile_context("/api/commands/bundles/resolve"): + bundle_key = resolve_bundle_command_key(bundle_name) + if bundle_key is None: + raise KeyError(bundle_name) + bundle_result = build_bundle_invocation_message(bundle_key, user_instruction) + + if not bundle_result: + raise RuntimeError("Bundle command returned no invocation text") + + message, loaded_skills, missing_skills = bundle_result + resolved_message = str(message or "").strip() + if not resolved_message: + raise RuntimeError("Bundle command returned no invocation text") + + return { + "name": bundle_key.lstrip("/"), + "source": "bundle", + "message": resolved_message, + "loaded_skills": list(loaded_skills or []), + "missing_skills": list(missing_skills or []), + } + + def execute_agent_command(command: str) -> str: """Execute a narrow allowlist of agent-side runtime commands.""" diff --git a/api/routes.py b/api/routes.py index 52f881b6..b5d58be8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6645,6 +6645,10 @@ def handle_get(handler, parsed) -> bool: from api.commands import list_commands return j(handler, {"commands": list_commands()}) + if parsed.path == "/api/commands/bundles": + from api.commands import list_command_bundles + return j(handler, {"bundles": list_command_bundles()}) + if parsed.path == "/api/updates/check": settings = load_settings() if not settings.get("check_for_updates", True): @@ -8369,6 +8373,22 @@ def handle_post(handler, parsed) -> bool: return _handle_clarify_respond(handler, body) # ── Commands (POST) ── + if parsed.path == "/api/commands/bundles/resolve": + from api.commands import resolve_bundle_command + + command = str(body.get("command", "") or "").strip() + if not command: + return bad(handler, "command is required") + + try: + return j(handler, resolve_bundle_command(command)) + except KeyError: + return bad(handler, "Bundle command not found", 404) + except ValueError as e: + return bad(handler, str(e), 400) + except RuntimeError as e: + return bad(handler, _sanitize_error(e), 500) + if parsed.path == "/api/commands/exec": from api.commands import execute_agent_command, execute_plugin_command diff --git a/static/commands.js b/static/commands.js index 08ddd861..14bc8d1c 100644 --- a/static/commands.js +++ b/static/commands.js @@ -76,6 +76,11 @@ 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); @@ -101,6 +106,9 @@ let _slashModelCache=null; let _slashModelCachePromise=null; let _slashPersonalityCache=null; let _slashPersonalityCachePromise=null; +let _bundleCommandCache=[]; +let _bundleCommandLoadPromise=null; +let _bundleCommandCacheReady=false; let _slashSkillCache=null; let _slashSkillCachePromise=null; let _agentCommandCache=null; @@ -300,6 +308,15 @@ async function _runAgentCommandTransport(text,_meta){ return String(data&&data.output||'(no output)'); } +async function resolveBundleCommand(text,_meta){ + const command=String(text||'').trim(); + if(!command) throw new Error('command is required'); + return api('/api/commands/bundles/resolve',{ + method:'POST', + body:JSON.stringify({command}) + }); +} + function _parseSlashAutocomplete(text){ if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null; const raw=text.slice(1); @@ -1550,6 +1567,18 @@ function _buildSkillCommandEntry(skill){ if(_getReservedSlashCommandSlugs().has(slug)) return null; return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName}; } +function _buildBundleCommandEntry(bundle){ + const slug=_skillCommandSlug(bundle&&bundle.name); + if(!slug)return null; + if(_getReservedSlashCommandSlugs().has(slug)) return null; + const skillCount=Number(bundle&&bundle.skill_count||0); + return{ + name:slug, + desc:String(bundle&&bundle.description||'').trim()||'Skill bundle', + source:'bundle', + skillCount:Number.isFinite(skillCount)?skillCount:0, + }; +} async function loadSkillCommands(force=false){ if(_skillCommandCacheReady&&!force)return _skillCommandCache; if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise; @@ -1565,6 +1594,27 @@ async function loadSkillCommands(force=false){ })(); return _skillCommandLoadPromise; } +async function loadBundleCommands(force=false){ + if(_bundleCommandCacheReady&&!force)return _bundleCommandCache; + if(_bundleCommandLoadPromise&&!force)return _bundleCommandLoadPromise; + _bundleCommandLoadPromise=(async()=>{ + try{ + const data=await api('/api/commands/bundles'); + const deduped=new Map(); + for(const bundle of (data&&data.bundles)||[]){const entry=_buildBundleCommandEntry(bundle);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);} + _bundleCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name)); + }catch(_){_bundleCommandCache=[];} + finally{_bundleCommandCacheReady=true;_bundleCommandLoadPromise=null;} + return _bundleCommandCache; + })(); + return _bundleCommandLoadPromise; +} +async function getBundleCommandMetadata(name){ + const needle=String(name||'').trim().toLowerCase(); + if(!needle) return null; + const bundles=await loadBundleCommands(); + return bundles.find(bundle=>String(bundle&&bundle.name||'').toLowerCase()===needle)||null; +} function refreshSlashCommandDropdown(){ const ta=$('msg');if(!ta)return; const text=ta.value||''; @@ -1577,6 +1627,9 @@ function refreshSlashCommandDropdown(){ function ensureSkillCommandsLoadedForAutocomplete(){ if(_skillCommandCacheReady||_skillCommandLoadPromise)return; loadSkillCommands().then(()=>{refreshSlashCommandDropdown();}); + if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){ + loadBundleCommands().then(()=>{refreshSlashCommandDropdown();}); + } // Also preload agent/plugin command metadata for autocomplete if(!_agentCommandCacheReady&&!_agentCommandCachePromise){ loadAgentCommandMetadata().then(()=>{refreshSlashCommandDropdown();}); @@ -1601,7 +1654,11 @@ function showCmdDropdown(matches){ const isSubArg=c.source==='subarg'; const isPath=c.source==='path'; const usage=(!isSubArg&&c.arg)?` ${esc(c.arg)}`:''; - const badge=c.source==='skill'?`${esc(t('slash_skill_badge'))}`:''; + const badge=c.source==='skill' + ? ` ${esc(t('slash_skill_badge'))}` + : c.source==='bundle' + ? ' Bundle' + : ''; if(c.source==='skill') el.classList.add('cmd-item-skill'); if(isPath) el.classList.add('cmd-item-path'); const nameHtml=isPath @@ -1633,7 +1690,7 @@ function showCmdDropdown(matches){ const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':'')); $('msg').value=nextValue; $('msg').focus(); - if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){ + if(!isSubArg&&c.source!=='skill'&&c.source!=='bundle'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){ getSlashAutocompleteMatches(nextValue).then(matches=>{ if(($('msg').value||'')!==nextValue) return; if(matches.length) showCmdDropdown(matches); diff --git a/static/messages.js b/static/messages.js index f090968d..d568c542 100644 --- a/static/messages.js +++ b/static/messages.js @@ -892,7 +892,7 @@ async function send(){ } _sendInProgress = true; try{ - const text=$('msg').value.trim(); + let text=$('msg').value.trim(); if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;} // Don't send while an inline message edit is active if(document.querySelector('.msg-edit-area')){_sendInProgress=false;_sendInProgressSid=null;return;} @@ -969,6 +969,7 @@ async function send(){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; } + let _slashDisplayTextOverride=null; // Slash command intercept -- local commands handled without agent round-trip. // We push the user message BEFORE running the handler for echo-worthy // commands so chat order is correct: some handlers (e.g. cmdHelp) push @@ -998,6 +999,26 @@ 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; @@ -1087,7 +1108,7 @@ async function send(){ $('msg').value='';autoResize(); // Clear persisted composer draft since message was sent. if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid); - const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)'); + const displayText=_slashDisplayTextOverride||text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)'); const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000}; S.toolCalls=[]; // clear tool calls from previous turn clearLiveToolCards(); // clear any leftover live cards from last turn diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 58a2a3bd..942512ad 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -52,6 +52,13 @@ def test_frontend_fetches_agent_command_metadata_lazily(): assert "_agentCommandCache" in COMMANDS_JS +def test_frontend_fetches_bundle_command_metadata_lazily(): + assert "async function loadBundleCommands" in COMMANDS_JS + assert "async function getBundleCommandMetadata" in COMMANDS_JS + assert "api('/api/commands/bundles')" in COMMANDS_JS + assert "_bundleCommandCache" in COMMANDS_JS + + def test_frontend_matches_agent_command_aliases(): helper_idx = COMMANDS_JS.find("async function getAgentCommandMetadata") assert helper_idx != -1 @@ -130,6 +137,22 @@ def _run_commands_js(script_body: str) -> dict: }} ] }}; + if (path === '/api/commands/bundles') return {{ + bundles: [ + {{ + name: 'handoff', + description: 'Bundle collision should stay hidden behind reserved slash names', + skill_count: 2, + source: 'bundle' + }}, + {{ + name: 'incident-review', + description: 'Bundle should beat a same-slug plain skill', + skill_count: 3, + source: 'bundle' + }} + ] + }}; if (path === '/api/skills') return {{ skills: [ {{ @@ -208,10 +231,33 @@ def test_cli_only_response_helper_uses_canonical_command_name(): assert "configured server-side" in result["response"] +def test_bundle_command_metadata_helper_resolves_known_bundle(): + result = _run_commands_js( + """ + const bundle = await getBundleCommandMetadata('incident-review'); + const missing = await getBundleCommandMetadata('does-not-exist'); + return { + by_name: bundle && bundle.name, + source: bundle && bundle.source, + skill_count: bundle && bundle.skillCount, + missing: missing === null + }; + """ + ) + + assert result == { + "by_name": "incident-review", + "source": "bundle", + "skill_count": 3, + "missing": True, + } + + def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): result = _run_commands_js( """ await loadAgentCommandMetadata(true); + await loadBundleCommands(true); await loadSkillCommands(true); const handoff = await getSlashAutocompleteMatches('/handoff'); const delegate = await getSlashAutocompleteMatches('/delegate'); @@ -222,6 +268,7 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): handoff_names: handoff.map(item => item.name), delegate_names: delegate.map(item => item.name), incident_names: incident.map(item => item.name), + incident_sources: incident.map(item => item.source), skills_names: skills.map(item => item.name), use_names: use.map(item => item.name) }; @@ -231,6 +278,7 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): assert result["handoff_names"] == [] assert result["delegate_names"] == [] assert result["incident_names"] == ["incident-review"] + assert result["incident_sources"] == ["bundle"] assert "skills" in result["skills_names"] assert "use" in result["use_names"] @@ -248,6 +296,18 @@ def test_send_intercepts_cli_only_commands_before_agent_round_trip(): assert "return;" in intercept +def test_send_intercepts_bundle_commands_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) + assert normal_send_idx != -1 + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "await getBundleCommandMetadata(_parsedCmd.name)" in intercept + assert "await resolveBundleCommand(text,_bundleCmd)" in intercept + assert "_slashDisplayTextOverride=text;" in intercept + assert "text=_bundleMessage;" in intercept + + 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) @@ -315,6 +375,7 @@ def test_unknown_slash_commands_still_fall_through_to_agent(): normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + assert "if(_bundleCmd){" in intercept assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept assert "if(_AGENT_COMMANDS_RUN_ON_WEBUI.has(_agentCmdName))" in intercept assert "if(_agentCmd&&_agentCmd.category==='Plugin')" in intercept diff --git a/tests/test_issue4087_skill_bundles.py b/tests/test_issue4087_skill_bundles.py new file mode 100644 index 00000000..f07fc79d --- /dev/null +++ b/tests/test_issue4087_skill_bundles.py @@ -0,0 +1,119 @@ +"""Regression coverage for issue #4087, WebUI skill bundle slash parity.""" + +from contextlib import contextmanager, nullcontext +from pathlib import Path +from types import ModuleType + +import pytest + +import api.commands as commands + + +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") +ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") + + +def _install_fake_skill_bundles(monkeypatch, *, bundles=None, resolver=None, builder=None): + import sys + + agent_pkg = sys.modules.get("agent") or ModuleType("agent") + agent_pkg.__path__ = [] + skill_bundles = ModuleType("agent.skill_bundles") + skill_bundles.list_bundles = lambda: list(bundles or []) + skill_bundles.resolve_bundle_command_key = resolver or (lambda name: None) + skill_bundles.build_bundle_invocation_message = builder or (lambda key, args: None) + monkeypatch.setitem(sys.modules, "agent", agent_pkg) + monkeypatch.setitem(sys.modules, "agent.skill_bundles", skill_bundles) + + +def test_bundle_routes_are_wired_through_dedicated_endpoints(): + assert 'if parsed.path == "/api/commands/bundles":' in ROUTES_PY + assert 'if parsed.path == "/api/commands/bundles/resolve":' in ROUTES_PY + assert 'return j(handler, {"bundles": list_command_bundles()})' in ROUTES_PY + assert 'return j(handler, resolve_bundle_command(command))' in ROUTES_PY + + +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 "await resolveBundleCommand(text,_bundleCmd)" in MESSAGES_JS + + +def test_list_command_bundles_returns_bundle_metadata(monkeypatch): + seen = {} + + @contextmanager + def _profile_scope(purpose): + seen["purpose"] = purpose + yield + + _install_fake_skill_bundles( + monkeypatch, + bundles=[ + { + "slug": "incident-review", + "description": "Investigate incidents with the bundled workflow", + "skills": ["triage", "report"], + }, + { + "slug": "", + "description": "ignored", + "skills": ["missing-slug"], + }, + ], + ) + monkeypatch.setattr(commands, "_bundle_profile_context", _profile_scope) + + assert commands.list_command_bundles() == [ + { + "name": "incident-review", + "description": "Investigate incidents with the bundled workflow", + "skill_count": 2, + "source": "bundle", + } + ] + assert seen == {"purpose": "/api/commands/bundles"} + + +def test_resolve_bundle_command_uses_bundle_runtime(monkeypatch): + seen = {} + + @contextmanager + def _profile_scope(purpose): + seen["purpose"] = purpose + yield + + def _resolve(name): + seen["resolve_name"] = name + return "/incident-review" if name == "incident-review" else None + + def _build(key, args): + seen["build"] = (key, args) + return ("$incident review the primary alerts", ["triage", "report"], []) + + _install_fake_skill_bundles(monkeypatch, resolver=_resolve, builder=_build) + monkeypatch.setattr(commands, "_bundle_profile_context", _profile_scope) + + assert commands.resolve_bundle_command("/incident-review the primary alerts") == { + "name": "incident-review", + "source": "bundle", + "message": "$incident review the primary alerts", + "loaded_skills": ["triage", "report"], + "missing_skills": [], + } + assert seen == { + "purpose": "/api/commands/bundles/resolve", + "resolve_name": "incident-review", + "build": ("/incident-review", "the primary alerts"), + } + + +def test_resolve_bundle_command_raises_for_unknown_bundle(monkeypatch): + _install_fake_skill_bundles(monkeypatch) + monkeypatch.setattr(commands, "_bundle_profile_context", lambda purpose: nullcontext()) + + with pytest.raises(KeyError): + commands.resolve_bundle_command("/does-not-exist investigate this") diff --git a/tests/test_sprint47.py b/tests/test_sprint47.py index b7ba0c42..c70cfd10 100644 --- a/tests/test_sprint47.py +++ b/tests/test_sprint47.py @@ -3,7 +3,9 @@ Sprint 47 tests: skill-backed slash commands appear in the Web UI autocomplete. Covers: - commands.js lazily loads /api/skills for slash autocomplete +- commands.js lazily loads /api/commands/bundles for bundle autocomplete - built-in commands still win over skill name collisions +- bundle entries merge ahead of plain skill entries for the same slug - boot.js primes the async skill load when typing '/' - the dropdown marks skill-backed entries visually """ @@ -34,6 +36,12 @@ def test_skill_commands_are_loaded_from_api_skills_for_autocomplete(): assert "source:'skill'" in COMMANDS_JS +def test_bundle_commands_are_loaded_from_dedicated_api_for_autocomplete(): + assert "loadBundleCommands" in COMMANDS_JS + assert "api('/api/commands/bundles')" in COMMANDS_JS + assert "source:'bundle'" in COMMANDS_JS + + def test_use_command_declares_skills_subargs(): assert "{name:'use'" in COMMANDS_JS assert "subArgs:'skills'" in COMMANDS_JS @@ -58,6 +66,14 @@ def test_builtin_commands_take_precedence_over_skill_slug_collisions(): assert "if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;" in COMMANDS_JS +def test_bundle_entries_merge_before_plain_skill_entries(): + bundle_loop = COMMANDS_JS.find("for(const bundle of _bundleCommandCache){") + skill_loop = COMMANDS_JS.find("for(const skill of _skillCommandCache){") + assert bundle_loop != -1 + assert skill_loop != -1 + assert bundle_loop < skill_loop + + def test_typing_slash_primes_async_skill_command_loading(): assert "ensureSkillCommandsLoadedForAutocomplete" in BOOT_JS assert "ensureSkillCommandsLoadedForAutocomplete();" in BOOT_JS From 840f12a74e79056bd4ce2a2dcc61aa5cbadf9737 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 13 Jun 2026 07:59:02 -0400 Subject: [PATCH 2/5] fix(commands): keep bundle profile scoping strict (#4087) --- api/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/commands.py b/api/commands.py index ebae498b..2d7ccb29 100644 --- a/api/commands.py +++ b/api/commands.py @@ -62,7 +62,7 @@ def _bundle_profile_context(purpose: str): try: from api.profiles import profile_env_for_active_request - except Exception: + except ImportError: return nullcontext() return profile_env_for_active_request(purpose, logger_override=logger) From 6c7d551b41d078a65a09652d2c9781cf16632be2 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 13 Jun 2026 13:47:15 -0400 Subject: [PATCH 3/5] fix(commands): keep slash command ownership ahead of bundles (#4087) --- static/commands.js | 21 ++++----- static/messages.js | 40 ++++++++-------- tests/test_cli_only_slash_commands.py | 66 ++++++++++++++++++++++++++- tests/test_issue4087_skill_bundles.py | 10 +++- tests/test_sprint47.py | 11 +++++ 5 files changed, 115 insertions(+), 33 deletions(-) diff --git a/static/commands.js b/static/commands.js index 14bc8d1c..2fbd4c18 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 d568c542..293a4036 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 942512ad..96e37f0e 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 f07fc79d..86e69dda 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 c70cfd10..c3c20a0f 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 From d7e8f704238445a74439e6644ced9738ce06a400 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 13 Jun 2026 18:53:46 -0400 Subject: [PATCH 4/5] fix(commands): block transient bundle shadowing --- api/commands.py | 16 ++++-- static/commands.js | 11 ++-- tests/test_cli_only_slash_commands.py | 72 +++++++++++++++++++++++++++ tests/test_issue4087_skill_bundles.py | 12 +++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/api/commands.py b/api/commands.py index 2d7ccb29..51878bf7 100644 --- a/api/commands.py +++ b/api/commands.py @@ -175,11 +175,17 @@ def resolve_bundle_command(command: str) -> dict[str, Any]: logger.warning("Skill bundle runtime unavailable", exc_info=True) raise RuntimeError("Skill bundle runtime unavailable") from exc - with _bundle_profile_context("/api/commands/bundles/resolve"): - bundle_key = resolve_bundle_command_key(bundle_name) - if bundle_key is None: - raise KeyError(bundle_name) - bundle_result = build_bundle_invocation_message(bundle_key, user_instruction) + try: + with _bundle_profile_context("/api/commands/bundles/resolve"): + bundle_key = resolve_bundle_command_key(bundle_name) + if bundle_key is None: + raise KeyError(bundle_name) + bundle_result = build_bundle_invocation_message(bundle_key, user_instruction) + except (KeyError, ValueError, RuntimeError): + raise + except Exception as exc: + logger.warning("Failed to resolve skill bundle command", exc_info=True) + raise RuntimeError("Skill bundle command unavailable") from exc if not bundle_result: raise RuntimeError("Bundle command returned no invocation text") diff --git a/static/commands.js b/static/commands.js index 2fbd4c18..e1cd7f85 100644 --- a/static/commands.js +++ b/static/commands.js @@ -88,10 +88,12 @@ 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); + if(_agentCommandCacheReady){ + 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; @@ -1598,6 +1600,7 @@ async function loadBundleCommands(force=false){ if(_bundleCommandLoadPromise&&!force)return _bundleCommandLoadPromise; _bundleCommandLoadPromise=(async()=>{ try{ + await loadAgentCommandMetadata(); const data=await api('/api/commands/bundles'); const deduped=new Map(); for(const bundle of (data&&data.bundles)||[]){const entry=_buildBundleCommandEntry(bundle);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);} diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 96e37f0e..25f2f331 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -334,6 +334,78 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): assert "use" in result["use_names"] +def test_bundle_collisions_stay_hidden_until_agent_metadata_is_ready(): + script = textwrap.dedent( + f""" + const vm = require('vm'); + let releaseCommands; + const commandsReady = new Promise(resolve => {{ releaseCommands = resolve; }}); + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path === '/api/commands') return commandsReady; + if (path === '/api/commands/bundles') return {{ + bundles: [ + {{ + name: 'plugin-review', + description: 'Bundle collision should stay hidden until plugin metadata lands', + skill_count: 5, + source: 'bundle' + }} + ] + }}; + if (path === '/api/skills') return {{ skills: [] }}; + throw new Error('unexpected api path: ' + path); + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const bundleLoad = vm.runInContext('loadBundleCommands(true)', ctx); + const before = await vm.runInContext("getSlashAutocompleteMatches('/plugin')", ctx); + releaseCommands({{ + commands: [ + {{ + name: 'plugin-review', + description: 'Run plugin review', + category: 'Plugin', + aliases: ['plugin_review'], + cli_only: false, + gateway_only: false + }} + ] + }}); + await bundleLoad; + const after = await vm.runInContext("getSlashAutocompleteMatches('/plugin')", ctx); + process.stdout.write(JSON.stringify({{ + before_names: before.map(item => item.name), + before_sources: before.map(item => item.source), + after_names: after.map(item => item.name), + after_sources: after.map(item => item.source) + }})); + }})().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) + + result = json.loads(proc.stdout) + assert result["before_names"] == [] + assert result["before_sources"] == [] + assert result["after_names"] == ["plugin-review"] + assert result["after_sources"] == ["plugin"] + + def test_send_intercepts_cli_only_commands_before_agent_round_trip(): intercept_idx = MESSAGES_JS.find("Slash command intercept") assert intercept_idx != -1 diff --git a/tests/test_issue4087_skill_bundles.py b/tests/test_issue4087_skill_bundles.py index 86e69dda..f933e433 100644 --- a/tests/test_issue4087_skill_bundles.py +++ b/tests/test_issue4087_skill_bundles.py @@ -38,6 +38,7 @@ 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 loadAgentCommandMetadata();" in COMMANDS_JS assert "const _bundleCmd=!_agentCmd&&typeof getBundleCommandMetadata==='function'" in MESSAGES_JS assert "await resolveBundleCommand(text,_bundleCmd)" in MESSAGES_JS @@ -125,3 +126,14 @@ def test_resolve_bundle_command_raises_for_unknown_bundle(monkeypatch): with pytest.raises(KeyError): commands.resolve_bundle_command("/does-not-exist investigate this") + + +def test_resolve_bundle_command_wraps_unexpected_runtime_errors(monkeypatch): + def _explode(_name): + raise AttributeError("bundle runtime broke") + + _install_fake_skill_bundles(monkeypatch, resolver=_explode) + monkeypatch.setattr(commands, "_bundle_profile_context", lambda purpose: nullcontext()) + + with pytest.raises(RuntimeError, match="Skill bundle command unavailable"): + commands.resolve_bundle_command("/incident-review investigate this") From e069a2dbcc27cef56d9906abe927247177e89287 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 13 Jun 2026 23:14:31 +0000 Subject: [PATCH 5/5] docs(changelog): stamp #4087 skill-bundle slash commands (precedence-fixed) as v0.51.400 (Release NM) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d589f4e8..7e839d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.400] — 2026-06-13 — Release NM (skill bundles in WebUI slash commands, #4087) + +### Fixed + +- **Skill bundles now appear in the WebUI slash-command autocomplete and dispatch, without shadowing existing commands (#4087).** Bundle-backed `/commands` (from the agent's `skill_bundles` registry) are surfaced alongside skills/plugins, restoring CLI↔WebUI parity. Command resolution keeps strict precedence: built-in → CLI-only → runtime/plugin (`getAgentCommandMetadata` / `_AGENT_COMMANDS_RUN_ON_WEBUI`) → **then** bundle (gated on no agent command claiming the name) → plain skill, so a bundle whose slug collides with `reload-skills`/`codex-runtime`/a plugin can't hijack dispatch. Autocomplete mirrors that order via `_getReservedSlashCommandSlugs()` (built-ins + all agent/runtime/plugin names + aliases reserved before bundles). Bundle execution runs through the same profile-scoped trusted path as skills. (#4087) + ## [v0.51.399] — 2026-06-13 — Release NL (per-home provider probe-worker pool, #3787) ### Fixed