test_reload_skills_command.py
1 """Tests for the ``/reload-skills`` gateway slash command handler. 2 3 Verifies: 4 * dispatcher routes ``/reload-skills`` to ``_handle_reload_skills_command`` 5 * the underscored alias ``/reload_skills`` is not flagged as unknown 6 * the handler invokes ``agent.skill_commands.reload_skills`` and renders a 7 human-readable diff 8 * when any skills changed, a one-shot note is queued on 9 ``runner._pending_skills_reload_notes[session_key]`` (the agent loop 10 consumes and clears it on the next user turn — see ``gateway/run.py`` 11 near the ``_has_fresh_tool_tail`` block) 12 * the handler does NOT append to the session transcript out-of-band — 13 message alternation must not be broken by a phantom user turn 14 """ 15 16 from datetime import datetime 17 from types import SimpleNamespace 18 from unittest.mock import AsyncMock, MagicMock 19 20 import pytest 21 22 from gateway.config import GatewayConfig, Platform, PlatformConfig 23 from gateway.platforms.base import MessageEvent 24 from gateway.session import SessionEntry, SessionSource, build_session_key 25 26 27 def _make_source() -> SessionSource: 28 return SessionSource( 29 platform=Platform.TELEGRAM, 30 user_id="u1", 31 chat_id="c1", 32 user_name="tester", 33 chat_type="dm", 34 ) 35 36 37 def _make_event(text: str) -> MessageEvent: 38 return MessageEvent(text=text, source=_make_source(), message_id="m1") 39 40 41 def _make_runner(): 42 from gateway.run import GatewayRunner 43 44 runner = object.__new__(GatewayRunner) 45 runner.config = GatewayConfig( 46 platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} 47 ) 48 adapter = MagicMock() 49 adapter.send = AsyncMock() 50 runner.adapters = {Platform.TELEGRAM: adapter} 51 runner._voice_mode = {} 52 runner.hooks = SimpleNamespace( 53 emit=AsyncMock(), 54 emit_collect=AsyncMock(return_value=[]), 55 loaded_hooks=False, 56 ) 57 58 session_entry = SessionEntry( 59 session_key=build_session_key(_make_source()), 60 session_id="sess-1", 61 created_at=datetime.now(), 62 updated_at=datetime.now(), 63 platform=Platform.TELEGRAM, 64 chat_type="dm", 65 ) 66 runner.session_store = MagicMock() 67 runner.session_store.get_or_create_session.return_value = session_entry 68 runner.session_store.load_transcript.return_value = [] 69 runner.session_store.has_any_sessions.return_value = True 70 runner.session_store.append_to_transcript = MagicMock() 71 runner.session_store.rewrite_transcript = MagicMock() 72 runner.session_store.update_session = MagicMock() 73 runner._running_agents = {} 74 runner._pending_messages = {} 75 runner._pending_approvals = {} 76 runner._session_db = None 77 runner._reasoning_config = None 78 runner._provider_routing = {} 79 runner._fallback_model = None 80 runner._show_reasoning = False 81 runner._is_user_authorized = lambda _source: True 82 runner._set_session_env = lambda _context: None 83 runner._should_send_voice_reply = lambda *_args, **_kwargs: False 84 # Use the real _session_key_for_source binding so the key matches what 85 # the agent-loop consumer will look up later. 86 from gateway.run import GatewayRunner as _GR 87 runner._session_key_for_source = _GR._session_key_for_source.__get__(runner, _GR) 88 return runner 89 90 91 @pytest.mark.asyncio 92 async def test_reload_skills_handler_queues_note_on_diff(monkeypatch): 93 """Diff non-empty → handler queues a one-shot note and does NOT touch transcript.""" 94 fake_result = { 95 "added": [ 96 {"name": "alpha", "description": "Run alpha to do xyz"}, 97 {"name": "beta", "description": "Run beta to do abc"}, 98 ], 99 "removed": [ 100 {"name": "gamma", "description": "Old removed skill"}, 101 ], 102 "unchanged": ["delta"], 103 "total": 3, 104 "commands": 3, 105 } 106 107 import agent.skill_commands as skill_commands_mod 108 monkeypatch.setattr(skill_commands_mod, "reload_skills", lambda: fake_result) 109 110 runner = _make_runner() 111 event = _make_event("/reload-skills") 112 out = await runner._handle_reload_skills_command(event) 113 114 assert out is not None 115 assert "Skills Reloaded" in out 116 assert "Added Skills:" in out 117 assert "- alpha: Run alpha to do xyz" in out 118 assert "- beta: Run beta to do abc" in out 119 assert "Removed Skills:" in out 120 assert "- gamma: Old removed skill" in out 121 assert "3 skill(s) available" in out 122 123 # MUST NOT write to the session transcript — that would break alternation. 124 runner.session_store.append_to_transcript.assert_not_called() 125 126 # MUST have queued a one-shot note keyed on the session. 127 pending = getattr(runner, "_pending_skills_reload_notes", None) 128 assert pending is not None 129 session_key = runner._session_key_for_source(event.source) 130 assert session_key in pending 131 note = pending[session_key] 132 assert note.startswith("[USER INITIATED SKILLS RELOAD:") 133 assert note.endswith("Use skills_list to see the updated catalog.]") 134 assert "Added Skills:" in note 135 assert " - alpha: Run alpha to do xyz" in note 136 assert " - beta: Run beta to do abc" in note 137 assert "Removed Skills:" in note 138 assert " - gamma: Old removed skill" in note 139 140 141 @pytest.mark.asyncio 142 async def test_reload_skills_handler_reports_no_changes(monkeypatch): 143 """No diff → no queued note, no transcript write.""" 144 import agent.skill_commands as skill_commands_mod 145 146 monkeypatch.setattr( 147 skill_commands_mod, 148 "reload_skills", 149 lambda: { 150 "added": [], 151 "removed": [], 152 "unchanged": ["alpha"], 153 "total": 1, 154 "commands": 1, 155 }, 156 ) 157 158 runner = _make_runner() 159 out = await runner._handle_reload_skills_command(_make_event("/reload-skills")) 160 161 assert "No new skills detected" in out 162 assert "1 skill(s) available" in out 163 runner.session_store.append_to_transcript.assert_not_called() 164 # No queued note when nothing changed. 165 pending = getattr(runner, "_pending_skills_reload_notes", None) 166 assert not pending # None or empty dict 167 168 169 @pytest.mark.asyncio 170 async def test_dispatcher_routes_reload_skills(monkeypatch): 171 """``/reload-skills`` must reach ``_handle_reload_skills_command``.""" 172 import gateway.run as gateway_run 173 174 runner = _make_runner() 175 sentinel = "reload-skills handler reached" 176 runner._handle_reload_skills_command = AsyncMock(return_value=sentinel) # type: ignore[attr-defined] 177 178 monkeypatch.setattr( 179 gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} 180 ) 181 182 result = await runner._handle_message(_make_event("/reload-skills")) 183 assert result == sentinel 184 185 186 @pytest.mark.asyncio 187 async def test_underscored_alias_not_flagged_unknown(monkeypatch): 188 """Telegram autocomplete sends ``/reload_skills`` for ``/reload-skills``.""" 189 import gateway.run as gateway_run 190 191 runner = _make_runner() 192 runner._handle_reload_skills_command = AsyncMock(return_value="ok") # type: ignore[attr-defined] 193 194 monkeypatch.setattr( 195 gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} 196 ) 197 198 result = await runner._handle_message(_make_event("/reload_skills")) 199 if result is not None: 200 assert "Unknown command" not in result