Change-detectors, tautologies, source-reading tests, redundant duplicates, mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane ledger (category + reason for every removal).
162 lines
5.4 KiB
Python
162 lines
5.4 KiB
Python
"""Tests that the ``agent_loop_stopped`` plugin hook fires when the gateway
|
|
interrupts a running agent turn — both via ``/stop`` and via the running-agent
|
|
fast-path inside ``/new``.
|
|
|
|
Mirrors ``test_session_boundary_hooks.py``: we bypass ``GatewayRunner.__init__``
|
|
and stub just enough attributes to drive ``_interrupt_and_clear_session``.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
from gateway.session import SessionSource, build_session_key
|
|
|
|
|
|
def _make_source() -> SessionSource:
|
|
return SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
user_id="u1",
|
|
chat_id="c1",
|
|
user_name="tester",
|
|
chat_type="dm",
|
|
)
|
|
|
|
|
|
def _make_runner():
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(
|
|
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
|
|
)
|
|
# SimpleNamespace, not MagicMock — _interrupt_and_clear_session calls
|
|
# adapter.interrupt_session_activity() only when hasattr(...) is true,
|
|
# and MagicMock fakes every attribute, which would push us into an
|
|
# await on a non-awaitable.
|
|
adapter = SimpleNamespace(send=AsyncMock())
|
|
runner.adapters = {Platform.TELEGRAM: adapter}
|
|
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
|
runner._running_agents = {}
|
|
runner._pending_messages = {}
|
|
|
|
# _invalidate_session_run_generation + _release_running_agent_state are
|
|
# called downstream; stub to no-op so we exercise the hook emit only.
|
|
runner._invalidate_session_run_generation = lambda *a, **kw: None
|
|
runner._release_running_agent_state = lambda *a, **kw: None
|
|
return runner
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
async def test_interrupt_fires_agent_loop_stopped_hook(mock_invoke_hook):
|
|
"""Calling ``_interrupt_and_clear_session`` with a real running agent
|
|
must interrupt it AND dispatch the hook with the session, platform,
|
|
and reasons attached."""
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
session_key = build_session_key(source)
|
|
|
|
running_agent = MagicMock()
|
|
runner._running_agents[session_key] = running_agent
|
|
|
|
await runner._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason="user_stop",
|
|
invalidation_reason="stop_command",
|
|
)
|
|
|
|
running_agent.interrupt.assert_called_once_with("user_stop")
|
|
mock_invoke_hook.assert_any_call(
|
|
"agent_loop_stopped",
|
|
session_key=session_key,
|
|
platform="telegram",
|
|
reason="user_stop",
|
|
invalidation_reason="stop_command",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
async def test_hook_not_fired_for_pending_sentinel_stop(mock_invoke_hook):
|
|
"""The pending-sentinel /stop path (slash_commands.py) has no in-flight
|
|
work to cancel — the agent loop never started. The hook is gated on a
|
|
real running agent and must not fire in this case."""
|
|
from gateway.run import _AGENT_PENDING_SENTINEL
|
|
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
session_key = build_session_key(source)
|
|
|
|
runner._running_agents[session_key] = _AGENT_PENDING_SENTINEL
|
|
|
|
await runner._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason="user_stop",
|
|
invalidation_reason="stop_command_pending",
|
|
)
|
|
|
|
agent_loop_stopped_calls = [
|
|
call for call in mock_invoke_hook.call_args_list
|
|
if call.args and call.args[0] == "agent_loop_stopped"
|
|
]
|
|
assert agent_loop_stopped_calls == [], (
|
|
f"agent_loop_stopped must not fire on the pending-sentinel path "
|
|
f"(saw {len(agent_loop_stopped_calls)} call(s))"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
async def test_hook_not_fired_when_no_running_agent(mock_invoke_hook):
|
|
"""If there is no agent at all for the session key (already cleared),
|
|
there is nothing to interrupt and the hook must not fire."""
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
session_key = build_session_key(source)
|
|
|
|
# _running_agents stays empty — no entry for this session_key.
|
|
await runner._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason="user_stop",
|
|
invalidation_reason="stop_command_no_agent",
|
|
)
|
|
|
|
agent_loop_stopped_calls = [
|
|
call for call in mock_invoke_hook.call_args_list
|
|
if call.args and call.args[0] == "agent_loop_stopped"
|
|
]
|
|
assert agent_loop_stopped_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
async def test_hook_dispatch_failure_does_not_break_interrupt(mock_invoke_hook):
|
|
"""A misbehaving plugin must not prevent the interrupt from completing."""
|
|
mock_invoke_hook.side_effect = RuntimeError("plugin exploded")
|
|
|
|
runner = _make_runner()
|
|
source = _make_source()
|
|
session_key = build_session_key(source)
|
|
|
|
running_agent = MagicMock()
|
|
runner._running_agents[session_key] = running_agent
|
|
|
|
# Should not raise — the hook block has try/except for exactly this.
|
|
await runner._interrupt_and_clear_session(
|
|
session_key,
|
|
source,
|
|
interrupt_reason="user_stop",
|
|
invalidation_reason="stop_command",
|
|
)
|
|
|
|
# The interrupt itself still happened despite the hook blowing up.
|
|
running_agent.interrupt.assert_called_once_with("user_stop")
|