Files
hermes-agent/tests/gateway/test_agent_loop_stopped_hook.py
Franci Penov f361971eed feat(gateway): fire agent_loop_stopped plugin hook on interrupt
Reapplied onto current main. The branch had drifted ~3348 commits and a trial
merge produced 48 conflict markers, so this is the same change re-landed rather
than a rebase of the old history.

_interrupt_and_clear_session interrupts the running agent without signalling
plugins, so a plugin holding a per-turn external resource — an outbound RPC
waiting on a tool result the loop will never consume — has no way to learn the
turn is gone. Dispatch agent_loop_stopped immediately after
running_agent.interrupt(), gated on a real running agent: the pending-sentinel
/stop path has no in-flight work, so firing there would be noise.

Per review on #27208, the current helper's behaviour is preserved untouched —
multiplex-aware _adapter_for_source() resolution and cached-agent eviction both
still run; the hook is additive and its dispatch failures are swallowed so a
misbehaving plugin cannot break an interrupt.

Tests fail without the change (hook registration and dispatch) and pass with
it. The three failures in tests/hermes_cli/test_plugins.py::TestPluginDiscovery
are pre-existing on this checkout and reproduce with the change stashed.
2026-09-12 22:19:44 -07:00

166 lines
5.6 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
from hermes_cli.plugins import VALID_HOOKS
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
def test_agent_loop_stopped_in_valid_hooks():
"""Plugins must be able to subscribe to the new hook without warnings."""
assert "agent_loop_stopped" in VALID_HOOKS
@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")