Files
hermes-agent/tests/gateway/test_post_stream_media_delivery.py
kshitijk4poor ab2f4602de refactor: MessageEvent to gateway/platforms/event.py; ElicitationHandler takes a call_context thunk
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two
sites now name the real types.

gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved
out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py
imported helpers.py at module level, so helpers could not name MessageEvent. Now
TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed
(`from gateway.platforms.base import` -> `.event`, preserving each import's layout);
gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT
pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType,
photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event.
Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans).

tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its
MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only
ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]`
and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one
`functools.partial`, run directly or inside the captured Context.

ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved.
(The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional
exposes ~60 unguarded call sites — separate follow-up.)

Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31
failures reproduce identically on origin/main (macOS /private/tmp, systemd socket,
long-path fixtures, live-service tests).
2026-09-07 22:47:33 +05:30

115 lines
4.0 KiB
Python

"""Post-stream media delivery is explicit-only (#20834).
``GatewayRunner._deliver_media_from_response`` runs AFTER streaming has sent
the visible reply. At that point a bare local filesystem path in the response
text is either text the user already saw, or stale inspected/tool content —
it is NOT an attachment request. Only explicit ``MEDIA:`` directives may
trigger post-stream uploads.
The non-streaming path (``gateway/platforms/base.py``) keeps its bare-path
auto-detect (``extract_local_files``) — that path controls what text is sent
and can strip the path from the visible reply, so auto-attach is intentional
there. This file pins the asymmetry.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
def _event():
source = SessionSource(
platform=Platform.SLACK,
chat_id="C123CHAN",
chat_type="group",
thread_id=None,
)
return MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=source,
message_id="171.000001",
)
def _fake_runner(thread_meta):
return SimpleNamespace(
_thread_metadata_for_source=lambda source, anchor=None: thread_meta,
_reply_anchor_for_event=lambda event: None,
)
def _adapter():
return 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")),
send_multiple_images=AsyncMock(return_value=SendResult(success=True, message_id="imgs")),
)
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_bare_local_path_in_streamed_reply_is_not_uploaded(tmp_path, monkeypatch):
"""The #20834 shape: visible reply contains a bare path (from inspected
content), no MEDIA: directive — nothing may be uploaded post-stream."""
media_file = _allowed_media_path(tmp_path, monkeypatch, "mockup.png")
adapter = _adapter()
await GatewayRunner._deliver_media_from_response(
_fake_runner({}),
f"The design lives at {media_file} if you want to look later.",
_event(),
adapter,
)
adapter.send_multiple_images.assert_not_awaited()
adapter.send_image_file.assert_not_awaited()
adapter.send_document.assert_not_awaited()
adapter.send_video.assert_not_awaited()
adapter.send_voice.assert_not_awaited()
@pytest.mark.asyncio
async def test_explicit_media_tag_still_delivers_post_stream(tmp_path, monkeypatch):
"""Explicit MEDIA: directives keep working after the #20834 fix."""
media_file = _allowed_media_path(tmp_path, monkeypatch, "chart.png")
adapter = _adapter()
await GatewayRunner._deliver_media_from_response(
_fake_runner({}),
f"Here is the chart.\nMEDIA:{media_file}",
_event(),
adapter,
)
adapter.send_multiple_images.assert_awaited_once()
images_kwargs = adapter.send_multiple_images.await_args.kwargs
assert images_kwargs["chat_id"] == "C123CHAN"
assert str(media_file) in images_kwargs["images"][0][0]