{"repo":"NousResearch/hermes-agent","public_id":"5480","display_label":"Discord slash command registration and limits","summary":"These issues and PRs concern registering skills as native Discord slash commands, including approaches for organizing them (direct commands vs. grouped subcommands) and addressing Discord's 100-command limit and related connectivity problems.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5603},"resolution":{"state":"partially_resolved","by":"pr-5603"},"freshness":{"state":"stale","last_verified_at":"2026-04-06T19:09:36+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":5480,"pr_number":5603,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5480,"title":"feat(discord): register installed skills as native slash commands — parity with Telegram v0.7.0","url":null,"state":"closed"},{"kind":"issue","number":7137,"title":"[Bug]: Fix Discord connectivity (need to reduce registered skills to <100)","url":null,"state":"closed"},{"kind":"pr","number":5498,"title":"feat(discord): register installed skills as native slash commands","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5603","number":5603},{"kind":"pr","value":"#5480","number":5480}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5603,"title":"feat(discord): register skills as native slash commands via shared gateway logic","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","hermes_cli/commands.py","tests/hermes_cli/test_commands.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -1695,6 +1695,47 @@ async def slash_background(interaction: discord.Interaction, prompt: str):\n         async def slash_btw(interaction: discord.Interaction, question: str):\n             await self._run_simple_slash(interaction, f\"/btw {question}\")\n \n+        # Register installed skills as native slash commands (parity with\n+        # Telegram, which uses telegram_menu_commands() in commands.py).\n+        # Discord allows up to 100 application commands globally.\n+        _DISCORD_CMD_LIMIT = 100\n+        try:\n+            from hermes_cli.commands import discord_skill_commands\n+\n+            existing_names = {cmd.name for cmd in tree.get_commands()}\n+            remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))\n+\n+            skill_entries, skipped = discord_skill_commands(\n+                max_slots=remaining_slots,\n+                reserved_names=existing_names,\n+            )\n+\n+            for discord_name, description, cmd_key in skill_entries:\n+                # Closure factory to capture cmd_key per iteration\n+                def _make_skill_handler(_key: str):\n+                    async def _skill_slash(interaction: discord.Interaction, args: str = \"\"):\n+                        await self._run_simple_slash(interaction, f\"{_key} {args}\".strip())\n+                    return _skill_slash\n+\n+                handler = _make_skill_handler(cmd_key)\n+                handler.__name__ = f\"skill_{discord_name.replace('-', '_')}\"\n+\n+                cmd = discord.app_commands.Command(\n+                    name=discord_name,\n+                    description=description,\n+                    callback=handler,\n+                )\n+                discord.app_commands.describe(args=\"Optional arguments for the skill\")(cmd)\n+                tree.add_command(cmd)\n+\n+            if skipped:\n+                logger.warning(\n+                    \"[%s] Discord slash command limit reached (%d): %d skill(s) not registered\",\n+                    self.name, _DISCORD_CMD_LIMIT, skipped,\n+                )\n+        except Exception as exc:\n+            logger.warning(\"[%s] Failed to register skill slash commands: %s\", self.name, exc)\n+\n     def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:\n         \"\"\"Build a MessageEvent from a Discord slash command interaction.\"\"\"\n         is_dm = isinstance(interaction.channel, discord.DMChannel)"]},{"file":"hermes_cli/commands.py","hunks":["@@ -372,7 +372,11 @@ def telegram_bot_commands() -> list[tuple[str, str]]:\n     return result\n \n \n-_TG_NAME_LIMIT = 32\n+_CMD_NAME_LIMIT = 32\n+\"\"\"Max command name length shared by Telegram and Discord.\"\"\"\n+\n+# Backward-compat alias — tests and external code may reference the old name.\n+_TG_NAME_LIMIT = _CMD_NAME_LIMIT\n \n # Telegram Bot API allows only lowercase a-z, 0-9, and underscores in\n # command names.  This regex strips everything else after initial conversion.","@@ -394,13 +398,14 @@ def _sanitize_telegram_name(raw: str) -> str:\n     return name.strip(\"_\")\n \n \n-def _clamp_telegram_names(\n+def _clamp_command_names(\n     entries: list[tuple[str, str]],\n     reserved: set[str],\n ) -> list[tuple[str, str]]:\n-    \"\"\"Enforce Telegram's 32-char command name limit with collision avoidance.\n+    \"\"\"Enforce 32-char command name limit with collision avoidance.\n \n-    Names exceeding 32 chars are truncated.  If truncation creates a duplicate\n+    Both Telegram and Discord cap slash command names at 32 characters.\n+    Names exceeding the limit are truncated.  If truncation creates a duplicate\n     (against *reserved* names or earlier entries in the same batch), the name is\n     shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.\n     If all 10 digit slots are taken the entry is silently dropped.","@@ -408,10 +413,10 @@ def _clamp_telegram_names(\n     used: set[str] = set(reserved)\n     result: list[tuple[str, str]] = []\n     for name, desc in entries:\n-        if len(name) > _TG_NAME_LIMIT:\n-            candidate = name[:_TG_NAME_LIMIT]\n+        if len(name) > _CMD_NAME_LIMIT:\n+            candidate = name[:_CMD_NAME_LIMIT]\n             if candidate in used:\n-                prefix = name[:_TG_NAME_LIMIT - 1]\n+                prefix = name[:_CMD_NAME_LIMIT - 1]\n                 for digit in range(10):\n                     candidate = f\"{prefix}{digit}\"\n                     if candidate not in used:","@@ -427,63 +432,83 @@ def _clamp_telegram_names(\n     return result\n \n \n-def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:\n-    \"\"\"Return Telegram menu commands capped to the Bot API limit.\n+# Backward-compat alias.\n+_clamp_telegram_names = _clamp_command_names\n \n-    Priority order (higher priority = never bumped by overflow):\n-      1. Core CommandDef commands (always included)\n-      2. Plugin slash commands (take precedence over skills)\n-      3. Built-in skill commands (fill remaining slots, alphabetical)\n \n-    Skills are the only tier that gets trimmed when the cap is hit.\n-    User-installed hub skills are excluded — accessible via /skills.\n-    Skills disabled for the ``\"telegram\"`` platform (via ``hermes skills\n-    config``) are excluded from the menu entirely.\n+# ---------------------------------------------------------------------------\n+# Shared skill/plugin collection for gateway platforms\n+# ---------------------------------------------------------------------------\n+\n+def _collect_gateway_skill_entries(\n+    platform: str,\n+    max_slots: int,\n+    reserved_names: set[str],\n+    desc_limit: int = 100,\n+    sanitize_name: \"Callable[[str], str] | None\" = None,\n+) -> tuple[list[tuple[str, str, str]], int]:\n+    \"\"\"Collect plugin + skill entries for a gateway platform.\n+\n+    Priority order:\n+      1. Plugin slash commands (take precedence over skills)\n+      2. Built-in skill commands (fill remaining slots, alphabetical)\n+\n+    Only skills are trimmed when the cap is reached.\n+    Hub-installed skills are excluded.  Per-platform disabled skills are\n+    excluded.\n+\n+    Args:\n+        platform: Platform identifier for per-platform skill filtering\n+            (``\"telegram\"``, ``\"discord\"``, etc.).\n+        max_slots: Maximum number of entries to return (remaining slots after\n+            built-in/core commands).\n+        reserved_names: Names already taken by built-in commands.  Mutated\n+            in-place as new names are added.\n+        desc_limit: Max description length (40 for Telegram, 100 for Discord).\n+        sanitize_name: Optional name transform applied before clamping, e.g.\n+            :func:`_sanitize_telegram_name` for Telegram.  May return an\n+            empty string to signal \"skip this entry\".\n \n     Returns:\n-        (menu_commands, hidden_count) where hidden_count is the number of\n-        skill commands omitted due to the cap.\n+        ``(entries, hidden_count)`` where *entries* is a list of\n+        ``(name, description, cmd_key)`` triples and *hidden_count* is the\n+        number of skill entries dropped due to the cap.  ``cmd_key`` is the\n+        original ``/skill-name`` key from :func:`get_skill_commands`.\n     \"\"\"\n-    core_commands = list(telegram_bot_commands())\n-    # Reserve core names so plugin/skill truncation can't collide with them\n-    reserved_names = {n for n, _ in core_commands}\n-    all_commands = list(core_commands)\n+    all_entries: list[tuple[str, str, str]] = []\n \n-    # Plugin slash commands get priority over skills\n-    plugin_entries: list[tuple[str, str]] = []\n+    # --- Tier 1: Plugin slash commands (never trimmed) ---------------------\n+    plugin_pairs: list[tuple[str, str]] = []\n     try:\n         from hermes_cli.plugins import get_plugin_manager\n         pm = get_plugin_manager()\n         plugin_cmds = getattr(pm, \"_plugin_commands\", {})\n         for cmd_name in sorted(plugin_cmds):\n-            tg_name = _sanitize_telegram_name(cmd_name)\n-            if not tg_name:\n+            name = sanitize_name(cmd_name) if sanitize_name else cmd_name\n+            if not name:\n                 continue\n             desc = \"Plugin command\"\n-            if len(desc) > 40:\n-                desc = desc[:37] + \"...\"\n-            plugin_entries.append((tg_name, desc))\n+            if len(desc) > desc_limit:\n+                desc = desc[:desc_limit - 3] + \"...\"\n+            plugin_pairs.append((name, desc))\n     except Exception:\n         pass\n \n-    # Clamp plugin names to 32 chars with collision avoidance\n-    plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names)\n-    reserved_names.update(n for n, _ in plugin_entries)\n-    all_commands.extend(plugin_entries)\n+    plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)\n+    reserved_names.update(n for n, _ in plugin_pairs)\n+    # Plugins have no cmd_key — use empty string as placeholder\n+    for n, d in plugin_pairs:\n+        all_entries.append((n, d, \"\"))\n \n-    # Load per-platform disabled skills so they don't consume menu slots.\n-    # get_skill_commands() already filters the *global* disabled list, but\n-    # per-platform overrides (skills.platform_disabled.telegram) were never\n-    # applied here — that's what this block fixes.\n+    # --- Tier 2: Built-in skill commands (trimmed at cap) -----------------\n     _platform_disabled: set[str] = set()\n     try:\n         from agent.skill_utils import get_disabled_skill_names\n-        _platform_disabled = get_disabled_skill_names(platform=\"telegram\")\n+        _platform_disabled = get_disabled_skill_names(platform=platform)\n     except Exception:\n         pass\n \n-    # Remaining slots go to built-in skill commands (not hub-installed).\n-    skill_entries: list[tuple[str, str]] = []\n+    skill_triples: list[tuple[str, str, str]] = []\n     try:\n         from agent.skill_commands import get_skill_commands\n         from tools.skills_tool import SKILLS_DIR","@@ -497,32 +522,103 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str\n                 continue\n             if skill_path.startswith(_hub_dir):\n                 continue\n-            # Skip skills disabled for telegram\n             skill_name = info.get(\"name\", \"\")\n             if skill_name in _platform_disabled:\n                 continue\n-            name = _sanitize_telegram_name(cmd_key.lstrip(\"/\"))\n+            raw_name = cmd_key.lstrip(\"/\")\n+            name = sanitize_name(raw_name) if sanitize_name else raw_name\n             if not name:\n                 continue\n             desc = info.get(\"description\", \"\")\n-            # Keep descriptions short — setMyCommands has an undocumented\n-            # total payload limit.  40 chars fits 100 commands safely.\n-            if len(desc) > 40:\n-                desc = desc[:37] + \"...\"\n-            skill_entries.append((name, desc))\n+            if len(desc) > desc_limit:\n+                desc = desc[:desc_limit - 3] + \"...\"\n+            skill_triples.append((name, desc, cmd_key))\n     except Exception:\n         pass\n \n-    # Clamp skill names to 32 chars with collision avoidance\n-    skill_entries = _clamp_telegram_names(skill_entries, reserved_names)\n+    # Clamp names; _clamp_command_names works on (name, desc) pairs so we\n+    # need to zip/unzip.\n+    skill_pairs = [(n, d) for n, d, _ in skill_triples]\n+    key_by_pair = {(n, d): k for n, d, k in skill_triples}\n+    skill_pairs = _clamp_command_names(skill_pairs, reserved_names)\n+\n+    # Skills fill remaining slots — only tier that gets trimmed\n+    remaining = max(0, max_slots - len(all_entries))\n+    hidden_count = max(0, len(skill_pairs) - remaining)\n+    for n, d in skill_pairs[:remaining]:\n+        all_entries.append((n, d, key_by_pair.get((n, d), \"\")))\n+\n+    return all_entries[:max_slots], hidden_count\n+\n+\n+# ---------------------------------------------------------------------------\n+# Platform-specific wrappers\n+# ---------------------------------------------------------------------------\n+\n+def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:\n+    \"\"\"Return Telegram menu commands capped to the Bot API limit.\n+\n+    Priority order (higher priority = never bumped by overflow):\n+      1. Core CommandDef commands (always included)\n+      2. Plugin slash commands (take precedence over skills)\n+      3. Built-in skill commands (fill remaining slots, alphabetical)\n+\n+    Skills are the only tier that gets trimmed when the cap is hit.\n+    User-installed hub skills are excluded — accessible via /skills.\n+    Skills disabled for the ``\"telegram\"`` platform (via ``hermes skills\n+    config``) are excluded from the menu entirely.\n+\n+    Returns:\n+        (menu_commands, hidden_count) where hidden_count is the number of\n+        skill commands omitted due to the cap.\n+    \"\"\"\n+    core_commands = list(telegram_bot_commands())\n+    reserved_names = {n for n, _ in core_commands}\n+    all_commands = list(core_commands)\n \n-    # Skills fill remaining slots — they're the only tier that gets trimmed\n     remaining_slots = max(0, max_commands - len(all_commands))\n-    hidden_count = max(0, len(skill_entries) - remaining_slots)\n-    all_commands.extend(skill_entries[:remaining_slots])\n+    entries, hidden_count = _collect_gateway_skill_entries(\n+        platform=\"telegram\",\n+        max_slots=remaining_slots,\n+        reserved_names=reserved_names,\n+        desc_limit=40,\n+        sanitize_name=_sanitize_telegram_name,\n+    )\n+    # Drop the cmd_key — Telegram only needs (name, desc) pairs.\n+    all_commands.extend((n, d) for n, d, _k in entries)\n     return all_commands[:max_commands], hidden_count\n \n \n+def discord_skill_commands(\n+    max_slots: int,\n+    reserved_names: set[str],\n+) -> tuple[list[tuple[str, str, str]], int]:\n+    \"\"\"Return skill entries for Discord slash command registration.\n+\n+    Same priority and filtering logic as :func:`telegram_menu_commands`\n+    (plugins > skills, hub excluded, per-platform disabled excluded), but\n+    adapted for Discord's constraints:\n+\n+    - Hyphens are allowed in names (no ``-`` → ``_`` sanitization)\n+    - Descriptions capped at 100 chars (Discord's per-field max)\n+\n+    Args:\n+        max_slots: Available command slots (100 minus existing built-in count).\n+        reserved_names: Names of already-registered built-in commands.\n+\n+    Returns:\n+        ``(entries, hidden_count)`` where *entries* is a list of\n+        ``(discord_name, description, cmd_key)`` triples.  ``cmd_key`` is\n+        the original ``/skill-name`` key needed for the slash handler callback.\n+    \"\"\"\n+    return _collect_gateway_skill_entries(\n+        platform=\"discord\",\n+        max_slots=max_slots,\n+        reserved_names=set(reserved_names),  # copy — don't mutate caller's set\n+        desc_limit=100,\n+    )\n+\n+\n def slack_subcommand_map() -> dict[str, str]:\n     \"\"\"Return subcommand -> /command mapping for Slack /hermes handler.\n "]},{"file":"tests/hermes_cli/test_commands.py","hunks":["@@ -12,9 +12,12 @@\n     SUBCOMMANDS,\n     SlashCommandAutoSuggest,\n     SlashCommandCompleter,\n+    _CMD_NAME_LIMIT,\n     _TG_NAME_LIMIT,\n+    _clamp_command_names,\n     _clamp_telegram_names,\n     _sanitize_telegram_name,\n+    discord_skill_commands,\n     gateway_help_lines,\n     resolve_command,\n     slack_subcommand_map,","@@ -751,3 +754,238 @@ def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):\n         assert \"valid_skill\" in menu_names\n         # No empty string in menu names\n         assert \"\" not in menu_names\n+\n+\n+# ---------------------------------------------------------------------------\n+# Backward-compat aliases\n+# ---------------------------------------------------------------------------\n+\n+class TestBackwardCompatAliases:\n+    \"\"\"The renamed constants/functions still exist under the old names.\"\"\"\n+\n+    def test_tg_name_limit_alias(self):\n+        assert _TG_NAME_LIMIT == _CMD_NAME_LIMIT == 32\n+\n+    def test_clamp_telegram_names_is_clamp_command_names(self):\n+        assert _clamp_telegram_names is _clamp_command_names\n+\n+\n+# ---------------------------------------------------------------------------\n+# Discord skill command registration\n+# ---------------------------------------------------------------------------\n+\n+class TestDiscordSkillCommands:\n+    \"\"\"Tests for discord_skill_commands() — centralized skill registration.\"\"\"\n+\n+    def test_returns_skill_entries(self, tmp_path, monkeypatch):\n+        \"\"\"Skills under SKILLS_DIR (not .hub) should be returned.\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/gif-search\": {\n+                \"name\": \"gif-search\",\n+                \"description\": \"Search for GIFs\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/gif-search/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/gif-search\",\n+            },\n+            \"/code-review\": {\n+                \"name\": \"code-review\",\n+                \"description\": \"Review code changes\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/code-review/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/code-review\",\n+            },\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, hidden = discord_skill_commands(\n+                max_slots=50, reserved_names=set(),\n+            )\n+\n+        names = {n for n, _d, _k in entries}\n+        assert \"gif-search\" in names\n+        assert \"code-review\" in names\n+        assert hidden == 0\n+        # Verify cmd_key is preserved for handler callbacks\n+        keys = {k for _n, _d, k in entries}\n+        assert \"/gif-search\" in keys\n+        assert \"/code-review\" in keys\n+\n+    def test_names_allow_hyphens(self, tmp_path, monkeypatch):\n+        \"\"\"Discord names should keep hyphens (unlike Telegram's _ sanitization).\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/my-cool-skill\": {\n+                \"name\": \"my-cool-skill\",\n+                \"description\": \"A cool skill\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/my-cool-skill/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/my-cool-skill\",\n+            },\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, _ = discord_skill_commands(\n+                max_slots=50, reserved_names=set(),\n+            )\n+\n+        assert entries[0][0] == \"my-cool-skill\"  # hyphens preserved\n+\n+    def test_cap_enforcement(self, tmp_path, monkeypatch):\n+        \"\"\"Entries beyond max_slots should be hidden.\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            f\"/skill-{i:03d}\": {\n+                \"name\": f\"skill-{i:03d}\",\n+                \"description\": f\"Skill {i}\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/skill-{i:03d}/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/skill-{i:03d}\",\n+            }\n+            for i in range(20)\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, hidden = discord_skill_commands(\n+                max_slots=5, reserved_names=set(),\n+            )\n+\n+        assert len(entries) == 5\n+        assert hidden == 15\n+\n+    def test_excludes_discord_disabled_skills(self, tmp_path, monkeypatch):\n+        \"\"\"Skills disabled for discord should not appear.\"\"\"\n+        from unittest.mock import patch\n+\n+        config_file = tmp_path / \"config.yaml\"\n+        config_file.write_text(\n+            \"skills:\\n\"\n+            \"  platform_disabled:\\n\"\n+            \"    discord:\\n\"\n+            \"      - secret-skill\\n\"\n+        )\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/secret-skill\": {\n+                \"name\": \"secret-skill\",\n+                \"description\": \"Should not appear\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/secret-skill/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/secret-skill\",\n+            },\n+            \"/public-skill\": {\n+                \"name\": \"public-skill\",\n+                \"description\": \"Should appear\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/public-skill/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/public-skill\",\n+            },\n+        }\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, _ = discord_skill_commands(\n+                max_slots=50, reserved_names=set(),\n+            )\n+\n+        names = {n for n, _d, _k in entries}\n+        assert \"secret-skill\" not in names\n+        assert \"public-skill\" in names\n+\n+    def test_reserved_names_not_overwritten(self, tmp_path, monkeypatch):\n+        \"\"\"Skills whose names collide with built-in commands should be skipped.\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        fake_cmds = {\n+            \"/status\": {\n+                \"name\": \"status\",\n+                \"description\": \"Skill that collides with built-in\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/status/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/status\",\n+            },\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, _ = discord_skill_commands(\n+                max_slots=50, reserved_names={\"status\"},\n+            )\n+\n+        names = {n for n, _d, _k in entries}\n+        assert \"status\" not in names\n+\n+    def test_description_truncated_at_100_chars(self, tmp_path, monkeypatch):\n+        \"\"\"Descriptions exceeding 100 chars should be truncated.\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        long_desc = \"x\" * 150\n+        fake_cmds = {\n+            \"/verbose-skill\": {\n+                \"name\": \"verbose-skill\",\n+                \"description\": long_desc,\n+                \"skill_md_path\": f\"{fake_skills_dir}/verbose-skill/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/verbose-skill\",\n+            },\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, _ = discord_skill_commands(\n+                max_slots=50, reserved_names=set(),\n+            )\n+\n+        assert len(entries[0][1]) == 100\n+        assert entries[0][1].endswith(\"...\")\n+\n+    def test_all_names_within_32_chars(self, tmp_path, monkeypatch):\n+        \"\"\"All returned names must respect the 32-char Discord limit.\"\"\"\n+        from unittest.mock import patch\n+\n+        fake_skills_dir = str(tmp_path / \"skills\")\n+        long_name = \"a\" * 50\n+        fake_cmds = {\n+            f\"/{long_name}\": {\n+                \"name\": long_name,\n+                \"description\": \"Long name skill\",\n+                \"skill_md_path\": f\"{fake_skills_dir}/{long_name}/SKILL.md\",\n+                \"skill_dir\": f\"{fake_skills_dir}/{long_name}\",\n+            },\n+        }\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"skills\").mkdir(exist_ok=True)\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\"),\n+        ):\n+            entries, _ = discord_skill_commands(\n+                max_slots=50, reserved_names=set(),\n+            )\n+\n+        for name, _d, _k in entries:\n+            assert len(name) <= _CMD_NAME_LIMIT, (\n+                f\"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})\"\n+            )\n"]}]},{"kind":"pr","number":7805,"title":"fix(discord): move slash command sync to background to prevent reconnect loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8334","number":8334},{"kind":"pr","value":"#7456","number":7456},{"kind":"pr","value":"#7452","number":7452},{"kind":"pr","value":"#7979","number":7979}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}