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:
8 files changed
+571
-11
No files matched your search
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user