Merge pull request #6934 from nesquena/release/stage-6932-1786470719
Release: search skills by keyword in slash-command autocomplete (#6932)
This commit is contained in:
5 files changed
+126
-5
No files matched your search
@@ -9,6 +9,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **Slash-command autocomplete now searches skills by keyword.** Typing `/` and a term now matches plain skills on a case-insensitive keyword in their **name or description** (previously only a name prefix), so a skill is discoverable by what it does, not just its exact slug. Built-in, agent/plugin, and bundle commands keep prefix matching and always take precedence over a same-slug skill, and skill suggestions are held until the bundle-metadata request settles so a slow response can't briefly surface a shadowed skill. Thanks @MinhoJJang. (#6932)
|
||||
|
||||
- **Extensions can observe turn lifecycle events via `handle.events.on(...)`.** A registered extension (see `window.hermesExt.register`) can now subscribe to session turn-lifecycle events — `turn:start`, `turn:complete`, `turn:error`, and `turn:cancel` — through a frozen `events` accessor on its handle, each delivering a frozen scalar event (`sessionId`, `streamId`, `status`, and start/end timestamps; no message content). Core owns the authoritative live-session stream and emits each event exactly once per real transition (a per-session/stream state machine suppresses duplicate starts and post-terminal events, including on reconnect/replay), with per-listener error isolation so a misbehaving extension cannot break streaming. Subscriptions return an idempotent unsubscribe. This is the B1 observer surface on top of the E0 identity handle — a stable page-local facade instead of extensions polling private DOM state. Thanks @franksong2702. (#6924)
|
||||
|
||||
- **Extensions can claim a cooperative page identity via `window.hermesExt.register(id)`.** An injected extension can now claim a stable browser-page identity together with its existing settings and storage accessors: `register(id)` returns a frozen `{id, settings, storage}` handle for an ID present in the effective-enabled, Core-sanitized manifest inventory captured at page boot, and returns `null` (fail-closed, no namespace side effects) for empty, malformed, unknown, or untrusted IDs. Re-registering the same ID in one page returns the same handle; boot-time trust is immutable across status refreshes (a refresh cannot elevate an ID absent at boot). This is cooperative attribution for extensions sharing the page — explicitly not a sandbox, capability, permission, isolation mechanism, or security boundary. Thanks @franksong2702. (#6795)
|
||||
|
||||
@@ -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 <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
+12
-4
@@ -187,6 +187,7 @@ function getMatchingCommands(prefix){
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
const seen=new Set(matches.map(c=>c.name));
|
||||
const reserved=_getReservedSlashCommandSlugs();
|
||||
const bundleSlugs=new Set(_bundleCommandCache.map(bundle=>bundle.name));
|
||||
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
matches.push({
|
||||
@@ -227,10 +228,16 @@ 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){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
|
||||
const name=String(skill&&skill.name||'').toLowerCase();
|
||||
const description=String(skill&&skill.desc||'').toLowerCase();
|
||||
if((!name.includes(q)&&!description.includes(q))||seen.has(name)||reserved.has(name)||bundleSlugs.has(name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
seen.add(name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -2086,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();});
|
||||
}
|
||||
|
||||
@@ -215,6 +215,18 @@ def _run_commands_js(script_body: str) -> dict:
|
||||
{{
|
||||
name: 'plugin review',
|
||||
description: 'Plugin collisions should stay hidden from slash autocomplete'
|
||||
}},
|
||||
{{
|
||||
name: 'hermes-upgrade',
|
||||
description: 'Safely upgrade and verify a Hermes installation'
|
||||
}},
|
||||
{{
|
||||
name: 'maintenance-guide',
|
||||
description: 'Plan a safe upgrade and rollback path'
|
||||
}},
|
||||
{{
|
||||
name: 'reinstall-helper',
|
||||
description: 'Recover from a failed reinstall'
|
||||
}}
|
||||
]
|
||||
}};
|
||||
@@ -352,6 +364,102 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace():
|
||||
assert "use" in result["use_names"]
|
||||
|
||||
|
||||
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');
|
||||
return {
|
||||
upgrade_names: upgrade.map(item => item.name),
|
||||
upgrade_sources: upgrade.map(item => item.source),
|
||||
reinstall_names: reinstall.map(item => item.name),
|
||||
reinstall_sources: reinstall.map(item => item.source)
|
||||
};
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["upgrade_names"] == ["hermes-upgrade", "maintenance-guide"]
|
||||
assert result["upgrade_sources"] == ["skill", "skill"]
|
||||
assert result["reinstall_names"] == ["reinstall-helper"]
|
||||
assert result["reinstall_sources"] == ["skill"]
|
||||
|
||||
|
||||
def test_skill_autocomplete_hides_keyword_match_shadowed_by_bundle():
|
||||
result = _run_commands_js(
|
||||
"""
|
||||
await loadAgentCommandMetadata(true);
|
||||
await loadBundleCommands(true);
|
||||
await loadSkillCommands(true);
|
||||
const matches = await getSlashAutocompleteMatches('/non-colliding');
|
||||
return matches.map(item => ({ name: item.name, source: item.source }));
|
||||
"""
|
||||
)
|
||||
|
||||
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"""
|
||||
|
||||
@@ -63,7 +63,9 @@ def test_skill_mutations_invalidate_slash_skill_caches():
|
||||
def test_builtin_commands_take_precedence_over_skill_slug_collisions():
|
||||
assert "_getReservedSlashCommandSlugs" in COMMANDS_JS
|
||||
assert "if(_getReservedSlashCommandSlugs().has(slug)) return null;" in COMMANDS_JS
|
||||
assert "if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;" in COMMANDS_JS
|
||||
command_matching = _function_body(COMMANDS_JS, "getMatchingCommands")
|
||||
assert "reserved.has(name)" in command_matching
|
||||
assert "!name.includes(q)&&!description.includes(q)" in command_matching
|
||||
|
||||
|
||||
def test_bundle_entries_merge_before_plain_skill_entries():
|
||||
|
||||
Reference in new issue
Block a user