hermes-agent/tests/gateway/test_goal_status_notice.py
JC 03ddff8897 fix(gateway): defer goal status notices until after response delivery
Route goal status notices through the platform adapter send API and register post-delivery callbacks so completed-goal notices appear after the final assistant response. Also cancel queued synthetic goal continuations on /goal pause and /goal clear while preserving normal queued user messages.
2026-05-07 17:33:09 -07:00

147 lines
4.6 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from hermes_cli.goals import CONTINUATION_PROMPT_TEMPLATE
class FakeAdapter:
def __init__(self):
self.calls = []
self.callbacks = {}
self._active_sessions = {}
async def send(self, chat_id, content, reply_to=None, metadata=None):
self.calls.append(
{
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata,
}
)
return SimpleNamespace(success=True)
def register_post_delivery_callback(self, session_key, callback, *, generation=None):
self.callbacks[session_key] = (generation, callback)
def _goal_continuation_event(source, goal="finish the task"):
return MessageEvent(
text=CONTINUATION_PROMPT_TEMPLATE.format(goal=goal),
message_type=MessageType.TEXT,
source=source,
)
@pytest.mark.asyncio
async def test_goal_status_notice_uses_adapter_send_with_thread_metadata():
"""Regression: /goal judge status must use BasePlatformAdapter.send().
The old implementation checked for a non-existent send_message() method,
so the goal could be marked done in state_meta without the visible
"✓ Goal achieved" status line being delivered to Discord/Telegram.
"""
runner = GatewayRunner.__new__(GatewayRunner)
adapter = FakeAdapter()
runner.adapters = {Platform.DISCORD: adapter}
source = SessionSource(
platform=Platform.DISCORD,
chat_id="parent-channel",
thread_id="thread-123",
)
await runner._send_goal_status_notice(source, "✓ Goal achieved: done")
assert adapter.calls == [
{
"chat_id": "parent-channel",
"content": "✓ Goal achieved: done",
"reply_to": None,
"metadata": {"thread_id": "thread-123"},
}
]
@pytest.mark.asyncio
async def test_goal_status_notice_defers_until_post_delivery_callback():
"""Regression: goal status must appear after the agent's visible reply.
_post_turn_goal_continuation runs before BasePlatformAdapter sends the
returned final response. It should therefore register a post-delivery
callback, not send the judge status immediately.
"""
runner = GatewayRunner.__new__(GatewayRunner)
adapter = FakeAdapter()
runner.adapters = {Platform.DISCORD: adapter}
runner.config = SimpleNamespace(group_sessions_per_user=True, thread_sessions_per_user=False)
source = SessionSource(
platform=Platform.DISCORD,
chat_id="parent-channel",
thread_id="thread-123",
user_id="user-1",
)
await runner._defer_goal_status_notice_after_delivery(source, "✓ Goal achieved: done")
assert adapter.calls == []
assert len(adapter.callbacks) == 1
_, callback = next(iter(adapter.callbacks.values()))
result = callback()
if hasattr(result, "__await__"):
await result
assert adapter.calls == [
{
"chat_id": "parent-channel",
"content": "✓ Goal achieved: done",
"reply_to": None,
"metadata": {"thread_id": "thread-123"},
}
]
def test_clear_goal_pending_continuations_removes_slot_and_overflow_only():
"""Regression: /goal pause/clear must cancel queued self-continuations.
A user-issued /goal pause can arrive after the judge queued the next
continuation but before that queued turn runs. The queued synthetic goal
continuation should be removed without dropping normal user /queue items.
"""
runner = GatewayRunner.__new__(GatewayRunner)
adapter = FakeAdapter()
adapter._pending_messages = {}
runner._queued_events = {}
source = SessionSource(
platform=Platform.DISCORD,
chat_id="parent-channel",
thread_id="thread-123",
)
session_key = "discord:parent-channel:thread-123"
normal_event = MessageEvent(
text="normal queued user message",
message_type=MessageType.TEXT,
source=source,
)
adapter._pending_messages[session_key] = _goal_continuation_event(source)
runner._queued_events[session_key] = [
normal_event,
_goal_continuation_event(source, goal="second continuation"),
]
removed = runner._clear_goal_pending_continuations(session_key, adapter)
assert removed == 2
assert adapter._pending_messages.get(session_key) is None
assert runner._queued_events[session_key] == [normal_event]