mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 20:34:35 +08:00
Compare commits
3 Commits
chore/remo
...
worktree-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a926a45a7 | ||
|
|
b2968bbd70 | ||
|
|
cde41f3140 |
@@ -67,10 +67,30 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||||||
return { items, query }
|
return { items, query }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||||
|
'complete.slash',
|
||||||
|
{ text }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||||
|
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||||
|
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||||
|
// the full `/personality alice` token instead of stranding `/alice`.
|
||||||
|
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||||
|
const isArgCompletion = replaceFrom > 1
|
||||||
|
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||||
|
|
||||||
const items = (result.items ?? [])
|
const items = (result.items ?? [])
|
||||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
.map(item => {
|
||||||
|
if (!isArgCompletion) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
const argText = typeof item.text === 'string' ? item.text : ''
|
||||||
|
|
||||||
|
return { ...item, text: `${prefix}${argText}` }
|
||||||
|
})
|
||||||
|
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||||
|
|||||||
@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
|
|||||||
it('returns null for plain text', () => {
|
it('returns null for plain text', () => {
|
||||||
expect(detectTrigger('hello there')).toBeNull()
|
expect(detectTrigger('hello there')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the slash trigger live while typing args', () => {
|
||||||
|
expect(detectTrigger('/personality ')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'personality ',
|
||||||
|
tokenLength: 13
|
||||||
|
})
|
||||||
|
expect(detectTrigger('/personality alic')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'personality alic',
|
||||||
|
tokenLength: 17
|
||||||
|
})
|
||||||
|
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||||
|
kind: '/',
|
||||||
|
query: 'tools enable foo',
|
||||||
|
tokenLength: 17
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat file-style paths as slash triggers', () => {
|
||||||
|
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||||
|
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||||
|
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('extractClipboardImageBlobs', () => {
|
describe('extractClipboardImageBlobs', () => {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export interface TriggerState {
|
|||||||
tokenLength: number
|
tokenLength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||||
|
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||||
|
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||||
|
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||||
|
// paths like `src/foo/bar`.
|
||||||
|
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||||
|
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||||
|
|
||||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||||
export function blobDedupeKey(blob: Blob): string {
|
export function blobDedupeKey(blob: Blob): string {
|
||||||
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||||
const match = TRIGGER_RE.exec(textBefore)
|
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||||
|
|
||||||
if (!match) {
|
if (slash) {
|
||||||
return null
|
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||||
|
|
||||||
|
if (at) {
|
||||||
|
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ describe('desktop slash command curation', () => {
|
|||||||
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('surfaces /tools, /save, and /personality on the desktop', () => {
|
||||||
|
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
|
||||||
|
expect(isDesktopSlashSuggestion('/save')).toBe(true)
|
||||||
|
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
|
||||||
|
expect(isDesktopSlashCommand('/tools')).toBe(true)
|
||||||
|
expect(isDesktopSlashCommand('/save')).toBe(true)
|
||||||
|
expect(isDesktopSlashCommand('/personality')).toBe(true)
|
||||||
|
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
|
||||||
|
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
|
||||||
|
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('allows aliases to execute without cluttering the popover', () => {
|
it('allows aliases to execute without cluttering the popover', () => {
|
||||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||||
@@ -74,6 +86,24 @@ describe('desktop slash command curation', () => {
|
|||||||
['/new', 'Start a new desktop chat'],
|
['/new', 'Start a new desktop chat'],
|
||||||
['/ship-it', 'Run release checklist']
|
['/ship-it', 'Run release checklist']
|
||||||
])
|
])
|
||||||
|
// skill_count is recomputed from the filtered output (only /ship-it is an
|
||||||
|
// extension command — /new is a built-in) so the /help footer matches what
|
||||||
|
// the user actually sees rather than echoing the unfiltered backend total.
|
||||||
|
expect(filtered.skill_count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
|
||||||
|
const filtered = filterDesktopCommandsCatalog({
|
||||||
|
pairs: [
|
||||||
|
['/new', 'Start a new session'],
|
||||||
|
['/clear', 'Clear terminal screen'],
|
||||||
|
['/gif-search', 'Search for a gif'],
|
||||||
|
['/ship-it', 'Run release checklist']
|
||||||
|
],
|
||||||
|
skill_count: 12
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
|
||||||
expect(filtered.skill_count).toBe(2)
|
expect(filtered.skill_count).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,19 @@ const DESKTOP_COMMAND_META = [
|
|||||||
['/goal', 'Manage the standing goal for this session'],
|
['/goal', 'Manage the standing goal for this session'],
|
||||||
['/help', 'Show desktop slash commands'],
|
['/help', 'Show desktop slash commands'],
|
||||||
['/new', 'Start a new desktop chat'],
|
['/new', 'Start a new desktop chat'],
|
||||||
|
['/personality', 'Switch personality for this session'],
|
||||||
['/profile', 'Switch the active Hermes profile'],
|
['/profile', 'Switch the active Hermes profile'],
|
||||||
['/queue', 'Queue a prompt for the next turn'],
|
['/queue', 'Queue a prompt for the next turn'],
|
||||||
['/resume', 'Resume a saved session'],
|
['/resume', 'Resume a saved session'],
|
||||||
['/retry', 'Retry the last user message'],
|
['/retry', 'Retry the last user message'],
|
||||||
['/rollback', 'List or restore filesystem checkpoints'],
|
['/rollback', 'List or restore filesystem checkpoints'],
|
||||||
|
['/save', 'Save the current transcript to JSON'],
|
||||||
['/skin', 'Switch desktop theme or cycle to the next one'],
|
['/skin', 'Switch desktop theme or cycle to the next one'],
|
||||||
['/status', 'Show current session status'],
|
['/status', 'Show current session status'],
|
||||||
['/steer', 'Steer the current run after the next tool call'],
|
['/steer', 'Steer the current run after the next tool call'],
|
||||||
['/stop', 'Stop running background processes'],
|
['/stop', 'Stop running background processes'],
|
||||||
['/title', 'Rename the current session'],
|
['/title', 'Rename the current session'],
|
||||||
|
['/tools', 'List or toggle tools available to the agent'],
|
||||||
['/undo', 'Remove the last user/assistant exchange'],
|
['/undo', 'Remove the last user/assistant exchange'],
|
||||||
['/usage', 'Show token usage for this session'],
|
['/usage', 'Show token usage for this session'],
|
||||||
['/version', 'Show Hermes Agent version'],
|
['/version', 'Show Hermes Agent version'],
|
||||||
@@ -90,7 +93,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
|
|||||||
'/redraw',
|
'/redraw',
|
||||||
'/reload',
|
'/reload',
|
||||||
'/restart',
|
'/restart',
|
||||||
'/save',
|
|
||||||
'/sb',
|
'/sb',
|
||||||
'/set-home',
|
'/set-home',
|
||||||
'/sethome',
|
'/sethome',
|
||||||
@@ -98,7 +100,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
|
|||||||
'/snapshot',
|
'/snapshot',
|
||||||
'/statusbar',
|
'/statusbar',
|
||||||
'/toolsets',
|
'/toolsets',
|
||||||
'/tools',
|
|
||||||
'/update',
|
'/update',
|
||||||
'/verbose'
|
'/verbose'
|
||||||
])
|
])
|
||||||
@@ -112,7 +113,6 @@ const ADVANCED_COMMANDS = new Set([
|
|||||||
'/fast',
|
'/fast',
|
||||||
'/insights',
|
'/insights',
|
||||||
'/kanban',
|
'/kanban',
|
||||||
'/personality',
|
|
||||||
'/reasoning',
|
'/reasoning',
|
||||||
'/reload-mcp',
|
'/reload-mcp',
|
||||||
'/reload-skills',
|
'/reload-skills',
|
||||||
@@ -274,10 +274,32 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
|
|||||||
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
?.filter(([command]) => isDesktopSlashSuggestion(command))
|
||||||
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
|
||||||
|
|
||||||
|
// Recount skill commands from the filtered output so /help's footer reflects
|
||||||
|
// what the user actually sees. Backend's skill_count includes commands the
|
||||||
|
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
|
||||||
|
// like "60 skill commands available" while only ~29 appear in the list.
|
||||||
|
const filteredCommands = new Set<string>()
|
||||||
|
for (const section of categories ?? []) {
|
||||||
|
for (const [command] of section.pairs) {
|
||||||
|
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [command] of pairs ?? []) {
|
||||||
|
filteredCommands.add(canonicalDesktopSlashCommand(command))
|
||||||
|
}
|
||||||
|
let skillCount = 0
|
||||||
|
for (const command of filteredCommands) {
|
||||||
|
if (isDesktopSlashExtensionCommand(command)) {
|
||||||
|
skillCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...catalog,
|
...catalog,
|
||||||
...(categories ? { categories } : {}),
|
...(categories ? { categories } : {}),
|
||||||
...(pairs ? { pairs } : {})
|
...(pairs ? { pairs } : {}),
|
||||||
|
...(hasSkillCount ? { skill_count: skillCount } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1538,6 +1538,91 @@ class SlashCommandCompleter(Completer):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tools_completions(sub_text: str, sub_lower: str):
|
||||||
|
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
||||||
|
|
||||||
|
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
||||||
|
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
||||||
|
keys and MCP server prefixes, filtered by current enable state so the
|
||||||
|
user only sees actionable options).
|
||||||
|
"""
|
||||||
|
SUBS = ("list", "disable", "enable")
|
||||||
|
parts = sub_text.split()
|
||||||
|
trailing_space = sub_text.endswith(" ")
|
||||||
|
|
||||||
|
# Subcommand stage: zero words typed, or completing the first word.
|
||||||
|
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
||||||
|
partial = sub_text if not trailing_space else ""
|
||||||
|
for sub in SUBS:
|
||||||
|
if sub.startswith(partial.lower()) and sub != partial.lower():
|
||||||
|
yield Completion(sub, start_position=-len(partial), display=sub)
|
||||||
|
return
|
||||||
|
|
||||||
|
subcommand = parts[0].lower()
|
||||||
|
if subcommand not in ("enable", "disable"):
|
||||||
|
return
|
||||||
|
|
||||||
|
partial = "" if trailing_space else parts[-1]
|
||||||
|
partial_lower = partial.lower()
|
||||||
|
already = set(parts[1:] if trailing_space else parts[1:-1])
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
from hermes_cli.tools_config import (
|
||||||
|
CONFIGURABLE_TOOLSETS,
|
||||||
|
_get_platform_tools,
|
||||||
|
_get_plugin_toolset_keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||||
|
|
||||||
|
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
|
||||||
|
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||||
|
continue
|
||||||
|
is_on = ts_key in enabled
|
||||||
|
if subcommand == "enable" and is_on:
|
||||||
|
continue
|
||||||
|
if subcommand == "disable" and not is_on:
|
||||||
|
continue
|
||||||
|
yield Completion(
|
||||||
|
ts_key,
|
||||||
|
start_position=-len(partial),
|
||||||
|
display=ts_key,
|
||||||
|
display_meta=label,
|
||||||
|
)
|
||||||
|
|
||||||
|
for ts_key in sorted(_get_plugin_toolset_keys()):
|
||||||
|
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||||
|
continue
|
||||||
|
is_on = ts_key in enabled
|
||||||
|
if subcommand == "enable" and is_on:
|
||||||
|
continue
|
||||||
|
if subcommand == "disable" and not is_on:
|
||||||
|
continue
|
||||||
|
yield Completion(
|
||||||
|
ts_key,
|
||||||
|
start_position=-len(partial),
|
||||||
|
display=ts_key,
|
||||||
|
display_meta="plugin toolset",
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp_servers = config.get("mcp_servers") or {}
|
||||||
|
if isinstance(mcp_servers, dict):
|
||||||
|
for server in sorted(mcp_servers):
|
||||||
|
prefix = f"{server}:"
|
||||||
|
if prefix in already or not prefix.startswith(partial_lower):
|
||||||
|
continue
|
||||||
|
yield Completion(
|
||||||
|
prefix,
|
||||||
|
start_position=-len(partial),
|
||||||
|
display=prefix,
|
||||||
|
display_meta=f"MCP server '{server}'",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _personality_completions(sub_text: str, sub_lower: str):
|
def _personality_completions(sub_text: str, sub_lower: str):
|
||||||
"""Yield completions for /personality from configured personalities."""
|
"""Yield completions for /personality from configured personalities."""
|
||||||
@@ -1596,6 +1681,13 @@ class SlashCommandCompleter(Completer):
|
|||||||
yield from self._personality_completions(sub_text, sub_lower)
|
yield from self._personality_completions(sub_text, sub_lower)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# /tools needs multi-word completion (subcommand + toolset name)
|
||||||
|
# so it handles both stages itself, bypassing the single-word
|
||||||
|
# SUBCOMMANDS branch below.
|
||||||
|
if base_cmd == "/tools":
|
||||||
|
yield from self._tools_completions(sub_text, sub_lower)
|
||||||
|
return
|
||||||
|
|
||||||
# Static subcommand completions
|
# Static subcommand completions
|
||||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||||
for sub in SUBCOMMANDS[base_cmd]:
|
for sub in SUBCOMMANDS[base_cmd]:
|
||||||
|
|||||||
@@ -687,6 +687,105 @@ class TestSubcommandCompletion:
|
|||||||
completions = _completions(SlashCommandCompleter(), "/help ")
|
completions = _completions(SlashCommandCompleter(), "/help ")
|
||||||
assert completions == []
|
assert completions == []
|
||||||
|
|
||||||
|
def test_tools_subcommand_completion(self):
|
||||||
|
"""`/tools ` should suggest list, disable, enable."""
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools ")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
assert texts == {"list", "disable", "enable"}
|
||||||
|
|
||||||
|
def test_tools_subcommand_prefix_filters(self):
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools en")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
assert texts == {"enable"}
|
||||||
|
|
||||||
|
def test_tools_enable_completes_toolset_names(self, monkeypatch):
|
||||||
|
"""`/tools enable ` should suggest currently-disabled toolsets."""
|
||||||
|
from hermes_cli import commands as commands_mod
|
||||||
|
|
||||||
|
# `web` is enabled, `spotify` is disabled — enabling should only offer
|
||||||
|
# the disabled ones.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_platform_tools",
|
||||||
|
lambda *_a, **_k: {"web", "file"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||||
|
lambda: set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools enable ")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
# Should include disabled toolsets, exclude already-enabled ones.
|
||||||
|
assert "web" not in texts
|
||||||
|
assert "file" not in texts
|
||||||
|
assert "spotify" in texts
|
||||||
|
|
||||||
|
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_platform_tools",
|
||||||
|
lambda *_a, **_k: {"web", "file"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||||
|
lambda: set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools disable ")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
# Should include enabled toolsets, exclude disabled ones.
|
||||||
|
assert texts == {"web", "file"}
|
||||||
|
|
||||||
|
def test_tools_enable_partial_filters(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_platform_tools",
|
||||||
|
lambda *_a, **_k: set(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||||
|
lambda: set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
assert texts == {"spotify"}
|
||||||
|
|
||||||
|
def test_tools_enable_skips_already_listed(self, monkeypatch):
|
||||||
|
"""If the user already typed a name, don't suggest it again."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_platform_tools",
|
||||||
|
lambda *_a, **_k: set(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||||
|
lambda: set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
assert "spotify" not in texts
|
||||||
|
|
||||||
|
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_platform_tools",
|
||||||
|
lambda *_a, **_k: set(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.config.load_config",
|
||||||
|
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._get_plugin_toolset_keys",
|
||||||
|
lambda: set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
completions = _completions(SlashCommandCompleter(), "/tools enable git")
|
||||||
|
texts = {c.text for c in completions}
|
||||||
|
assert "github:" in texts
|
||||||
|
|
||||||
|
|
||||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user