From e4c3c728d9dcc04dfae2ab2d8a311fd3bbcad879 Mon Sep 17 00:00:00 2001 From: MHJ <84257033+MinhoJJang@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:32:57 +0900 Subject: [PATCH] fix(commands): wait for bundle metadata before skill suggestions --- README.md | 1 + static/commands.js | 9 +++- tests/test_cli_only_slash_commands.py | 61 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e121ec52..ad139126 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup ### Slash commands - Type `/` in the composer for autocomplete dropdown +- Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill - Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model `, `/workspace `, `/new`, `/usage`, `/theme` - Arrow keys navigate, Tab/Enter select, Escape closes - Unrecognized commands pass through to the agent diff --git a/static/commands.js b/static/commands.js index 56b7fcc0..8bef376d 100644 --- a/static/commands.js +++ b/static/commands.js @@ -228,6 +228,10 @@ function getMatchingCommands(prefix){ seen.add(bundle.name); } } + // A same-slug bundle owns dispatch. Hold plain skills until the independent + // bundle metadata request settles so a slow bundle response cannot briefly + // expose a selectable, shadowed skill. + if(!_bundleCommandCacheReady)return matches; for(const skill of _skillCommandCache){ const name=String(skill&&skill.name||'').toLowerCase(); const description=String(skill&&skill.desc||'').toLowerCase(); @@ -2089,8 +2093,9 @@ function refreshSlashCommandDropdown(){ }); } function ensureSkillCommandsLoadedForAutocomplete(){ - if(_skillCommandCacheReady||_skillCommandLoadPromise)return; - loadSkillCommands().then(()=>{refreshSlashCommandDropdown();}); + if(!_skillCommandCacheReady&&!_skillCommandLoadPromise){ + loadSkillCommands().then(()=>{refreshSlashCommandDropdown();}); + } if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){ loadBundleCommands().then(()=>{refreshSlashCommandDropdown();}); } diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index c01fe737..9ac5c2d6 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -367,6 +367,7 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): def test_skill_autocomplete_matches_keyword_in_name_or_description(): result = _run_commands_js( """ + await loadBundleCommands(true); await loadSkillCommands(true); const upgrade = await getSlashAutocompleteMatches('/upgrade'); const reinstall = await getSlashAutocompleteMatches('/reinstall'); @@ -399,6 +400,66 @@ def test_skill_autocomplete_hides_keyword_match_shadowed_by_bundle(): assert result == [] +def test_skill_autocomplete_waits_for_bundle_metadata_before_showing_colliding_keyword_match(): + script = textwrap.dedent( + f""" + const vm = require('vm'); + let releaseBundles; + const bundlesReady = new Promise(resolve => {{ releaseBundles = resolve; }}); + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path === '/api/commands') return {{ commands: [] }}; + if (path === '/api/commands/bundles') return bundlesReady; + if (path === '/api/skills') return {{ skills: [ + {{ + name: 'incident-review', + description: 'Handle a non-colliding keyword', + category: 'ops' + }} + ] }}; + throw new Error('unexpected api path: ' + path); + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + await vm.runInContext('loadSkillCommands(true)', ctx); + const bundleLoad = vm.runInContext('loadBundleCommands(true)', ctx); + const before = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx); + releaseBundles({{ bundles: [ + {{ + name: 'incident-review', + description: 'Incident review bundle', + skill_count: 3, + source: 'bundle' + }} + ] }}); + await bundleLoad; + const after = await vm.runInContext("getSlashAutocompleteMatches('/non-colliding')", ctx); + process.stdout.write(JSON.stringify({{ + before: before.map(item => ({{ name: item.name, source: item.source }})), + after: after.map(item => ({{ name: item.name, source: 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) + + assert json.loads(proc.stdout) == {"before": [], "after": []} + + def test_bundle_collisions_stay_hidden_until_agent_metadata_is_ready(): script = textwrap.dedent( f"""