From 7d925a324ac2f26c5de92591539aae6a17da51fe Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 11 Jun 2026 16:30:23 -0400 Subject: [PATCH] fix(#3968): autocomplete skill names for /use --- static/commands.js | 36 ++++++++++++++++++++++++++++++++++++ static/panels.js | 3 +++ tests/test_sprint47.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/static/commands.js b/static/commands.js index 5fb97d30..70ae4197 100644 --- a/static/commands.js +++ b/static/commands.js @@ -100,6 +100,8 @@ let _slashModelCache=null; let _slashModelCachePromise=null; let _slashPersonalityCache=null; let _slashPersonalityCachePromise=null; +let _slashSkillCache=null; +let _slashSkillCachePromise=null; let _agentCommandCache=null; let _agentCommandCachePromise=null; @@ -117,6 +119,7 @@ function _invalidateSlashModelCache(){ // define a window global — see tests/test_cli_only_slash_commands.py. if(typeof window!=='undefined'){ window._invalidateSlashModelCache=_invalidateSlashModelCache; + window.invalidateSlashSkillCaches=invalidateSlashSkillCaches; } function _normalizeSlashSubArg(value){ @@ -198,10 +201,43 @@ async function _loadSlashPersonalitySubArgs(force=false){ return _slashPersonalityCachePromise; } +async function _loadSlashSkillSubArgs(force=false){ + if(_slashSkillCache&&!force) return _slashSkillCache; + if(_slashSkillCachePromise&&!force) return _slashSkillCachePromise; + _slashSkillCachePromise=(async()=>{ + try{ + const data=await api('/api/skills'); + const values=[]; + for(const skill of (data&&data.skills)||[]){ + const name=_normalizeSlashSubArg(skill&&skill.name); + if(name) values.push(name); + } + const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); + _slashSkillCache=deduped; + return deduped; + }catch(_){ + _slashSkillCache=null; + return []; + }finally{ + _slashSkillCachePromise=null; + } + })(); + return _slashSkillCachePromise; +} + +function invalidateSlashSkillCaches(){ + _slashSkillCache=null; + _slashSkillCachePromise=null; + _skillCommandCache=[]; + _skillCommandCacheReady=false; + _skillCommandLoadPromise=null; +} + function _getSlashSubArgOptions(spec){ if(Array.isArray(spec)) return Promise.resolve(spec.slice()); if(spec==='models') return _loadSlashModelSubArgs(); if(spec==='personalities') return _loadSlashPersonalitySubArgs(); + if(spec==='skills') return _loadSlashSkillSubArgs(); return Promise.resolve([]); } diff --git a/static/panels.js b/static/panels.js index 75f77605..6c5d9477 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3860,6 +3860,7 @@ async function toggleSkill(name, currentlyEnabled) { const skill = _skillsData.find(s => s.name === name); if (skill) skill.disabled = !newEnabled; } + if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); renderSkills(_skillsData || []); } else { setStatus((result && result.error) || t('skill_toggle_failed')); @@ -4114,6 +4115,7 @@ async function saveSkillForm() { showToast(_editingSkillName ? t('skill_updated') : t('skill_created')); _skillsData = null; _cronSkillsCache = null; + if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); _editingSkillName = null; _skillPreFormDetail = null; await loadSkills(); @@ -4153,6 +4155,7 @@ async function deleteCurrentSkill() { _skillPreFormDetail = null; _skillsData = null; _cronSkillsCache = null; + if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); _skillMode = 'empty'; const body = $('skillDetailBody'); const empty = $('skillDetailEmpty'); diff --git a/tests/test_sprint47.py b/tests/test_sprint47.py index 74aeaea3..9b95731b 100644 --- a/tests/test_sprint47.py +++ b/tests/test_sprint47.py @@ -13,15 +13,45 @@ import pathlib REPO_ROOT = pathlib.Path(__file__).parent.parent COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") +def _function_body(source, name): + marker = f"function {name}(" + start = source.find(marker) + assert start != -1, f"{name} function not found" + next_function = source.find("\nfunction ", start + len(marker)) + next_async_function = source.find("\nasync function ", start + len(marker)) + ends = [pos for pos in (next_function, next_async_function) if pos != -1] + end = min(ends) if ends else len(source) + return source[start:end] + + def test_skill_commands_are_loaded_from_api_skills_for_autocomplete(): assert "loadSkillCommands" in COMMANDS_JS assert "api('/api/skills')" in COMMANDS_JS assert "source:'skill'" in COMMANDS_JS +def test_use_command_declares_skills_subargs(): + assert "{name:'use'" in COMMANDS_JS + assert "subArgs:'skills'" in COMMANDS_JS + + +def test_skills_subargs_route_through_dedicated_loader(): + assert "async function _loadSlashSkillSubArgs(force=false)" in COMMANDS_JS + assert "if(spec==='skills') return _loadSlashSkillSubArgs();" in COMMANDS_JS + assert "if(spec==='skills') return loadSkillCommands();" not in COMMANDS_JS + + +def test_skill_mutations_invalidate_slash_skill_caches(): + assert "function invalidateSlashSkillCaches()" in COMMANDS_JS + assert "window.invalidateSlashSkillCaches=invalidateSlashSkillCaches;" in COMMANDS_JS + for function_name in ("saveSkillForm", "deleteCurrentSkill", "toggleSkill"): + assert "window.invalidateSlashSkillCaches()" in _function_body(PANELS_JS, function_name) + + def test_builtin_commands_take_precedence_over_skill_slug_collisions(): # In the combined implementation, REGISTRY (agent registry + WEBUI_ONLY) wins over skills assert ("if(COMMANDS.some(c=>c.name===slug)) return null;" in COMMANDS_JS or