Merge pull request #4149 from nesquena/stage-4112b

Release NM (v0.51.400): skill bundles in WebUI slash commands (#4087)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-13 16:25:30 -07:00
commit ca4b82c126
8 files changed
+571 -11

No files matched your search

+6
View File
@@ -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
+92 -1
View File
@@ -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."""
+20
View File
@@ -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
+67 -8
View File
@@ -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)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
const badge=c.source==='skill'
? ` <span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`
: c.source==='bundle'
? ' <span class="cmd-item-badge">Bundle</span>'
: '';
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);
+23 -2
View File
@@ -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
+197
View File
@@ -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
+139
View File
@@ -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")
+27
View File
@@ -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