fix(gateway): resolve Telegram's underscored /commands to skill/plugin keys

Telegram's Bot API disallows hyphens in command names, so
_build_telegram_menu registers /claude-code as /claude_code. When the
user taps it from autocomplete, the gateway dispatch did a direct
lookup against skill_cmds (keyed on the hyphenated form) and missed,
silently falling through to the LLM as plain text. The model would
then typically call delegate_task, spawning a Hermes subagent instead
of invoking the intended skill.

Normalize underscores to hyphens in skill and plugin command lookup,
matching the existing pattern in _check_unavailable_skill.
This commit is contained in:
analista 2026-04-05 09:35:26 +00:00 committed by Teknium
parent afccbf253c
commit 4a75aec433
3 changed files with 82 additions and 5 deletions

View file

@ -217,6 +217,25 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical skill_cmds key.
Skills are always stored with hyphens ``scan_skill_commands`` normalizes
spaces and underscores to hyphens when building the key. Hyphens and
underscores are treated interchangeably in user input: this matches
``_check_unavailable_skill`` and accommodates Telegram bot-command names
(which disallow hyphens, so ``/claude-code`` is registered as
``/claude_code`` and comes back in the underscored form).
Returns the matching ``/slug`` key from ``get_skill_commands()`` or
``None`` if no match.
"""
if not command:
return None
cmd_key = f"/{command.replace('_', '-')}"
return cmd_key if cmd_key in get_skill_commands() else None
def build_skill_invocation_message(
cmd_key: str,
user_instruction: str = "",

View file

@ -2112,7 +2112,10 @@ class GatewayRunner:
if command:
try:
from hermes_cli.plugins import get_plugin_command_handler
plugin_handler = get_plugin_command_handler(command)
# Normalize underscores to hyphens so Telegram's underscored
# autocomplete form matches plugin commands registered with
# hyphens. See hermes_cli/commands.py:_build_telegram_menu.
plugin_handler = get_plugin_command_handler(command.replace("_", "-"))
if plugin_handler:
user_args = event.get_command_args().strip()
import asyncio as _aio
@ -2123,13 +2126,20 @@ class GatewayRunner:
except Exception as e:
logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
# Skill slash commands: /skill-name loads the skill and sends to agent
# Skill slash commands: /skill-name loads the skill and sends to agent.
# resolve_skill_command_key() handles the Telegram underscore/hyphen
# round-trip so /claude_code from Telegram autocomplete still resolves
# to the claude-code skill.
if command:
try:
from agent.skill_commands import get_skill_commands, build_skill_invocation_message
from agent.skill_commands import (
get_skill_commands,
build_skill_invocation_message,
resolve_skill_command_key,
)
skill_cmds = get_skill_commands()
cmd_key = f"/{command}"
if cmd_key in skill_cmds:
cmd_key = resolve_skill_command_key(command)
if cmd_key is not None:
# Check per-platform disabled status before executing.
# get_skill_commands() only applies the *global* disabled
# list at scan time; per-platform overrides need checking

View file

@ -10,6 +10,7 @@ from agent.skill_commands import (
build_plan_path,
build_preloaded_skills_prompt,
build_skill_invocation_message,
resolve_skill_command_key,
scan_skill_commands,
)
@ -101,6 +102,53 @@ class TestScanSkillCommands:
assert "/disabled-skill" not in result
class TestResolveSkillCommandKey:
"""Telegram bot-command names disallow hyphens, so the menu registers
skills with hyphens swapped for underscores. When Telegram autocomplete
sends the underscored form back, we need to find the hyphenated key.
"""
def test_hyphenated_form_matches_directly(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "claude-code")
scan_skill_commands()
assert resolve_skill_command_key("claude-code") == "/claude-code"
def test_underscore_form_resolves_to_hyphenated_skill(self, tmp_path):
"""/claude_code from Telegram autocomplete must resolve to /claude-code."""
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "claude-code")
scan_skill_commands()
assert resolve_skill_command_key("claude_code") == "/claude-code"
def test_single_word_command_resolves(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "investigate")
scan_skill_commands()
assert resolve_skill_command_key("investigate") == "/investigate"
def test_unknown_command_returns_none(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "claude-code")
scan_skill_commands()
assert resolve_skill_command_key("does_not_exist") is None
assert resolve_skill_command_key("does-not-exist") is None
def test_empty_command_returns_none(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
scan_skill_commands()
assert resolve_skill_command_key("") is None
def test_hyphenated_command_is_not_mangled(self, tmp_path):
"""A user-typed /foo-bar (hyphen) must not trigger the underscore fallback."""
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "foo-bar")
scan_skill_commands()
assert resolve_skill_command_key("foo-bar") == "/foo-bar"
# Underscore form also works (Telegram round-trip)
assert resolve_skill_command_key("foo_bar") == "/foo-bar"
class TestBuildPreloadedSkillsPrompt:
def test_builds_prompt_for_multiple_named_skills(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):