From 60a664519cf9028a0a1a5f68e79b74beceb275ad Mon Sep 17 00:00:00 2001 From: LOGIN-TB <7146963+LOGIN-TB@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:25:24 +0200 Subject: [PATCH] fix(telegram): prioritize dynamic skill menu commands --- hermes_cli/commands.py | 52 ++++++++++++++++++++----- tests/hermes_cli/test_commands.py | 64 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 0957dbcd98..943204ec5b 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -836,6 +836,8 @@ def _telegram_command_menu_config() -> dict[str, Any]: raw_priority = menu_cfg.get("priority") if isinstance(raw_priority, list): priority = [str(item) for item in raw_priority if str(item).strip()] + elif isinstance(raw_priority, str) and raw_priority.strip(): + priority = [raw_priority] else: priority = [] @@ -1091,6 +1093,32 @@ def _collect_gateway_skill_entries( # any clamp-induced renames. skill_triples = _clamp_command_names(skill_triples, reserved_names) + # Telegram's configured command-menu priority applies to dynamic skills as + # well as core commands. Reorder before trimming so a prioritized skill can + # claim a scarce remaining BotCommand slot instead of losing to the + # alphabetical default order. + if platform == "telegram": + priority = { + name: index + for index, name in enumerate(_telegram_effective_priority()) + } + skill_triples = [ + entry + for original_index, entry in sorted( + enumerate(skill_triples), + key=lambda item: ( + 0, + priority[item[1][0]], + item[0], + ) + if item[1][0] in priority + else ( + 1, + item[0], + ), + ) + ] + # Skills fill remaining slots — only tier that gets trimmed remaining = max(0, max_slots - len(all_entries)) hidden_count = max(0, len(skill_triples) - remaining) @@ -1112,7 +1140,12 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str 2. Plugin slash commands (take precedence over skills) 3. Built-in skill commands (fill remaining slots, alphabetical) - Skills are the only tier that gets trimmed when the cap is hit. + Core, plugin, and skill tiers keep their existing relative order unless a + command is named in ``platforms.telegram.extra.command_menu.priority``. + Explicit priority is applied to the combined candidate list before the Bot + API cap, so a prioritized dynamic command can displace an unprioritized core + command when the core tier already fills the menu. + User-installed hub skills are excluded — accessible via /skills. Skills disabled for the ``"telegram"`` platform (via ``hermes skills config``) are excluded from the menu entirely. @@ -1121,22 +1154,21 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str (menu_commands, hidden_count) where hidden_count is the number of commands omitted due to the cap. """ - core_commands = _prioritize_telegram_menu_commands(list(telegram_bot_commands())) + core_commands = list(telegram_bot_commands()) reserved_names = {n for n, _ in core_commands} - all_commands = list(core_commands) - hidden_core_count = max(0, len(all_commands) - max_commands) - - remaining_slots = max(0, max_commands - len(all_commands)) entries, hidden_count = _collect_gateway_skill_entries( platform="telegram", - max_slots=remaining_slots, + max_slots=max_commands, reserved_names=reserved_names, desc_limit=40, sanitize_name=_sanitize_telegram_name, ) - # Drop the cmd_key — Telegram only needs (name, desc) pairs. - all_commands.extend((n, d) for n, d, _k in entries) - return all_commands[:max_commands], hidden_count + hidden_core_count + # Drop the cmd_key — Telegram only needs (name, desc) pairs. Apply the + # configured priority across all tiers before enforcing the global cap. + all_commands = core_commands + [(n, d) for n, d, _k in entries] + all_commands = _prioritize_telegram_menu_commands(all_commands) + overflow_count = max(0, len(all_commands) - max_commands) + return all_commands[:max_commands], hidden_count + overflow_count def discord_skill_commands( diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index e7fb446a21..a4823bfc2c 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -807,6 +807,70 @@ class TestTelegramMenuCommands: # No empty string in menu names assert "" not in menu_names + def test_configured_priority_promotes_skill_into_last_menu_slot( + self, tmp_path, monkeypatch + ): + """A prioritized dynamic skill must not be trimmed behind alphabetical peers.""" + from unittest.mock import patch + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + local_dir = tmp_path / "skills" + local_dir.mkdir() + fake_cmds = { + "/aaa-skill": { + "name": "aaa-skill", + "description": "Alphabetically first", + "skill_md_path": f"{local_dir}/aaa-skill/SKILL.md", + "skill_dir": f"{local_dir}/aaa-skill", + }, + "/gym": { + "name": "gym", + "description": "GymPilot", + "skill_md_path": f"{local_dir}/gym/SKILL.md", + "skill_dir": f"{local_dir}/gym", + }, + } + core_count = len(telegram_bot_commands()) + + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", local_dir), + patch( + "hermes_cli.commands._telegram_effective_priority", + return_value=("gym",), + ), + ): + menu, hidden = telegram_menu_commands(max_commands=core_count) + + menu_names = [name for name, _description in menu] + assert len(menu_names) == core_count + assert menu_names[0] == "gym" + assert "aaa_skill" not in menu_names + assert hidden == 2 + + def test_scalar_configured_priority_is_accepted_as_one_command(self): + """The config CLI's scalar value form must work for a single priority.""" + from unittest.mock import patch + from hermes_cli.commands import _telegram_effective_priority + + raw_config = { + "platforms": { + "telegram": { + "extra": { + "command_menu": { + "priority": "gym", + "priority_mode": "prepend", + } + } + } + } + } + + with patch("hermes_cli.config.read_raw_config", return_value=raw_config): + priority = _telegram_effective_priority() + + assert priority[0] == "gym" + # --------------------------------------------------------------------------- # Backward-compat aliases