Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 36 additions & 35 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5478,29 +5478,30 @@ async def _handler(interaction: discord.Interaction):
slot_cap = _DISCORD_MAX_APP_COMMANDS - 1
dropped_over_cap = 0
try:
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates

try:
already_registered = {cmd.name for cmd in tree.get_commands()}
except Exception:
pass
already_registered = {cmd.name for cmd in tree.get_commands()}
except Exception:
pass

config_overrides = _resolve_config_gates()
# ── Plugin-registered slash commands ──
# Plugins register via PluginContext.register_command(); we mirror
# those into Discord's native slash picker so users get the same
# autocomplete UX as for built-in commands. Explicit user plugin
# commands take priority over lower-priority generated built-ins, so
# they remain reachable when Discord's 100-command cap is full.
try:
from hermes_cli.commands import _iter_plugin_command_entries

for cmd_def in COMMAND_REGISTRY:
if not _is_gateway_available(cmd_def, config_overrides):
continue
# Discord command names: lowercase, hyphens OK, max 32 chars.
discord_name = cmd_def.name.lower()[:32]
for plugin_name, plugin_desc, plugin_args_hint in _iter_plugin_command_entries():
discord_name = plugin_name.lower()[:32]
if discord_name in already_registered:
continue
if len(already_registered) >= slot_cap:
dropped_over_cap += 1
continue
auto_cmd = _build_auto_slash_command(
cmd_def.name,
cmd_def.description,
cmd_def.args_hint,
plugin_name,
plugin_desc,
plugin_args_hint,
)
try:
tree.add_command(auto_cmd)
Expand All @@ -5509,33 +5510,30 @@ async def _handler(interaction: discord.Interaction):
# Silently skip commands that fail registration (e.g.
# name conflict with a subcommand group).
pass

logger.debug(
"Discord auto-registered %d commands from COMMAND_REGISTRY",
len(already_registered),
)
except Exception as e:
logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e)
logger.warning(
"Discord auto-register from plugin commands failed: %s", e
)

# ── Plugin-registered slash commands ──
# Plugins register via PluginContext.register_command(); we mirror
# those into Discord's native slash picker so users get the same
# autocomplete UX as for built-in commands. No per-platform plugin
# API needed — plugin commands are platform-agnostic.
try:
from hermes_cli.commands import _iter_plugin_command_entries
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates

for plugin_name, plugin_desc, plugin_args_hint in _iter_plugin_command_entries():
discord_name = plugin_name.lower()[:32]
config_overrides = _resolve_config_gates()

for cmd_def in COMMAND_REGISTRY:
if not _is_gateway_available(cmd_def, config_overrides):
continue
# Discord command names: lowercase, hyphens OK, max 32 chars.
discord_name = cmd_def.name.lower()[:32]
if discord_name in already_registered:
continue
if len(already_registered) >= slot_cap:
dropped_over_cap += 1
continue
auto_cmd = _build_auto_slash_command(
plugin_name,
plugin_desc,
plugin_args_hint,
cmd_def.name,
cmd_def.description,
cmd_def.args_hint,
)
try:
tree.add_command(auto_cmd)
Expand All @@ -5544,10 +5542,13 @@ async def _handler(interaction: discord.Interaction):
# Silently skip commands that fail registration (e.g.
# name conflict with a subcommand group).
pass
except Exception as e:
logger.warning(
"Discord auto-register from plugin commands failed: %s", e

logger.debug(
"Discord auto-registered %d commands from COMMAND_REGISTRY",
len(already_registered),
)
except Exception as e:
logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e)

# Register skills under a single /skill command group with category
# subcommand groups. This uses 1 top-level slot instead of N,
Expand Down
42 changes: 42 additions & 0 deletions tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,48 @@ async def test_slash_command_registration_stays_under_discord_limit(adapter):
assert len(registered_plugins) < 200, "cap did not drop any overflow commands"


@pytest.mark.asyncio
async def test_plugin_commands_precede_generated_builtins_at_discord_cap(adapter):
"""Explicit plugin commands survive generated built-in overflow.

Native commands remain first, and the consolidated /skill command still
gets its reserved final slot. This reproduces the Discord picker symptom
where a valid user plugin command (for example /krieger) vanished once
generated gateway commands filled the 100-command application cap.
"""
from hermes_cli.commands import CommandDef

generated_builtins = [
CommandDef(f"generated{i:03d}", "Generated built-in", "Info")
for i in range(200)
]
krieger = {
"krieger": {
"handler": lambda _a: "ok",
"description": "Run the Krieger plugin command",
"args_hint": "",
"plugin": "krieger",
}
}

with patch("hermes_cli.commands.COMMAND_REGISTRY", generated_builtins), \
patch("hermes_cli.commands._is_gateway_available", return_value=True), \
patch("hermes_cli.commands._resolve_config_gates", return_value={}), \
patch("hermes_cli.plugins.get_plugin_commands", return_value=krieger), \
patch(
"hermes_cli.commands.discord_skill_commands_by_category",
return_value=({"tools": [("demo", "Demo skill", "/demo")]}, [], 0),
):
adapter._register_slash_commands()

tree_names = set(adapter._client.tree.commands)
for native in ("status", "stop", "new", "model", "help"):
assert native in tree_names
assert "krieger" in tree_names
assert "skill" in tree_names
assert len(tree_names) <= 100


# ------------------------------------------------------------------
# _handle_thread_create_slash — success, session dispatch, failure
# ------------------------------------------------------------------
Expand Down