fix(discord): /reload-skills now refreshes the /skill autocomplete live (#18754)
`_register_skill_group` captured the skill catalog in closure variables (`entries` and `skill_lookup`) so the single `tree.add_command` call at startup owned the only live copy. The closure is never re-entered after startup, so `/reload-skills` — which rescans the on-disk skills dir and refreshes the in-process `_skill_commands` registry — had no way to propagate results into the `/skill` autocomplete on Discord. New skills stayed invisible in the dropdown, and deleted skills returned "Unknown skill" when the stale autocomplete entry was clicked. The fix is purely a dataflow change: promote `entries` and `skill_lookup` to instance attributes (`_skill_entries`, `_skill_lookup`), split the collector-driven rebuild into a helper (`_refresh_skill_catalog_state`), and add a public `refresh_skill_group()` method that re-runs the helper and is safe to call at any point after the initial registration. The gateway's `_handle_reload_skills_command` then iterates `self.adapters` and calls `refresh_skill_group()` on any adapter that exposes it (currently only Discord). Both sync and async implementations are supported; adapters that don't override the method (Telegram's BotCommand menu, Slack subcommand map, etc.) are silently skipped — the in-process `reload_skills()` call covers them. No `tree.sync()` is required because Discord fetches autocomplete options dynamically on every keystroke — mutating the instance state the callbacks already read from is sufficient. That sidesteps the per-app command-bucket rate limit (~5 writes / 20 s) that made the previous bulk-sync-on-reload approach unusable (#16713 context). Tests: tests/gateway/test_reload_skills_discord_resync.py — five cases covering (1) refresh replaces entries, (2) entries stay sorted after refresh, (3) collector exception leaves cached state intact, (4) `_refresh_skill_catalog_state` populates the instance attrs, (5) orchestrator calls `refresh_skill_group()` on sync + async adapters and skips adapters that don't expose it.
This commit is contained in:
244
tests/gateway/test_reload_skills_discord_resync.py
Normal file
244
tests/gateway/test_reload_skills_discord_resync.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""Tests for `/reload-skills` resyncing the Discord ``/skill`` autocomplete.
|
||||
|
||||
Before this change, ``_register_skill_group`` captured the skill catalog
|
||||
in closure variables (``entries`` and ``skill_lookup``) so that the one
|
||||
``tree.add_command`` call at startup owned the only live copy of the
|
||||
skill list. The closure is never re-entered after startup, so
|
||||
``/reload-skills`` (which rescans the on-disk skill dir and refreshes
|
||||
the in-process registry) had no way to propagate its results into the
|
||||
autocomplete — new skills stayed invisible in the dropdown and deleted
|
||||
skills returned an "Unknown skill" error when the stale autocomplete
|
||||
entry was clicked.
|
||||
|
||||
The fix promotes those two variables to instance attributes
|
||||
(``_skill_entries`` / ``_skill_lookup``) and exposes a
|
||||
``refresh_skill_group()`` method that rescans and mutates them in
|
||||
place. The gateway ``_handle_reload_skills_command`` iterates its
|
||||
connected adapters and calls the method on any that expose it.
|
||||
|
||||
No ``tree.sync()`` is required because Discord fetches autocomplete
|
||||
options dynamically on every keystroke — we only need to rebind the
|
||||
data the live callbacks already read from.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
"""Construct a DiscordAdapter without going through __init__ / token checks."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from gateway.platforms.base import Platform
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
# ``platform`` is set by BasePlatformAdapter.__init__, which we skip
|
||||
# above; the inherited ``.name`` property dereferences it for log
|
||||
# formatting, so set it explicitly.
|
||||
adapter.platform = Platform.DISCORD
|
||||
return adapter
|
||||
|
||||
|
||||
class TestRefreshSkillGroup:
|
||||
def test_refresh_repopulates_entries_after_catalog_change(
|
||||
self, monkeypatch
|
||||
) -> None:
|
||||
"""The initial catalog is replaced wholesale on refresh.
|
||||
|
||||
Mirrors the observable /reload-skills case: a user adds a new
|
||||
skill to ~/.hermes/skills/, runs /reload-skills, and expects
|
||||
the autocomplete to surface it on the very next keystroke.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
|
||||
# Start-of-process state: /register built the catalog from the
|
||||
# original collector output.
|
||||
adapter._skill_entries = [
|
||||
("old-skill", "Pre-existing skill", "/old-skill"),
|
||||
]
|
||||
adapter._skill_lookup = {"old-skill": ("Pre-existing skill", "/old-skill")}
|
||||
adapter._skill_group_reserved_names = set()
|
||||
adapter._skill_group_hidden_count = 0
|
||||
|
||||
# User adds new-skill to disk and removes old-skill.
|
||||
def fake_collector(*, reserved_names):
|
||||
return (
|
||||
{"creative": [("new-skill", "Fresh skill", "/new-skill")]}, # categories
|
||||
[], # uncategorized
|
||||
0, # hidden
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
fake_collector,
|
||||
)
|
||||
|
||||
new_count, hidden = adapter.refresh_skill_group()
|
||||
|
||||
assert new_count == 1
|
||||
assert hidden == 0
|
||||
# Old skill is gone, new skill is present.
|
||||
names = [n for n, _d, _k in adapter._skill_entries]
|
||||
assert names == ["new-skill"]
|
||||
assert "old-skill" not in adapter._skill_lookup
|
||||
assert adapter._skill_lookup["new-skill"] == ("Fresh skill", "/new-skill")
|
||||
|
||||
def test_refresh_sorts_entries_alphabetically(self, monkeypatch) -> None:
|
||||
"""Autocomplete order must be stable and predictable across refreshes."""
|
||||
adapter = _make_adapter()
|
||||
adapter._skill_entries = []
|
||||
adapter._skill_lookup = {}
|
||||
adapter._skill_group_reserved_names = set()
|
||||
adapter._skill_group_hidden_count = 0
|
||||
|
||||
def fake_collector(*, reserved_names):
|
||||
# Intentionally unsorted — the fix must resort.
|
||||
return (
|
||||
{"zzz": [("zebra", "", "/zebra")]},
|
||||
[("alpha", "", "/alpha")],
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
fake_collector,
|
||||
)
|
||||
|
||||
adapter.refresh_skill_group()
|
||||
|
||||
names = [n for n, _d, _k in adapter._skill_entries]
|
||||
assert names == sorted(names) == ["alpha", "zebra"]
|
||||
|
||||
def test_refresh_handles_collector_exception_gracefully(
|
||||
self, monkeypatch
|
||||
) -> None:
|
||||
"""A broken collector must not take down /reload-skills."""
|
||||
adapter = _make_adapter()
|
||||
adapter._skill_entries = [("keep", "kept", "/keep")]
|
||||
adapter._skill_lookup = {"keep": ("kept", "/keep")}
|
||||
adapter._skill_group_reserved_names = set()
|
||||
adapter._skill_group_hidden_count = 0
|
||||
|
||||
def boom(*, reserved_names):
|
||||
raise RuntimeError("simulated collector failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
boom,
|
||||
)
|
||||
|
||||
new_count, hidden = adapter.refresh_skill_group()
|
||||
# Returns previously-cached count, no crash, existing entries
|
||||
# preserved so the live autocomplete keeps working.
|
||||
assert new_count == 1
|
||||
assert hidden == 0
|
||||
assert adapter._skill_entries == [("keep", "kept", "/keep")]
|
||||
|
||||
|
||||
class TestRegisterSkillGroupUsesInstanceState:
|
||||
"""The closure-based ``entries`` / ``skill_lookup`` must be gone.
|
||||
|
||||
If the callbacks in ``_register_skill_group`` still close over
|
||||
local variables instead of reading from ``self``, the refresh
|
||||
method is useless — autocomplete will keep serving the stale list.
|
||||
|
||||
The full slash-command registration path pulls in ``discord.app_commands``
|
||||
decorators (``@describe`` / ``@autocomplete`` / ``Command``), which
|
||||
are unstubbed in the hermetic test env. We assert the data-shaped
|
||||
side-effects instead: after ``_register_skill_group`` returns
|
||||
(successfully or not), ``_skill_entries`` and ``_skill_lookup`` must
|
||||
be populated from the collector output, because
|
||||
``_refresh_skill_catalog_state`` runs before any decorator evaluation.
|
||||
"""
|
||||
|
||||
def test_refresh_catalog_state_populates_instance_attrs(
|
||||
self, monkeypatch
|
||||
) -> None:
|
||||
adapter = _make_adapter()
|
||||
adapter._skill_group_reserved_names = set()
|
||||
|
||||
def fake_collector(*, reserved_names):
|
||||
return (
|
||||
{"creative": [("ascii-art", "Make ASCII", "/ascii-art")]},
|
||||
[],
|
||||
0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.commands.discord_skill_commands_by_category",
|
||||
fake_collector,
|
||||
)
|
||||
|
||||
adapter._refresh_skill_catalog_state()
|
||||
|
||||
# Instance-level state populated — the autocomplete + handler
|
||||
# callbacks both read from these, so `refresh_skill_group`
|
||||
# mutating them in place is enough to pick up new skills.
|
||||
assert adapter._skill_entries == [
|
||||
("ascii-art", "Make ASCII", "/ascii-art"),
|
||||
]
|
||||
assert adapter._skill_lookup == {
|
||||
"ascii-art": ("Make ASCII", "/ascii-art"),
|
||||
}
|
||||
assert adapter._skill_group_hidden_count == 0
|
||||
|
||||
|
||||
class TestHandleReloadSkillsCallsRefreshSkillGroup:
|
||||
"""Gateway-side integration: /reload-skills must call refresh on adapters."""
|
||||
|
||||
def test_orchestrator_calls_refresh_skill_group_on_every_adapter(self):
|
||||
"""Sync + async refresh_skill_group implementations both get awaited/called.
|
||||
|
||||
The orchestrator iterates ``self.adapters`` and calls
|
||||
``refresh_skill_group`` if it exists. Adapters that don't
|
||||
implement it (today: everything except Discord) are silently
|
||||
skipped without raising.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Import without constructing a real runner — test the method
|
||||
# directly against an ``object.__new__`` instance.
|
||||
from gateway.run import GatewayRunner
|
||||
runner = object.__new__(GatewayRunner)
|
||||
|
||||
sync_refresh = MagicMock(return_value=(5, 0))
|
||||
async_called = {"flag": False}
|
||||
|
||||
class AsyncAdapter:
|
||||
name = "async-platform"
|
||||
async def refresh_skill_group(self):
|
||||
async_called["flag"] = True
|
||||
return (3, 0)
|
||||
|
||||
class SyncAdapter:
|
||||
name = "sync-platform"
|
||||
refresh_skill_group = sync_refresh
|
||||
|
||||
class NoOpAdapter:
|
||||
name = "other"
|
||||
# No refresh_skill_group — must not crash.
|
||||
|
||||
runner.adapters = {
|
||||
"discord": AsyncAdapter(),
|
||||
"slack": SyncAdapter(),
|
||||
"telegram": NoOpAdapter(),
|
||||
}
|
||||
|
||||
# Mock reload_skills itself so no disk scan runs.
|
||||
fake_result = {"added": [], "removed": [], "total": 7}
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills", return_value=fake_result
|
||||
):
|
||||
event = MagicMock()
|
||||
event.source = MagicMock()
|
||||
# _session_key_for_source may be called — make it safe.
|
||||
runner._session_key_for_source = lambda src: None
|
||||
runner._pending_skills_reload_notes = {}
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
runner._handle_reload_skills_command(event)
|
||||
)
|
||||
|
||||
assert "Skills Reloaded" in result
|
||||
assert sync_refresh.called, "sync adapter refresh must be invoked"
|
||||
assert async_called["flag"], "async adapter refresh must be awaited"
|
||||
Reference in New Issue
Block a user