Files
aiturk-hermes-ide/tests/gateway/test_73771_media_resend_dedup.py
T

540 lines
21 KiB
Python

"""Regression tests for #73771 — session-wide MEDIA dedup swallowing
explicit resend requests.
The history-dedup guard in the delivery pipeline used to drop ANY
``MEDIA:<path>`` whose path appeared earlier in the session transcript —
unconditionally, silently, for the whole session lifetime. A user asking
"send me that file again" got a reply reading "here it is" with no
attachment and nothing in the logs.
Fix (salvaged from PR #74158 by @webtecnica, widened to the streaming
sibling):
* Non-streaming (``BasePlatformAdapter._process_message_background``):
explicit MEDIA tags are NO LONGER filtered against history. Stale
auto-appended tags are already deduped upstream in
``_collect_auto_append_media_tags``.
* Streaming (``GatewayRunner._deliver_media_from_response``): same filter
removed — that rescan is explicit-only by design (#20834), so anything
it finds is a deliberate attachment request.
* Bare local file paths (auto-detected, not explicit) KEEP the history
dedup on the non-streaming path, and the suppression is now logged.
"""
import asyncio
import logging
import threading
import time
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, _collect_auto_append_media_tags, _collect_history_media_paths
from gateway.session import SessionSource, build_session_key
class _DummyAdapter(BasePlatformAdapter):
"""Minimal BasePlatformAdapter for non-streaming dispatch tests."""
def __init__(self, platform: Platform = Platform.DISCORD):
super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform)
self.sent: list[dict] = []
self.documents: list[str] = []
self.images_sent: list[str] = []
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
return None
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
self.sent.append({"chat_id": chat_id, "content": content})
return SendResult(success=True, message_id="msg-1")
async def send_typing(self, chat_id: str, metadata=None) -> None:
return None
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
async def send_document(self, chat_id, file_path, caption=None, file_name=None, reply_to=None, metadata=None, **kwargs) -> SendResult:
self.documents.append(str(file_path))
return SendResult(success=True, message_id="doc-1")
async def send_multiple_images(self, chat_id, images, metadata=None, human_delay=0.0):
self.images_sent.extend(str(p) for p, _cap in images)
async def send_image_file(self, chat_id, image_path, caption=None, reply_to=None, metadata=None, **kwargs) -> SendResult:
self.images_sent.append(str(image_path))
return SendResult(success=True, message_id="img-1")
class _StubStore:
"""Session store stub whose transcript claims ``paths`` were already
delivered in prior turns (one assistant message per path, followed by a
trailing assistant message representing the current turn — which
_history_media_paths_for_session pops)."""
def __init__(self, paths):
self._transcript = [{"role": "user", "content": "make files"}]
for p in paths:
self._transcript.append({"role": "assistant", "content": f"Done! MEDIA:{p}"})
self._transcript.append({"role": "user", "content": "send it again please"})
# Current turn's already-persisted assistant reply (excluded by the
# helper), so all earlier paths stay in the dedup set.
self._transcript.append({"role": "assistant", "content": "resending now"})
def peek_session_id(self, session_key):
return "sess-1"
def load_transcript(self, session_id):
return list(self._transcript)
def _make_event(platform: Platform = Platform.DISCORD) -> MessageEvent:
return MessageEvent(
text="send me that file again",
message_type=MessageType.TEXT,
source=SessionSource(platform=platform, chat_id="111", chat_type="dm"),
message_id="m1",
)
async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None):
if stop_event is not None:
await stop_event.wait()
else:
await asyncio.Event().wait()
def _allowed_file(tmp_path, monkeypatch, name: str):
root = tmp_path / "media-cache"
f = root / name
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"payload")
monkeypatch.setattr("gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", (root,))
return f.resolve()
# ---------------------------------------------------------------------------
# Non-streaming path (base.py)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_explicit_media_resend_is_delivered_despite_history(tmp_path, monkeypatch):
"""#73771 core repro: the same MEDIA path was delivered in a prior turn;
the model re-emits it on an explicit user request — it MUST be sent."""
pdf = _allowed_file(tmp_path, monkeypatch, "report.pdf")
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
adapter.set_session_store(_StubStore([str(pdf)]))
async def handler(_event):
return f"Here it is again.\nMEDIA:{pdf}"
adapter.set_message_handler(handler)
event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))
assert adapter.documents == [str(pdf)], (
f"explicit MEDIA resend was suppressed: docs={adapter.documents} "
f"sent={adapter.sent}"
)
# And the visible text still went out.
assert any("Here it is again." in s["content"] for s in adapter.sent)
@pytest.mark.asyncio
async def test_first_delivery_not_poisoned_by_current_turn_tool_output(tmp_path, monkeypatch):
"""A MEDIA path present in the CURRENT turn's tool result used to poison
the history set and suppress even the first-ever delivery. With the tag
filter removed this cannot happen regardless of what history contains."""
docx = _allowed_file(tmp_path, monkeypatch, "letter.docx")
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
class _ToolEchoStore(_StubStore):
def __init__(self):
self._transcript = [
{"role": "user", "content": "make a docx"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "t1", "function": {"name": "execute_code"}}]},
{"role": "tool", "tool_call_id": "t1",
"content": f"wrote MEDIA:{docx}"},
{"role": "assistant", "content": "current turn reply"},
]
adapter.set_session_store(_ToolEchoStore())
async def handler(_event):
return f"DOCX:\nMEDIA:{docx}"
adapter.set_message_handler(handler)
event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))
assert adapter.documents == [str(docx)], (
f"first delivery suppressed by current-turn tool echo: {adapter.sent}"
)
@pytest.mark.asyncio
async def test_bare_local_path_history_dedup_survives_and_logs(tmp_path, monkeypatch, caplog):
"""Bare-path auto-detect (non-explicit) KEEPS the history dedup — and the
suppression is now observable in the logs instead of silent."""
png = _allowed_file(tmp_path, monkeypatch, "chart.png")
monkeypatch.setattr("gateway.platforms.base.LOCAL_DELIVERY_SAFE_ROOTS", (png.parent,), raising=False)
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
adapter.set_session_store(_StubStore([str(png)]))
# Bypass the local-path safety filter so the test pins ONLY the history
# dedup behavior, not the safe-root policy.
monkeypatch.setattr(
type(adapter), "filter_local_delivery_paths", staticmethod(lambda paths, session_key="": list(paths))
)
async def handler(_event):
# Bare path, no MEDIA: directive — the auto-detect lane.
return f"The chart is at {png} by the way."
adapter.set_message_handler(handler)
event = _make_event()
with caplog.at_level(logging.INFO, logger="gateway.platforms.base"):
await adapter._process_message_background(event, build_session_key(event.source))
assert adapter.images_sent == [] and adapter.documents == [], (
"bare-path history dedup regressed — stale path re-uploaded"
)
assert any("Suppressing" in r.getMessage() for r in caplog.records), (
"suppression must be logged (#73771 observability)"
)
@pytest.mark.asyncio
async def test_plain_text_response_does_not_load_transcript():
"""Ordinary text delivery must not touch SQLite-backed history at all."""
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
class _ExplodingStore:
calls = 0
def peek_session_id(self, _session_key):
self.calls += 1
raise AssertionError("plain text delivery loaded session history")
store = _ExplodingStore()
adapter.set_session_store(store)
async def handler(_event):
return "Plain response with no local attachment path."
adapter.set_message_handler(handler)
event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))
assert store.calls == 0
assert any("Plain response" in item["content"] for item in adapter.sent)
@pytest.mark.asyncio
async def test_explicit_media_response_does_not_load_transcript(tmp_path, monkeypatch):
"""Explicit MEDIA delivery must not touch SQLite-backed history."""
pdf = _allowed_file(tmp_path, monkeypatch, "explicit-no-history.pdf")
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
class _ExplodingStore:
calls = 0
def peek_session_id(self, _session_key):
self.calls += 1
raise AssertionError("explicit MEDIA delivery loaded session history")
store = _ExplodingStore()
adapter.set_session_store(store)
async def handler(_event):
return f"Here is the file.\nMEDIA:{pdf}"
adapter.set_message_handler(handler)
event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))
assert store.calls == 0
assert adapter.documents == [str(pdf)]
@pytest.mark.asyncio
async def test_bare_path_history_lookup_does_not_block_event_loop(tmp_path, monkeypatch):
"""A slow transcript read must run outside the platform event loop."""
pdf = _allowed_file(tmp_path, monkeypatch, "slow-history.pdf")
monkeypatch.setattr("gateway.platforms.base.LOCAL_DELIVERY_SAFE_ROOTS", (pdf.parent,), raising=False)
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
monkeypatch.setattr(
type(adapter), "filter_local_delivery_paths", staticmethod(lambda paths, session_key="": list(paths))
)
class _GatedStore:
def __init__(self):
self.release = threading.Event()
def peek_session_id(self, _session_key):
return "sess-slow"
def load_transcript(self, _session_id):
# Block until the test has proven the event loop stayed
# responsive. An Event gate (instead of a fixed sleep) makes the
# assertion deterministic on slow/loaded CI hosts.
self.release.wait(timeout=5)
return [{"role": "user", "content": "current"}]
store = _GatedStore()
adapter.set_session_store(store)
async def handler(_event):
return f"Generated file: {pdf}"
adapter.set_message_handler(handler)
event = _make_event()
delivery = asyncio.create_task(
adapter._process_message_background(event, build_session_key(event.source))
)
await asyncio.sleep(0.02)
# The transcript read is still parked on the gate. If load_transcript ran
# on the event-loop thread, the sleep above could never have resumed
# while the read was in progress — reaching this point with the delivery
# still pending proves the read happened off-loop.
assert not delivery.done()
assert not adapter.documents
store.release.set()
await delivery
assert adapter.documents == [str(pdf)]
@pytest.mark.asyncio
async def test_bare_path_history_lookup_timeout_fails_open(tmp_path, monkeypatch):
"""A wedged transcript read must not hold response delivery indefinitely."""
pdf = _allowed_file(tmp_path, monkeypatch, "timeout-history.pdf")
monkeypatch.setattr("gateway.platforms.base.LOCAL_DELIVERY_SAFE_ROOTS", (pdf.parent,), raising=False)
monkeypatch.setattr("gateway.platforms.base._HISTORY_MEDIA_LOOKUP_TIMEOUT_SECONDS", 0.02)
adapter = _DummyAdapter()
adapter._keep_typing = _hold_typing
monkeypatch.setattr(
type(adapter), "filter_local_delivery_paths", staticmethod(lambda paths, session_key="": list(paths))
)
class _WedgedStore:
def peek_session_id(self, _session_key):
return "sess-wedged"
def load_transcript(self, _session_id):
time.sleep(0.2)
return []
adapter.set_session_store(_WedgedStore())
async def handler(_event):
return f"Generated file: {pdf}"
adapter.set_message_handler(handler)
event = _make_event()
started = time.monotonic()
await adapter._process_message_background(event, build_session_key(event.source))
# The lookup times out after 0.02s and fails open; the bound only guards
# against delivery hanging on the wedged read indefinitely. 1.0s still
# flaked on loaded CI runners (observed 1.55s on main run 33455779041),
# so keep it >= 5s per the flake policy. Delivery of the document below
# is the real fail-open assertion.
assert time.monotonic() - started < 5.0
assert adapter.documents == [str(pdf)]
@pytest.mark.asyncio
async def test_history_lookup_saturation_fails_open_without_new_worker(monkeypatch):
"""Wedged lookups are bounded and cannot consume unbounded worker threads."""
monkeypatch.setattr("gateway.platforms.base._HISTORY_MEDIA_LOOKUP_TIMEOUT_SECONDS", 5.0)
monkeypatch.setattr(
"gateway.platforms.base._HISTORY_MEDIA_LOOKUP_ADMISSION",
threading.BoundedSemaphore(2),
)
adapter = _DummyAdapter()
release = threading.Event()
two_started = threading.Event()
calls = 0
calls_lock = threading.Lock()
def blocked_lookup(_session_key):
nonlocal calls
with calls_lock:
calls += 1
if calls == 2:
two_started.set()
release.wait(timeout=10)
return None
monkeypatch.setattr(adapter, "_history_media_paths_for_session", blocked_lookup)
first = asyncio.create_task(adapter._bounded_history_media_paths_for_session("one"))
second = asyncio.create_task(adapter._bounded_history_media_paths_for_session("two"))
deadline = time.monotonic() + 5
while not two_started.is_set() and time.monotonic() < deadline:
await asyncio.sleep(0.005)
assert two_started.is_set()
began = time.monotonic()
third = await adapter._bounded_history_media_paths_for_session("three")
elapsed = time.monotonic() - began
assert third is None
# Saturation must fail open immediately (no waiting on the 5.0s lookup
# timeout); 2.0s keeps the distinction while staying flake-free on
# loaded CI runners.
assert elapsed < 2.0
assert calls == 2
release.set()
await asyncio.gather(first, second)
@pytest.mark.asyncio
async def test_history_lookup_worker_start_failure_fails_open_and_releases_slot(
monkeypatch, caplog
):
"""If the worker thread cannot start, fail open without leaking a permit.
Regression: ``Thread.start()`` raising (thread exhaustion) used to
propagate into the delivery loop AND permanently leak the admission
permit acquired just above it, because the worker's finally-release
never runs when the worker never starts.
"""
admission = threading.BoundedSemaphore(1)
monkeypatch.setattr(
"gateway.platforms.base._HISTORY_MEDIA_LOOKUP_ADMISSION", admission
)
adapter = _DummyAdapter()
def _exhausted_start(self):
raise RuntimeError("can't start new thread")
monkeypatch.setattr(threading.Thread, "start", _exhausted_start)
with caplog.at_level(logging.WARNING):
first = await adapter._bounded_history_media_paths_for_session("one")
# With only one permit, a leak on the first call would force this
# second call down the capacity-exhausted path instead of the
# start-failure path.
second = await adapter._bounded_history_media_paths_for_session("two")
assert first is None
assert second is None
assert "capacity exhausted" not in caplog.text
assert (
caplog.text.count("Could not start media-delivery history lookup worker")
== 2
)
# The permit was returned both times: it is still acquirable.
assert admission.acquire(blocking=False)
admission.release()
# ---------------------------------------------------------------------------
# Streaming sibling (run.py _deliver_media_from_response)
# ---------------------------------------------------------------------------
def _stream_event():
return MessageEvent(
text="send it again",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.SLACK, chat_id="C1", chat_type="group"),
message_id="171.1",
)
def _stream_adapter():
return SimpleNamespace(
name="test",
extract_media=BasePlatformAdapter.extract_media,
extract_images=BasePlatformAdapter.extract_images,
send_voice=AsyncMock(return_value=SendResult(success=True, message_id="v")),
send_document=AsyncMock(return_value=SendResult(success=True, message_id="d")),
send_image_file=AsyncMock(return_value=SendResult(success=True, message_id="i")),
send_video=AsyncMock(return_value=SendResult(success=True, message_id="vid")),
send_multiple_images=AsyncMock(return_value=SendResult(success=True, message_id="ii")),
)
@pytest.mark.asyncio
async def test_streamed_explicit_media_resend_is_delivered(tmp_path, monkeypatch):
"""The streaming rescan must deliver an explicit MEDIA tag even when the
same path was already delivered in a prior turn (sibling of the base.py
fix — post-stream delivery is explicit-only, so nothing it finds is an
accidental echo of auto-appended history)."""
img = _allowed_file(tmp_path, monkeypatch, "flyer.png")
adapter = _stream_adapter()
runner = SimpleNamespace(
_thread_metadata_for_source=lambda source, anchor=None: {},
_reply_anchor_for_event=lambda event: None,
)
await GatewayRunner._deliver_media_from_response(
runner,
f"Here's the flyer again.\nMEDIA:{img}",
_stream_event(),
adapter,
)
adapter.send_multiple_images.assert_awaited_once()
sent_paths = [p for p, _cap in adapter.send_multiple_images.await_args.kwargs["images"]]
assert str(img) in sent_paths[0]
def test_stream_rescan_accepts_no_history_dedup_input():
"""Contract pin for the run.py half of the fix: the explicit-only
post-stream rescan must not accept a history-dedup set at all — with the
old ``history_media_paths`` parameter present, the call site fed it the
session transcript and explicit resends were silently filtered."""
import inspect
params = inspect.signature(GatewayRunner._deliver_media_from_response).parameters
assert "history_media_paths" not in params, (
"history dedup re-attached to the explicit-only post-stream rescan "
"(#73771 regression)"
)
# ---------------------------------------------------------------------------
# Invariant: the stale-echo protection that REMAINS
# ---------------------------------------------------------------------------
def test_auto_append_lane_still_dedups_stale_media():
"""Removing the delivery-side tag filter must not regress the upstream
protection: auto-appended tags from history are still deduped via
_collect_auto_append_media_tags + _collect_history_media_paths."""
history = [
{"role": "assistant",
"tool_calls": [{"id": "c", "function": {"name": "image_generate"}}]},
{"role": "tool", "tool_call_id": "c",
"content": '{"success": true, "image": "/tmp/gen/dog.png"}'},
{"role": "assistant", "content": "made it! MEDIA:/tmp/gen/dog.png"},
]
paths = _collect_history_media_paths(history)
assert "/tmp/gen/dog.png" in paths
tags, _voice = _collect_auto_append_media_tags(
history, history_offset=0, history_media_paths=paths
)
assert tags == [], f"stale auto-append tags re-emitted: {tags}"