fix(#3968): autocomplete skill names for /use

This commit is contained in:
Rod Boev 2026-06-11 16:30:23 -04:00
parent a023650330
commit 7d925a324a
3 changed files with 69 additions and 0 deletions

View file

@ -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([]);
}

View file

@ -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');

View file

@ -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