File size: 11,925 Bytes
b5b9c2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
"""Regression tests: slash commands must bypass the base adapter's active-session guard.

When an agent is running, the base adapter's Level 1 guard in
handle_message() intercepts all incoming messages and queues them as
pending.  Certain commands (/stop, /new, /reset, /approve, /deny,
/status) must bypass this guard and be dispatched directly to the gateway
runner — otherwise they are queued as user text and either:
  - leak into the conversation as agent input (/stop, /new), or
  - deadlock (/approve, /deny — agent blocks on Event.wait)

These tests verify that the bypass works at the adapter level and that
the safety net in _run_agent discards leaked command text.
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
from gateway.session import SessionSource, build_session_key


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


class _StubAdapter(BasePlatformAdapter):
    """Concrete adapter with abstract methods stubbed out."""

    async def connect(self):
        pass

    async def disconnect(self):
        pass

    async def send(self, chat_id, text, **kwargs):
        pass

    async def get_chat_info(self, chat_id):
        return {}


def _make_adapter():
    """Create a minimal adapter for testing the active-session guard."""
    config = PlatformConfig(enabled=True, token="test-token")
    adapter = _StubAdapter(config, Platform.TELEGRAM)
    adapter.sent_responses = []

    async def _mock_handler(event):
        cmd = event.get_command()
        return f"handled:{cmd}" if cmd else f"handled:text:{event.text}"

    adapter._message_handler = _mock_handler

    async def _mock_send_retry(chat_id, content, **kwargs):
        adapter.sent_responses.append(content)

    adapter._send_with_retry = _mock_send_retry
    return adapter


def _make_event(text="/stop", chat_id="12345"):
    source = SessionSource(
        platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"
    )
    return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)


def _session_key(chat_id="12345"):
    source = SessionSource(
        platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"
    )
    return build_session_key(source)


# ---------------------------------------------------------------------------
# Tests: commands bypass Level 1 when session is active
# ---------------------------------------------------------------------------


class TestCommandBypassActiveSession:
    """Commands that must bypass the active-session guard."""

    @pytest.mark.asyncio
    async def test_stop_bypasses_guard(self):
        """/stop must be dispatched directly, not queued."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/stop"))

        assert sk not in adapter._pending_messages, (
            "/stop was queued as a pending message instead of being dispatched"
        )
        assert any("handled:stop" in r for r in adapter.sent_responses), (
            "/stop response was not sent back to the user"
        )

    @pytest.mark.asyncio
    async def test_new_bypasses_guard(self):
        """/new must be dispatched directly, not queued."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/new"))

        assert sk not in adapter._pending_messages
        assert any("handled:new" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_reset_bypasses_guard(self):
        """/reset (alias for /new) must be dispatched directly."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/reset"))

        assert sk not in adapter._pending_messages
        assert any("handled:reset" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_approve_bypasses_guard(self):
        """/approve must bypass (deadlock prevention)."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/approve"))

        assert sk not in adapter._pending_messages
        assert any("handled:approve" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_deny_bypasses_guard(self):
        """/deny must bypass (deadlock prevention)."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/deny"))

        assert sk not in adapter._pending_messages
        assert any("handled:deny" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_status_bypasses_guard(self):
        """/status must bypass so it returns a system response."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/status"))

        assert sk not in adapter._pending_messages
        assert any("handled:status" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_background_bypasses_guard(self):
        """/background must bypass so it spawns a parallel task, not an interrupt."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/background summarize HN"))

        assert sk not in adapter._pending_messages, (
            "/background was queued as a pending message instead of being dispatched"
        )
        assert any("handled:background" in r for r in adapter.sent_responses), (
            "/background response was not sent back to the user"
        )


# ---------------------------------------------------------------------------
# Tests: non-bypass messages still get queued
# ---------------------------------------------------------------------------


