Files
hermes-agent/tests/gateway/test_queued_followup_processing_hooks.py
T
teknium1 876bdc857d test: accept the widened run_conversation kwargs in the queued-followup agent doubles
main now passes persist_user_platform_id (and friends) into run_conversation; the fakes
only need the message, so swallow the rest with **_kwargs instead of pinning today's signature.
2026-09-13 20:52:02 -07:00

314 lines
10 KiB
Python

"""Processing-hook parity for queued follow-up turns.
A message that arrives while a turn is already running is parked in the
adapter's ``_pending_messages`` slot and drained *in-band* by
``GatewayRunner._run_agent`` rather than by
``BasePlatformAdapter._process_message_background``. The runner-side drain
must still fire the ``on_processing_start`` / ``on_processing_complete``
lifecycle hooks, otherwise every platform that renders a read-receipt
reaction from those hooks (Slack 👀, Discord, Telegram, Feishu, Matrix,
Signal, ...) silently skips the acknowledgement for mid-turn messages.
"""
import importlib
import sys
import types
from types import SimpleNamespace
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
ProcessingOutcome,
SendResult,
)
from gateway.session import SessionSource
class HookRecordingAdapter(BasePlatformAdapter):
"""Adapter that records the processing-hook lifecycle it is driven through."""
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
self.started: list = []
self.completed: list = []
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
return None
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
return SendResult(success=True, message_id="sent-1")
async def send_typing(self, chat_id, metadata=None) -> None:
return None
async def stop_typing(self, chat_id) -> None:
return None
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
async def on_processing_start(self, event: MessageEvent) -> None:
self.started.append(getattr(event, "message_id", None))
async def on_processing_complete(self, event, outcome) -> None:
self.completed.append((getattr(event, "message_id", None), outcome))
class _TwoTurnAgent:
calls: list = []
def __init__(self, **kwargs):
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None, **_kwargs):
type(self).calls.append(message)
return {
"final_response": f"done-{len(type(self).calls)}",
"messages": [],
"api_calls": 1,
}
class _RaisingSecondTurnAgent:
calls: list = []
def __init__(self, **kwargs):
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None, **_kwargs):
type(self).calls.append(message)
if len(type(self).calls) >= 2:
raise RuntimeError("boom in the queued follow-up turn")
return {
"final_response": "done-1",
"messages": [],
"api_calls": 1,
}
def _make_runner(adapter):
gateway_run = importlib.import_module("gateway.run")
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {adapter.platform: adapter}
runner._voice_mode = {}
runner._prefill_messages = []
runner._ephemeral_system_prompt = ""
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._session_db = None
runner._running_agents = {}
runner._session_run_generation = {}
runner.hooks = SimpleNamespace(loaded_hooks=False)
runner.config = SimpleNamespace(
thread_sessions_per_user=False,
group_sessions_per_user=False,
stt_enabled=False,
)
runner._model = "openai/gpt-4.1-mini"
runner._base_url = None
return runner
def _install_fake_agent(monkeypatch, tmp_path, agent_cls):
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = agent_cls
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
gateway_run = importlib.import_module("gateway.run")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
)
SESSION_KEY = "agent:main:telegram:dm:4242"
def _source():
return SessionSource(platform=Platform.TELEGRAM, chat_id="4242", chat_type="dm")
@pytest.mark.asyncio
async def test_queued_followup_fires_processing_hooks(monkeypatch, tmp_path):
"""The runner-drained follow-up gets the same start/complete hooks as a
message that arrives while the session is idle."""
_TwoTurnAgent.calls = []
_install_fake_agent(monkeypatch, tmp_path, _TwoTurnAgent)
adapter = HookRecordingAdapter()
runner = _make_runner(adapter)
adapter._pending_messages[SESSION_KEY] = MessageEvent(
text="the follow-up",
message_type=MessageType.TEXT,
source=_source(),
message_id="queued-1",
)
result = await runner._run_agent(
message="the first turn",
context_prompt="",
history=[],
source=_source(),
session_id="sess-hooks",
session_key=SESSION_KEY,
)
# The follow-up really did run in-band.
assert result["final_response"] == "done-2"
assert _TwoTurnAgent.calls == ["the first turn", "the follow-up"]
# ...and it was acknowledged through the lifecycle hooks.
assert adapter.started == ["queued-1"]
assert adapter.completed == [("queued-1", ProcessingOutcome.SUCCESS)]
@pytest.mark.asyncio
async def test_queued_followup_failure_completes_the_hook(monkeypatch, tmp_path):
"""A follow-up turn that blows up still closes its hook, so a platform
never strands a 'still working' marker on the user's message."""
_RaisingSecondTurnAgent.calls = []
_install_fake_agent(monkeypatch, tmp_path, _RaisingSecondTurnAgent)
adapter = HookRecordingAdapter()
runner = _make_runner(adapter)
adapter._pending_messages[SESSION_KEY] = MessageEvent(
text="the doomed follow-up",
message_type=MessageType.TEXT,
source=_source(),
message_id="queued-2",
)
with pytest.raises(RuntimeError):
await runner._run_agent(
message="the first turn",
context_prompt="",
history=[],
source=_source(),
session_id="sess-hooks-failure",
session_key=SESSION_KEY,
)
assert adapter.started == ["queued-2"]
assert adapter.completed == [("queued-2", ProcessingOutcome.FAILURE)]
@pytest.mark.asyncio
async def test_synthetic_followup_is_not_acknowledged(monkeypatch, tmp_path):
"""Drains with no inbound platform message — /goal continuations, wake-ups,
CLI hand-offs — carry no message_id and must stay silent: there is nothing
on the platform to react to."""
_TwoTurnAgent.calls = []
_install_fake_agent(monkeypatch, tmp_path, _TwoTurnAgent)
adapter = HookRecordingAdapter()
runner = _make_runner(adapter)
adapter._pending_messages[SESSION_KEY] = MessageEvent(
text="synthetic continuation",
message_type=MessageType.TEXT,
source=_source(),
message_id=None,
)
result = await runner._run_agent(
message="the first turn",
context_prompt="",
history=[],
source=_source(),
session_id="sess-hooks-synthetic",
session_key=SESSION_KEY,
)
# It still ran — we only suppressed the acknowledgement, not the turn.
assert result["final_response"] == "done-2"
assert _TwoTurnAgent.calls == ["the first turn", "synthetic continuation"]
assert adapter.started == []
assert adapter.completed == []
@pytest.mark.asyncio
async def test_raw_envelope_only_followup_is_acknowledged(monkeypatch, tmp_path):
"""Signal never sets message_id — its hook keys off the raw envelope
(sender + timestamp_ms) — and Discord's reads raw_message. An event
carrying only a raw envelope is still a real inbound message."""
_TwoTurnAgent.calls = []
_install_fake_agent(monkeypatch, tmp_path, _TwoTurnAgent)
adapter = HookRecordingAdapter()
runner = _make_runner(adapter)
adapter._pending_messages[SESSION_KEY] = MessageEvent(
text="signal-shaped follow-up",
message_type=MessageType.TEXT,
source=_source(),
message_id=None,
raw_message={"sender": "+15550100", "timestamp_ms": 1700000000000},
)
await runner._run_agent(
message="the first turn",
context_prompt="",
history=[],
source=_source(),
session_id="sess-hooks-raw",
session_key=SESSION_KEY,
)
assert adapter.started == [None]
assert adapter.completed == [(None, ProcessingOutcome.SUCCESS)]
class CompleteOnlyAdapter(HookRecordingAdapter):
"""Google Chat and webhook implement on_processing_complete WITHOUT
on_processing_start; theirs is end-of-cycle teardown (reap the typing
card / end the delivery session), not a reaction."""
on_processing_start = BasePlatformAdapter.on_processing_start
@pytest.mark.asyncio
async def test_complete_only_adapter_is_left_alone(monkeypatch, tmp_path):
"""We bracket, so both halves must belong to us. An adapter that only
implements the completion half must not be handed a completion here: at
this point the follow-up's reply has not been delivered yet, so its
teardown would fire against a live turn."""
_TwoTurnAgent.calls = []
_install_fake_agent(monkeypatch, tmp_path, _TwoTurnAgent)
adapter = CompleteOnlyAdapter()
runner = _make_runner(adapter)
adapter._pending_messages[SESSION_KEY] = MessageEvent(
text="the follow-up",
message_type=MessageType.TEXT,
source=_source(),
message_id="queued-3",
)
result = await runner._run_agent(
message="the first turn",
context_prompt="",
history=[],
source=_source(),
session_id="sess-hooks-complete-only",
session_key=SESSION_KEY,
)
assert result["final_response"] == "done-2"
assert adapter.completed == []