584 lines
22 KiB
Python
584 lines
22 KiB
Python
"""
|
|
Tests for cross-platform audio/voice media routing.
|
|
|
|
These tests pin the expected delivery path for audio media files across
|
|
Telegram (where Bot-API sendAudio only accepts MP3/M4A and .ogg/.opus
|
|
only renders as a voice bubble when explicitly flagged) and via
|
|
``GatewayRunner._deliver_media_from_response``.
|
|
"""
|
|
|
|
import importlib
|
|
import sys
|
|
import types
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
|
from gateway.run import GatewayRunner
|
|
from gateway.session import SessionSource, build_session_key
|
|
|
|
|
|
class _MediaRoutingAdapter(BasePlatformAdapter):
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
|
|
|
|
async def connect(self, *, is_reconnect: bool = False):
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
pass
|
|
|
|
async def send(self, chat_id, content=None, **kwargs):
|
|
return SendResult(success=True, message_id="text")
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {"id": chat_id, "type": "dm"}
|
|
|
|
|
|
def _event(thread_id=None):
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="chat-1",
|
|
chat_type="dm",
|
|
thread_id=thread_id,
|
|
)
|
|
return MessageEvent(
|
|
text="make speech",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="msg-1",
|
|
)
|
|
|
|
|
|
def _allowed_media_path(tmp_path, monkeypatch, name):
|
|
root = tmp_path / "media-cache"
|
|
media_file = root / name
|
|
media_file.parent.mkdir(parents=True, exist_ok=True)
|
|
media_file.write_bytes(b"media")
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
|
(root,),
|
|
)
|
|
return media_file.resolve()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(tmp_path, monkeypatch):
|
|
adapter = _MediaRoutingAdapter()
|
|
event = _event()
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg")
|
|
adapter._message_handler = AsyncMock(
|
|
return_value=f"[[audio_as_voice]]\nMEDIA:{media_file}"
|
|
)
|
|
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
|
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
|
|
|
await adapter._process_message_background(event, build_session_key(event.source))
|
|
|
|
adapter.send_voice.assert_awaited_once_with(
|
|
chat_id="chat-1",
|
|
audio_path=str(media_file),
|
|
metadata={"notify": True},
|
|
is_voice=True,
|
|
)
|
|
adapter.send_document.assert_not_awaited()
|
|
|
|
|
|
def _fake_runner(thread_meta):
|
|
"""Build a fake GatewayRunner-like object with the helper methods needed by
|
|
_deliver_media_from_response."""
|
|
runner = SimpleNamespace(
|
|
_thread_metadata_for_source=lambda source, anchor=None: thread_meta,
|
|
_reply_anchor_for_event=lambda event: None,
|
|
)
|
|
return runner
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_path, monkeypatch):
|
|
event = _event(thread_id="topic-1")
|
|
allowed_root = tmp_path / "media-cache"
|
|
allowed_root.mkdir()
|
|
secret = tmp_path / "outside.pdf"
|
|
secret.write_bytes(b"%PDF secret")
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
|
(allowed_root,),
|
|
)
|
|
# This test exercises the strict-allowlist path; force strict mode on
|
|
# and disable recency trust so the freshly-written tmp_path file is not
|
|
# auto-accepted by the trust window. (Recency trust is covered separately
|
|
# in test_platform_base.py. The public default flipped to non-strict in
|
|
# 2026-05; this test pins strict on explicitly.)
|
|
monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1")
|
|
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_image_file=AsyncMock(return_value=SendResult(success=True, message_id="image")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
await GatewayRunner._deliver_media_from_response(
|
|
_fake_runner({"thread_id": "topic-1"}),
|
|
f"MEDIA:{secret}",
|
|
event,
|
|
adapter,
|
|
)
|
|
|
|
adapter.send_document.assert_not_awaited()
|
|
adapter.send_voice.assert_not_awaited()
|
|
|
|
|
|
class _DiscordMediaFailureAdapter(BasePlatformAdapter):
|
|
"""Minimal adapter to exercise non-streaming MEDIA failure notification."""
|
|
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.DISCORD)
|
|
self.notices: list[str] = []
|
|
|
|
async def connect(self, *, is_reconnect: bool = False):
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
pass
|
|
|
|
async def send(self, chat_id, content=None, **kwargs):
|
|
self.notices.append(content or "")
|
|
return SendResult(success=True, message_id="notice")
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {"id": chat_id, "type": "dm"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_streaming_media_failure_notifies_user(tmp_path, monkeypatch):
|
|
"""Attachmentless send_video results must surface a user-visible notice (#66797)."""
|
|
adapter = _DiscordMediaFailureAdapter()
|
|
event = _event()
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "clip.mp4")
|
|
adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_file}")
|
|
adapter.send_video = AsyncMock(
|
|
return_value=SendResult(
|
|
success=False,
|
|
error="Discord accepted the message but attached no files (clip.mp4)",
|
|
)
|
|
)
|
|
adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc"))
|
|
adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice"))
|
|
adapter.send_multiple_images = AsyncMock()
|
|
|
|
await adapter._process_message_background(event, build_session_key(event.source))
|
|
|
|
adapter.send_video.assert_awaited_once()
|
|
assert adapter.notices == ["⚠️ Couldn't deliver the video attachment."]
|
|
|
|
|
|
class _DiscordMediaFailureAdapter(BasePlatformAdapter):
|
|
"""Minimal adapter to exercise non-streaming MEDIA failure notification."""
|
|
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.DISCORD)
|
|
self.notices: list[str] = []
|
|
|
|
async def connect(self, *, is_reconnect: bool = False):
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
pass
|
|
|
|
async def send(self, chat_id, content=None, **kwargs):
|
|
self.notices.append(content or "")
|
|
return SendResult(success=True, message_id="notice")
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {"id": chat_id, "type": "dm"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_strips_media_tag_from_text_and_sends_image(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
event = _event(thread_id="topic-1")
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "pricelist.png")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = lambda source, anchor=None: {"thread_id": "topic-1"}
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
f"Quote here\nMEDIA:{media_file}",
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata={"thread_id": "topic-1"},
|
|
event_message_id=event.message_id,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once_with(
|
|
"chat-1",
|
|
"Quote here",
|
|
metadata={"thread_id": "topic-1"},
|
|
)
|
|
adapter.send_multiple_images.assert_awaited_once_with(
|
|
chat_id="chat-1",
|
|
images=[(f"file://{media_file.as_posix()}", "")],
|
|
metadata={"thread_id": "topic-1"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_reuses_routing_metadata_for_media(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
"""Queued text and media must stay on the same precomputed reply route."""
|
|
event = _event(thread_id="source-topic")
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "threaded.png")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = (
|
|
lambda source, reply_to_message_id=None: {"thread_id": "recomputed-topic"}
|
|
)
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
routing_metadata = {
|
|
"thread_id": "queued-topic",
|
|
"reply_to_message_id": "trigger-message",
|
|
}
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
f"Threaded image\nMEDIA:{media_file}",
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata=routing_metadata,
|
|
event_message_id=event.message_id,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once_with(
|
|
"chat-1",
|
|
"Threaded image",
|
|
metadata=routing_metadata,
|
|
)
|
|
adapter.send_multiple_images.assert_awaited_once_with(
|
|
chat_id="chat-1",
|
|
images=[(f"file://{media_file.as_posix()}", "")],
|
|
metadata=routing_metadata,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_keeps_remote_image_url_in_text():
|
|
event = _event(thread_id="topic-1")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = lambda source, anchor=None: {"thread_id": "topic-1"}
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
response = "See this mockup\nhttps://example.com/mockup.png"
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
response,
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata={"thread_id": "topic-1"},
|
|
event_message_id=event.message_id,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once_with(
|
|
"chat-1",
|
|
response,
|
|
metadata={"thread_id": "topic-1"},
|
|
)
|
|
adapter.send_multiple_images.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_keeps_bare_local_path_in_text(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
"""Queued delivery must not strip paths its explicit-only uploader ignores."""
|
|
event = _event(thread_id="topic-1")
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "inspected.png")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = (
|
|
lambda source, reply_to_message_id=None: {"thread_id": "topic-1"}
|
|
)
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
response = f"The inspected file is at {media_file}."
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
response,
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata={"thread_id": "topic-1"},
|
|
event_message_id=event.message_id,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once_with(
|
|
"chat-1",
|
|
response,
|
|
metadata={"thread_id": "topic-1"},
|
|
)
|
|
adapter.send_multiple_images.assert_not_awaited()
|
|
adapter.send_document.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_preserves_protected_media_example():
|
|
"""Inline-code MEDIA examples must remain visible after queued text cleanup."""
|
|
event = _event(thread_id="topic-1")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = lambda source, anchor=None: {"thread_id": "topic-1"}
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
response = "Tag files like `MEDIA:/tmp/example.png` in tool output."
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
response,
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata={"thread_id": "topic-1"},
|
|
event_message_id=event.message_id,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once_with(
|
|
"chat-1",
|
|
response,
|
|
metadata={"thread_id": "topic-1"},
|
|
)
|
|
adapter.send_multiple_images.assert_not_awaited()
|
|
adapter.send_document.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_followup_delivery_skips_media_when_turn_failed():
|
|
"""A failed first turn delivers its (failure) text but never uploads
|
|
attachments as if the turn succeeded — deliver_media=False mirrors the
|
|
completed-turn path's ``not agent_result.get("failed")`` guard."""
|
|
event = _event(thread_id="topic-1")
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._thread_metadata_for_source = lambda source, anchor=None: {"thread_id": "topic-1"}
|
|
runner._reply_anchor_for_event = lambda event: event.message_id
|
|
|
|
adapter = SimpleNamespace(
|
|
name="test",
|
|
extract_media=BasePlatformAdapter.extract_media,
|
|
extract_images=BasePlatformAdapter.extract_images,
|
|
extract_local_files=BasePlatformAdapter.extract_local_files,
|
|
send=AsyncMock(return_value=SendResult(success=True, message_id="text")),
|
|
send_multiple_images=AsyncMock(return_value=None),
|
|
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")),
|
|
send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")),
|
|
send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")),
|
|
)
|
|
|
|
await GatewayRunner._deliver_queued_first_response(
|
|
runner,
|
|
"The request failed: provider exploded\nMEDIA:/tmp/pricelist.png",
|
|
source=event.source,
|
|
adapter=adapter,
|
|
metadata={"thread_id": "topic-1"},
|
|
event_message_id=event.message_id,
|
|
deliver_media=False,
|
|
)
|
|
|
|
adapter.send.assert_awaited_once()
|
|
adapter.send_multiple_images.assert_not_awaited()
|
|
adapter.send_document.assert_not_awaited()
|
|
adapter.send_video.assert_not_awaited()
|
|
|
|
|
|
class _QueuedMediaCaptureAdapter(BasePlatformAdapter):
|
|
"""Adapter that records text + native image delivery for queued-resend tests."""
|
|
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
|
|
self.sent = []
|
|
self.images = []
|
|
|
|
async def connect(self, *, is_reconnect: bool = False):
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
return None
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
|
self.sent.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
|
return SendResult(success=True, message_id=f"text-{len(self.sent)}")
|
|
|
|
async def send_image_file(self, chat_id, image_path, caption=None, reply_to=None, metadata=None, **kwargs):
|
|
self.images.append({"chat_id": chat_id, "image_path": image_path, "metadata": metadata})
|
|
return SendResult(success=True, message_id=f"img-{len(self.images)}")
|
|
|
|
async def send_multiple_images(self, chat_id, images, metadata=None, human_delay=0.0):
|
|
for image_url, _alt in images:
|
|
path = image_url
|
|
if path.startswith("file://"):
|
|
path = path[len("file://"):]
|
|
self.images.append({"chat_id": chat_id, "image_path": path, "metadata": metadata})
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {"id": chat_id, "type": "dm"}
|
|
|
|
|
|
class _QueuedMediaAgent:
|
|
calls = 0
|
|
first_response = ""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1:
|
|
return {
|
|
"final_response": type(self).first_response,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
return {
|
|
"final_response": "follow-up processed",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queued_resend_branch_delivers_media_and_preserves_protected_example(
|
|
tmp_path, monkeypatch,
|
|
):
|
|
"""Exercise the real queued first-response resend path in ``_run_agent``."""
|
|
media_file = _allowed_media_path(tmp_path, monkeypatch, "quote.png")
|
|
protected = "Tag files like `MEDIA:/tmp/example.png` in tool output."
|
|
_QueuedMediaAgent.calls = 0
|
|
_QueuedMediaAgent.first_response = f"Quote here\nMEDIA:{media_file}\n{protected}"
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
setattr(fake_dotenv, "load_dotenv", lambda *args, **kwargs: None)
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
setattr(fake_run_agent, "AIAgent", _QueuedMediaAgent)
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = _QueuedMediaCaptureAdapter()
|
|
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.session_store = SimpleNamespace(_entries={}, _save=lambda: None)
|
|
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
|
runner.config = SimpleNamespace(
|
|
thread_sessions_per_user=False,
|
|
group_sessions_per_user=False,
|
|
stt_enabled=False,
|
|
)
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="chat-1",
|
|
chat_type="dm",
|
|
thread_id="topic-1",
|
|
)
|
|
session_key = build_session_key(source)
|
|
adapter._pending_messages[session_key] = MessageEvent(
|
|
text="queued follow-up",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="queued-1",
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-queued-media",
|
|
session_key=session_key,
|
|
)
|
|
|
|
assert _QueuedMediaAgent.calls == 2
|
|
assert result["final_response"] == "follow-up processed"
|
|
first_texts = [call["content"] for call in adapter.sent if "Quote here" in call["content"]]
|
|
assert first_texts, f"expected queued resend of first response, got: {adapter.sent!r}"
|
|
assert f"MEDIA:{media_file}" not in first_texts[0]
|
|
assert "`MEDIA:/tmp/example.png`" in first_texts[0]
|
|
assert any(str(media_file) in img["image_path"] for img in adapter.images), (
|
|
f"expected native image delivery via queued resend, got: {adapter.images!r}"
|
|
)
|