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
diff --git a/api/commands.py b/api/commands.py
index 66a221a1..e50eae38 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 ImportError:
+ return nullcontext()
+ return profile_env_for_active_request(purpose, logger_override=logger)
def _normalize_agent_command_name(command: str) -> str:
@@ -113,6 +131,79 @@ 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
+
+ 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")
+
+ 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 ef7922d2..041d2650 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -6773,6 +6773,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):
@@ -8469,6 +8473,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 831450bc..d62efd41 100644
--- a/static/commands.js
+++ b/static/commands.js
@@ -76,11 +76,6 @@ function getMatchingCommands(prefix){
});
seen.add(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();
@@ -93,6 +88,18 @@ function getMatchingCommands(prefix){
});
seen.add(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;
+ matches.push(skill);
+ seen.add(skill.name);
+ }
return matches;
}
@@ -101,6 +108,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 +310,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);
@@ -1535,7 +1554,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);
@@ -1551,6 +1569,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;
@@ -1566,6 +1596,28 @@ 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{
+ 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);}
+ _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||'';
@@ -1578,6 +1630,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();});
@@ -1602,7 +1657,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
@@ -1634,7 +1693,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 2a3bd959..23ef0584 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;}
@@ -970,6 +970,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
@@ -1040,6 +1041,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();}
@@ -1088,7 +1109,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 d58aa676..c8da5c7e 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
@@ -96,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
@@ -103,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
@@ -110,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
@@ -117,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
@@ -124,9 +135,54 @@ 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
+ }}
+ ]
+ }};
+ 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'
+ }},
+ {{
+ 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'
}}
]
}};
@@ -143,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'
}}
]
}};
@@ -208,20 +272,50 @@ 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');
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 {
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),
+ 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)
};
@@ -231,10 +325,87 @@ 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 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"]
+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
@@ -248,6 +419,31 @@ 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 "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)
@@ -316,6 +512,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..f933e433
--- /dev/null
+++ b/tests/test_issue4087_skill_bundles.py
@@ -0,0 +1,139 @@
+"""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 loadAgentCommandMetadata();" in COMMANDS_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 = {}
+
+ @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")
+
+
+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")
diff --git a/tests/test_sprint47.py b/tests/test_sprint47.py
index b7ba0c42..c3c20a0f 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,25 @@ 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():
+ 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