Files
hermes-agent/tests/gateway/test_queued_followup_final_delivery.py
teknium1 b4345b4e57 fix(gateway): suppress the completion send only when the queued lane's send actually landed
The queued-follow-up lane treated "the send call did not raise" as "the text reached the
chat". `_deliver_queued_first_response` returns normally when the adapter reports
`SendResult(success=False)` (flood control, retries exhausted): `_send_queued_final_text`
only logs the failure. The result was then marked `already_sent`, the completion path
suppressed the only remaining send, and the user got nothing at all — the opposite failure
to the duplicate this lane fixes, on the same code path.

`_deliver_queued_first_response` now reports whether the text is in the chat, and the mark is
gated on it. A refused send returns False (and skips the attachment upload, so the completion
send replays text and files together); a connector egress DECLINE still returns True, because
that destination is not approved and must not be re-sent.

Attachments were also uploaded twice on the single-delivery path: the queued lane uploads the
response's MEDIA: files, then the completion path's `already_sent` rescan uploaded them again.
The result now records `media_already_delivered` and the rescan skips them.

Live (real gateway, real IRC adapter, stand-in server/model, temp home): follow-up refused by
the reference guard with the first send refused — pre-fix head delivered nothing, this head
delivers the answer once.
2026-09-20 20:14:04 -07:00

126 lines
5.2 KiB
Python

"""Queued-follow-up lane: the fallback first-response send and the normal completion send share one
"already delivered" source of truth (#81052).
With streaming disabled (the shipped default) there is never a stream consumer, so the queued
lane always sends the first turn's final itself before running the follow-up. When the lane then
hands the FIRST turn's result back (the follow-up's text was refused), the normal completion path
must not send that text — or its attachments — a second time, and must still send them when the
queued lane's own send was refused.
"""
import pytest
from gateway.config import Platform
from gateway.platforms.base import SendResult
from gateway.platforms.event import MessageEvent, MessageType, SessionSource
from gateway.run import GatewayRunner
from tests.gateway.test_run_progress_topics import ProgressCaptureAdapter, _make_runner, _run_with_agent
_SESSION_KEY = "agent:main:telegram:group:-1001:17585"
class _DocCaptureAdapter(ProgressCaptureAdapter):
"""Capture document uploads so a double MEDIA delivery is visible."""
def __init__(self, platform=Platform.TELEGRAM):
super().__init__(platform=platform)
self.documents = []
async def send_document(self, chat_id, file_path, caption=None, file_name=None,
reply_to=None, metadata=None, **kwargs) -> SendResult:
self.documents.append(file_path)
return SendResult(success=True, message_id="doc-1")
class _RefusingDocCaptureAdapter(_DocCaptureAdapter):
"""Every send is refused (flood control, retries exhausted): a SendResult, not an exception."""
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
await super().send(chat_id, content, reply_to=reply_to, metadata=metadata)
return SendResult(success=False, error="flood control", retryable=False)
def _agent_returning(text):
class _Agent:
def __init__(self, **kwargs):
self.tools = []
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
return {"final_response": text, "messages": [], "api_calls": 1}
return _Agent
async def _none():
return None
async def _run_with_refused_followup(monkeypatch, tmp_path, agent_cls, adapter_cls):
"""First turn answers, the queued follow-up's text is refused: the lane hands back the FIRST
turn's result, which is the object the completion send then acts on."""
monkeypatch.setattr(
GatewayRunner, "_expand_inbound_context_references",
lambda self, source, session_key, message_text: _none(),
)
return await _run_with_agent(
monkeypatch, tmp_path, agent_cls, session_id="sess-refused-followup",
pending_text="@file:/etc/shadow please", adapter_cls=adapter_cls,
)
async def _completion_seam(adapter, agent_result, response):
"""Run the result through ``_hmwa_deliver_turn_response`` — the seam that decides whether the
caller sends the body again. Returns the text the caller would send (None = suppressed)."""
runner = _make_runner(adapter)
source = SessionSource(
platform=adapter.platform, chat_id="-1001", chat_type="group", thread_id="17585",
)
event = MessageEvent(text="hi", message_type=MessageType.TEXT, source=source, message_id="1")
class _Entry:
session_id = "s1"
return await runner._hmwa_deliver_turn_response(
event, source, _Entry(), _SESSION_KEY, None, agent_result, [], response, None, False,
)
@pytest.mark.asyncio
async def test_queued_lane_delivers_text_and_media_exactly_once(monkeypatch, tmp_path):
"""Single delivery: the queued lane sends the text and uploads the attachment, and the
completion seam re-sends neither."""
doc = tmp_path / "report.pdf"
doc.write_text("x", encoding="utf-8")
final = f"answer 1\n\nMEDIA: {doc}"
adapter, result = await _run_with_refused_followup(
monkeypatch, tmp_path, _agent_returning(final), _DocCaptureAdapter,
)
assert [c["content"] for c in adapter.sent] == ["answer 1"]
assert adapter.documents == [str(doc)]
assert result["final_response"] == final
assert await _completion_seam(adapter, result, final) is None
assert len(adapter.sent) == 1
assert adapter.documents == [str(doc)]
@pytest.mark.asyncio
async def test_refused_queued_send_leaves_the_completion_send_as_the_fallback(monkeypatch, tmp_path):
"""A refused queued send (``SendResult.success`` False) must NOT mark the turn delivered: the
completion seam is the only remaining send, and suppressing it loses the answer entirely."""
doc = tmp_path / "report.pdf"
doc.write_text("x", encoding="utf-8")
final = f"answer 1\n\nMEDIA: {doc}"
adapter, result = await _run_with_refused_followup(
monkeypatch, tmp_path, _agent_returning(final), _RefusingDocCaptureAdapter,
)
# Attempted (and retried) but never accepted by the platform.
assert adapter.sent and {c["content"] for c in adapter.sent} == {"answer 1"}
# The text never landed, so its attachments wait for the completion send too.
assert adapter.documents == []
assert not result.get("already_sent")
assert await _completion_seam(adapter, result, final) == final