class TestNonBypassStillQueued:
    """Regular messages and unknown commands must be queued, not dispatched."""

    @pytest.mark.asyncio
    async def test_regular_text_queued(self):
        """Plain text while agent is running must be queued as pending."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("hello world"))

        assert sk in adapter._pending_messages, (
            "Regular text was not queued — it should be pending"
        )
        assert len(adapter.sent_responses) == 0, (
            "Regular text should not produce a direct response"
        )

    @pytest.mark.asyncio
    async def test_unknown_command_queued(self):
        """Unknown /commands must be queued, not dispatched."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/foobar"))

        assert sk in adapter._pending_messages
        assert len(adapter.sent_responses) == 0

    @pytest.mark.asyncio
    async def test_file_path_not_treated_as_command(self):
        """A message like '/path/to/file' must not bypass the guard."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/path/to/file.py"))

        assert sk in adapter._pending_messages
        assert len(adapter.sent_responses) == 0


# ---------------------------------------------------------------------------
# Tests: no active session — commands go through normally
# ---------------------------------------------------------------------------


class TestNoActiveSessionNormalDispatch:
    """When no agent is running, messages spawn a background task normally."""

    @pytest.mark.asyncio
    async def test_stop_when_no_session_active(self):
        """/stop without an active session spawns a background task
        (the Level 2 handler will return 'No active task')."""
        adapter = _make_adapter()
        sk = _session_key()

        # No active session — _active_sessions is empty
        assert sk not in adapter._active_sessions

        await adapter.handle_message(_make_event("/stop"))

        # Should have gone through the normal path (background task spawned)
        # and NOT be in _pending_messages (that's the queued-during-active path)
        assert sk not in adapter._pending_messages


# ---------------------------------------------------------------------------
# Tests: safety net in _run_agent discards command text from pending queue
# ---------------------------------------------------------------------------


class TestPendingCommandSafetyNet:
    """The safety net in gateway/run.py _run_agent must discard command text
    that leaks into the pending queue via interrupt_message fallback."""

    def test_stop_command_detected(self):
        """resolve_command must recognize /stop so the safety net can
        discard it."""
        from hermes_cli.commands import resolve_command

        assert resolve_command("stop") is not None
        assert resolve_command("stop").name == "stop"

    def test_new_command_detected(self):
        from hermes_cli.commands import resolve_command

        assert resolve_command("new") is not None
        assert resolve_command("new").name == "new"

    def test_reset_alias_detected(self):
        from hermes_cli.commands import resolve_command

        assert resolve_command("reset") is not None
        assert resolve_command("reset").name == "new"  # alias

    def test_unknown_command_not_detected(self):
        from hermes_cli.commands import resolve_command

        assert resolve_command("foobar") is None

    def test_file_path_not_detected_as_command(self):
        """'/path/to/file' should not resolve as a command."""
        from hermes_cli.commands import resolve_command

        # The safety net splits on whitespace and takes the first word
        # after stripping '/'.  For '/path/to/file', that's 'path/to/file'.
        assert resolve_command("path/to/file") is None


# ---------------------------------------------------------------------------
# Tests: bypass with @botname suffix (Telegram-style)
# ---------------------------------------------------------------------------


class TestBypassWithBotnameSuffix:
    """Telegram appends @botname to commands. The bypass must still work."""

    @pytest.mark.asyncio
    async def test_stop_with_botname(self):
        """/stop@MyHermesBot must bypass the guard."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/stop@MyHermesBot"))

        assert sk not in adapter._pending_messages, (
            "/stop@MyHermesBot was queued instead of bypassing"
        )
        assert any("handled:stop" in r for r in adapter.sent_responses)

    @pytest.mark.asyncio
    async def test_new_with_botname(self):
        """/new@MyHermesBot must bypass the guard."""
        adapter = _make_adapter()
        sk = _session_key()
        adapter._active_sessions[sk] = asyncio.Event()

        await adapter.handle_message(_make_event("/new@MyHermesBot"))

        assert sk not in adapter._pending_messages
        assert any("handled:new" in r for r in adapter.sent_responses)