mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
604 lines
23 KiB
Python
604 lines
23 KiB
Python
"""Tests for the delivery routing module."""
|
|
|
|
import pytest
|
|
from typing import Any, cast
|
|
|
|
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
|
|
from gateway.delivery import DeliveryRouter, DeliveryTarget
|
|
from gateway.platforms.base import SendResult
|
|
from gateway.relay.adapter import RelayAdapter
|
|
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class TestParseTargetPlatformChat:
|
|
def test_explicit_telegram_chat(self):
|
|
target = DeliveryTarget.parse("telegram:12345")
|
|
assert target.platform == Platform.TELEGRAM
|
|
assert target.chat_id == "12345"
|
|
assert target.is_explicit is True
|
|
|
|
def test_platform_only_no_chat_id(self):
|
|
target = DeliveryTarget.parse("discord")
|
|
assert target.platform == Platform.DISCORD
|
|
assert target.chat_id is None
|
|
assert target.is_explicit is False
|
|
|
|
def test_local_target(self):
|
|
target = DeliveryTarget.parse("local")
|
|
assert target.platform == Platform.LOCAL
|
|
assert target.chat_id is None
|
|
|
|
def test_origin_with_source(self):
|
|
origin = SessionSource(platform=Platform.TELEGRAM, chat_id="789", thread_id="42")
|
|
target = DeliveryTarget.parse("origin", origin=origin)
|
|
assert target.platform == Platform.TELEGRAM
|
|
assert target.chat_id == "789"
|
|
assert target.thread_id == "42"
|
|
assert target.is_origin is True
|
|
|
|
def test_origin_without_source(self):
|
|
target = DeliveryTarget.parse("origin")
|
|
assert target.platform == Platform.LOCAL
|
|
assert target.is_origin is True
|
|
|
|
def test_unknown_platform(self):
|
|
target = DeliveryTarget.parse("unknown_platform")
|
|
assert target.platform == Platform.LOCAL
|
|
|
|
|
|
class TestTargetToStringRoundtrip:
|
|
def test_origin_roundtrip(self):
|
|
origin = SessionSource(platform=Platform.TELEGRAM, chat_id="111", thread_id="42")
|
|
target = DeliveryTarget.parse("origin", origin=origin)
|
|
assert target.to_string() == "origin"
|
|
|
|
def test_local_roundtrip(self):
|
|
target = DeliveryTarget.parse("local")
|
|
assert target.to_string() == "local"
|
|
|
|
def test_platform_only_roundtrip(self):
|
|
target = DeliveryTarget.parse("discord")
|
|
assert target.to_string() == "discord"
|
|
|
|
def test_explicit_chat_roundtrip(self):
|
|
target = DeliveryTarget.parse("telegram:999")
|
|
s = target.to_string()
|
|
assert s == "telegram:999"
|
|
|
|
reparsed = DeliveryTarget.parse(s)
|
|
assert reparsed.platform == Platform.TELEGRAM
|
|
assert reparsed.chat_id == "999"
|
|
|
|
|
|
class TestCaseSensitiveChatIdParsing:
|
|
"""Test that chat IDs preserve their original case (issue #11768)."""
|
|
|
|
def test_slack_uppercase_chat_id_preserved(self):
|
|
"""Slack channel IDs like C123ABC should preserve case."""
|
|
target = DeliveryTarget.parse("slack:C123ABC")
|
|
assert target.platform == Platform.SLACK
|
|
assert target.chat_id == "C123ABC" # Should NOT be lowercased to c123abc
|
|
assert target.is_explicit is True
|
|
|
|
def test_slack_chat_id_with_thread_preserved(self):
|
|
"""Slack channel:thread IDs should preserve case."""
|
|
target = DeliveryTarget.parse("slack:C123ABC:thread123")
|
|
assert target.platform == Platform.SLACK
|
|
assert target.chat_id == "C123ABC"
|
|
assert target.thread_id == "thread123"
|
|
|
|
def test_matrix_room_id_preserved(self):
|
|
"""Matrix room IDs like !RoomABC:example.org should preserve case.
|
|
|
|
Note: Matrix room IDs contain colons (e.g., !RoomABC:example.org).
|
|
Due to the platform:chat_id:thread_id format, these are parsed as
|
|
chat_id=!RoomABC and thread_id=example.org. This is a known limitation
|
|
of the current format. The fix preserves case but doesn't change the
|
|
parsing structure.
|
|
"""
|
|
target = DeliveryTarget.parse("matrix:!RoomABC:example.org")
|
|
assert target.platform == Platform.MATRIX
|
|
# The room ID is split at the first colon after the platform prefix
|
|
# This is a format limitation - the case is preserved but the structure is split
|
|
assert target.chat_id == "!RoomABC"
|
|
assert target.thread_id == "example.org"
|
|
|
|
def test_mixed_case_chat_id_roundtrip(self):
|
|
"""Mixed-case chat IDs should survive parse-to_string roundtrip."""
|
|
original = "telegram:ChatId123ABC"
|
|
target = DeliveryTarget.parse(original)
|
|
s = target.to_string()
|
|
reparsed = DeliveryTarget.parse(s)
|
|
assert reparsed.chat_id == "ChatId123ABC"
|
|
|
|
|
|
class TestPlatformNameCaseInsensitivity:
|
|
"""Test that platform names are case-insensitive."""
|
|
|
|
def test_uppercase_platform_name(self):
|
|
"""Platform names should be case-insensitive."""
|
|
target = DeliveryTarget.parse("TELEGRAM:12345")
|
|
assert target.platform == Platform.TELEGRAM
|
|
assert target.chat_id == "12345"
|
|
|
|
def test_mixed_case_platform_name(self):
|
|
"""Mixed-case platform names should work."""
|
|
target = DeliveryTarget.parse("TeleGram:12345")
|
|
assert target.platform == Platform.TELEGRAM
|
|
assert target.chat_id == "12345"
|
|
|
|
class _RelayDeliveryTransport:
|
|
"""Relay transport that advertises Slack and records outbound wire frames."""
|
|
|
|
def __init__(self):
|
|
self._identities = [("slack", "bot-1")]
|
|
self.sent = []
|
|
|
|
async def send_outbound(self, action, *, platform=None):
|
|
self.sent.append((action, platform))
|
|
if not action.get("metadata", {}).get("user_id"):
|
|
return {"success": False, "error": "target not routed to an onboarded tenant"}
|
|
return {"success": True, "message_id": "relay-message-1"}
|
|
|
|
|
|
def _make_relay(transport):
|
|
return RelayAdapter(
|
|
PlatformConfig(enabled=True),
|
|
CapabilityDescriptor(
|
|
contract_version=CONTRACT_VERSION,
|
|
platform="slack",
|
|
label="Slack",
|
|
max_message_length=4000,
|
|
supports_draft_streaming=False,
|
|
supports_edit=True,
|
|
supports_threads=True,
|
|
markdown_dialect="slack",
|
|
len_unit="chars",
|
|
),
|
|
transport=cast(Any, transport),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_relay_fronted_target_delivers_without_prior_inbound_chat_state(tmp_path, monkeypatch):
|
|
"""A persisted Slack home must work immediately after a gateway restart."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
transport = _RelayDeliveryTransport()
|
|
relay = _make_relay(transport)
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.RELAY: PlatformConfig(enabled=True),
|
|
Platform.SLACK: PlatformConfig(
|
|
enabled=False,
|
|
home_channel=HomeChannel(
|
|
platform=Platform.SLACK,
|
|
chat_id="D123",
|
|
name="Owner DM",
|
|
user_id="U123",
|
|
),
|
|
),
|
|
},
|
|
)
|
|
router = DeliveryRouter(config, adapters={Platform.RELAY: relay})
|
|
|
|
result = await router._deliver_to_platform(
|
|
DeliveryTarget(platform=Platform.SLACK, chat_id="D123"),
|
|
"scheduled result",
|
|
metadata={"job_id": "cron-1", "user_id": "stale-user"},
|
|
)
|
|
|
|
assert getattr(result, "success", False) is True
|
|
assert len(transport.sent) == 1
|
|
action, wire_platform = transport.sent[0]
|
|
assert wire_platform == "slack"
|
|
assert action["chat_id"] == "D123"
|
|
assert action["metadata"] == {"job_id": "cron-1", "user_id": "U123"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_relay_media_fallback_retains_explicit_platform_and_owner():
|
|
"""Attachment fallback cannot default to another Relay identity after restart."""
|
|
transport = _RelayDeliveryTransport()
|
|
transport._identities = [("discord", "discord-bot"), ("slack", "slack-bot")]
|
|
relay = _make_relay(transport)
|
|
|
|
result = await relay.send_document(
|
|
chat_id="D123",
|
|
file_path="/tmp/report.pdf",
|
|
metadata={
|
|
"_relay_logical_platform": "slack",
|
|
"user_id": "U123",
|
|
},
|
|
)
|
|
|
|
assert result.success is True
|
|
assert len(transport.sent) == 1
|
|
action, wire_platform = transport.sent[0]
|
|
assert wire_platform == "slack"
|
|
assert action["metadata"] == {"user_id": "U123"}
|
|
assert "_relay_logical_platform" not in action["metadata"]
|
|
|
|
|
|
class RecordingAdapter:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.ensure_dm_topic_calls = []
|
|
|
|
async def send(self, chat_id, content, metadata=None):
|
|
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
|
return {"success": True}
|
|
|
|
async def ensure_dm_topic(self, chat_id, topic_name, force_create=False):
|
|
self.ensure_dm_topic_calls.append(
|
|
{"chat_id": chat_id, "topic_name": topic_name, "force_create": force_create}
|
|
)
|
|
return "38049"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_adapter_wins_when_relay_also_fronts_platform(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
native = RecordingAdapter()
|
|
transport = _RelayDeliveryTransport()
|
|
relay = _make_relay(transport)
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.SLACK: PlatformConfig(enabled=True),
|
|
Platform.RELAY: PlatformConfig(enabled=True),
|
|
},
|
|
)
|
|
router = DeliveryRouter(
|
|
config,
|
|
adapters={Platform.SLACK: native, Platform.RELAY: relay},
|
|
)
|
|
|
|
await router._deliver_to_platform(
|
|
DeliveryTarget(platform=Platform.SLACK, chat_id="D123"),
|
|
"native result",
|
|
metadata=None,
|
|
)
|
|
|
|
assert native.calls == [
|
|
{"chat_id": "D123", "content": "native result", "metadata": None}
|
|
]
|
|
assert transport.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disabled_native_adapter_does_not_shadow_relay(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
native = RecordingAdapter()
|
|
transport = _RelayDeliveryTransport()
|
|
relay = _make_relay(transport)
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.SLACK: PlatformConfig(
|
|
enabled=False,
|
|
home_channel=HomeChannel(
|
|
platform=Platform.SLACK,
|
|
chat_id="D123",
|
|
name="Owner DM",
|
|
user_id="U123",
|
|
),
|
|
),
|
|
Platform.RELAY: PlatformConfig(enabled=True),
|
|
},
|
|
)
|
|
router = DeliveryRouter(
|
|
config,
|
|
adapters={Platform.SLACK: native, Platform.RELAY: relay},
|
|
)
|
|
|
|
await router._deliver_to_platform(
|
|
DeliveryTarget(platform=Platform.SLACK, chat_id="D123"),
|
|
"relay result",
|
|
metadata=None,
|
|
)
|
|
|
|
assert native.calls == []
|
|
assert len(transport.sent) == 1
|
|
assert transport.sent[0][1] == "slack"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_relay_does_not_claim_unadvertised_platform(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
transport = _RelayDeliveryTransport()
|
|
transport._identities = [("discord", "bot-1")]
|
|
relay = _make_relay(transport)
|
|
config = GatewayConfig(
|
|
platforms={Platform.RELAY: PlatformConfig(enabled=True)},
|
|
)
|
|
router = DeliveryRouter(config, adapters={Platform.RELAY: relay})
|
|
|
|
with pytest.raises(ValueError, match="No adapter configured for slack"):
|
|
await router._deliver_to_platform(
|
|
DeliveryTarget(platform=Platform.SLACK, chat_id="D123"),
|
|
"must not route",
|
|
metadata=None,
|
|
)
|
|
|
|
assert transport.sent == []
|
|
|
|
|
|
class StaleTopicAdapter:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.ensure_dm_topic_calls = []
|
|
|
|
async def send(self, chat_id, content, metadata=None):
|
|
self.calls.append({"chat_id": chat_id, "content": content, "metadata": dict(metadata or {})})
|
|
if len(self.calls) == 1:
|
|
return SendResult(success=False, error="Bad Request: message thread not found")
|
|
return SendResult(success=True, message_id="fresh-message")
|
|
|
|
async def ensure_dm_topic(self, chat_id, topic_name, force_create=False):
|
|
self.ensure_dm_topic_calls.append(
|
|
{"chat_id": chat_id, "topic_name": topic_name, "force_create": force_create}
|
|
)
|
|
return "38064" if force_create else "32343"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_telegram_private_thread_requires_reply_anchor(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = RecordingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:722341991:32344")
|
|
|
|
with pytest.raises(RuntimeError, match="requires telegram_reply_to_message_id"):
|
|
await router._deliver_to_platform(target, "hello", metadata=None)
|
|
|
|
assert adapter.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_named_telegram_private_topic_is_created_before_delivery(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = RecordingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:722341991:Hermes API Test")
|
|
|
|
await router._deliver_to_platform(target, "hello", metadata=None)
|
|
|
|
assert adapter.ensure_dm_topic_calls == [
|
|
{"chat_id": "722341991", "topic_name": "Hermes API Test", "force_create": False}
|
|
]
|
|
assert adapter.calls == [
|
|
{
|
|
"chat_id": "722341991",
|
|
"content": "hello",
|
|
"metadata": {
|
|
"thread_id": "38049",
|
|
"telegram_dm_topic_created_for_send": True,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_named_telegram_private_topic_refreshes_stale_thread_id(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = StaleTopicAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:722341991:Personal")
|
|
|
|
result = await router._deliver_to_platform(target, "hello", metadata=None)
|
|
|
|
assert getattr(result, "message_id", None) == "fresh-message"
|
|
assert adapter.ensure_dm_topic_calls == [
|
|
{"chat_id": "722341991", "topic_name": "Personal", "force_create": False},
|
|
{"chat_id": "722341991", "topic_name": "Personal", "force_create": True},
|
|
]
|
|
assert [call["metadata"]["thread_id"] for call in adapter.calls] == ["32343", "38064"]
|
|
assert all(call["metadata"]["telegram_dm_topic_created_for_send"] is True for call in adapter.calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_telegram_private_thread_uses_reply_fallback_with_anchor(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = RecordingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:722341991:32344")
|
|
|
|
await router._deliver_to_platform(
|
|
target,
|
|
"hello",
|
|
metadata={"telegram_reply_to_message_id": "9001"},
|
|
)
|
|
|
|
assert adapter.calls == [
|
|
{
|
|
"chat_id": "722341991",
|
|
"content": "hello",
|
|
"metadata": {
|
|
"telegram_reply_to_message_id": "9001",
|
|
"thread_id": "32344",
|
|
"telegram_dm_topic_reply_fallback": True,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_telegram_direct_messages_topic_metadata_is_respected(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = RecordingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:722341991:32344")
|
|
|
|
await router._deliver_to_platform(
|
|
target,
|
|
"hello",
|
|
metadata={"telegram_direct_messages_topic_id": "32344"},
|
|
)
|
|
|
|
assert adapter.calls[0]["metadata"] == {"telegram_direct_messages_topic_id": "32344"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_telegram_group_thread_does_not_mark_dm_fallback(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = RecordingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: adapter})
|
|
target = DeliveryTarget.parse("telegram:-100123:42")
|
|
|
|
await router._deliver_to_platform(target, "hello", metadata=None)
|
|
|
|
assert adapter.calls[0]["metadata"] == {"thread_id": "42"}
|
|
|
|
|
|
class FailingAdapter:
|
|
async def send(self, chat_id, content, metadata=None):
|
|
return SendResult(success=False, error="route failed", retryable=False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_send_failure_raises_for_delivery_result(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.TELEGRAM: FailingAdapter()})
|
|
target = DeliveryTarget.parse("telegram:722341991:32344")
|
|
|
|
with pytest.raises(RuntimeError, match="route failed"):
|
|
await router._deliver_to_platform(target, "hello", metadata={"telegram_reply_to_message_id": "9001"})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cron output truncation / adapter-aware chunking (issue #50126)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ChunkingAdapter:
|
|
"""Adapter that declares splits_long_messages=True (like Discord/Telegram)."""
|
|
splits_long_messages = True
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def send(self, chat_id, content, metadata=None):
|
|
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
|
return {"success": True}
|
|
|
|
|
|
class NonChunkingAdapter:
|
|
"""Adapter without splits_long_messages (default False — legacy behavior)."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
async def send(self, chat_id, content, metadata=None):
|
|
self.calls.append({"chat_id": chat_id, "content": content, "metadata": metadata})
|
|
return {"success": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_output_truncated_for_non_chunking_adapter(tmp_path, monkeypatch):
|
|
"""Non-chunking adapters receive truncated content with a footer + file save."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = NonChunkingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
|
target = DeliveryTarget.parse("discord:123")
|
|
|
|
long_content = "x" * 5000
|
|
await router._deliver_to_platform(target, long_content, metadata={"job_id": "job1"})
|
|
|
|
delivered = adapter.calls[0]["content"]
|
|
assert len(delivered) < 5000 # was truncated
|
|
assert "truncated" in delivered.lower()
|
|
assert "full output saved to" in delivered
|
|
# Full output was saved to disk
|
|
saved_files = list(tmp_path.glob("cron/output/job1_*.txt"))
|
|
assert len(saved_files) == 1
|
|
assert saved_files[0].read_text() == long_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_output_preserved_for_chunking_adapter(tmp_path, monkeypatch):
|
|
"""Chunking adapters (splits_long_messages=True) receive the FULL content."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = ChunkingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
|
target = DeliveryTarget.parse("discord:123")
|
|
|
|
long_content = "x" * 5000
|
|
await router._deliver_to_platform(target, long_content, metadata={"job_id": "job2"})
|
|
|
|
delivered = adapter.calls[0]["content"]
|
|
assert delivered == long_content # NOT truncated — adapter handles chunking
|
|
assert "truncated" not in delivered.lower()
|
|
# Full output still saved to disk as audit trail
|
|
saved_files = list(tmp_path.glob("cron/output/job2_*.txt"))
|
|
assert len(saved_files) == 1
|
|
assert saved_files[0].read_text() == long_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_output_never_truncated(tmp_path, monkeypatch):
|
|
"""Output under the limit passes through untouched for any adapter."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
adapter = NonChunkingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
|
target = DeliveryTarget.parse("discord:123")
|
|
|
|
short_content = "x" * 100
|
|
await router._deliver_to_platform(target, short_content, metadata={"job_id": "job3"})
|
|
|
|
assert adapter.calls[0]["content"] == short_content
|
|
# Nothing saved to disk
|
|
assert not list(tmp_path.glob("cron/output/*.txt"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audit_save_failure_does_not_break_chunking_delivery(tmp_path, monkeypatch):
|
|
"""If the audit save fails (disk full, permissions), chunking adapters
|
|
still receive the full content — the save is best-effort."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
|
|
adapter = ChunkingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
|
target = DeliveryTarget.parse("discord:123")
|
|
|
|
long_content = "x" * 5000
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def failing_save(content, job_id):
|
|
call_count["n"] += 1
|
|
raise OSError("No space left on device")
|
|
|
|
monkeypatch.setattr(router, "_save_full_output", failing_save)
|
|
|
|
# Should NOT raise — audit failure is caught for chunking adapters
|
|
await router._deliver_to_platform(target, long_content, metadata={"job_id": "job6"})
|
|
|
|
# Adapter still got the full content
|
|
assert adapter.calls[0]["content"] == long_content
|
|
# Save was attempted (best-effort, swallowed)
|
|
assert call_count["n"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_failure_during_truncation_raises_for_non_chunking_adapter(tmp_path, monkeypatch):
|
|
"""For a non-chunking adapter, the truncation footer needs a valid saved
|
|
path. If the save fails there, that is a real delivery problem and the
|
|
error propagates (not swallowed like the chunking best-effort save)."""
|
|
monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
|
|
|
|
adapter = NonChunkingAdapter()
|
|
router = DeliveryRouter(GatewayConfig(), adapters={Platform.DISCORD: adapter})
|
|
target = DeliveryTarget.parse("discord:123")
|
|
|
|
long_content = "x" * 5000
|
|
|
|
def failing_save(content, job_id):
|
|
raise OSError("No space left on device")
|
|
|
|
monkeypatch.setattr(router, "_save_full_output", failing_save)
|
|
|
|
# Non-chunking adapter must truncate → needs a valid saved path → the
|
|
# Step 1 best-effort catch swallows the first attempt, but the Step 2
|
|
# retry (footer needs the path) re-raises.
|
|
with pytest.raises(OSError, match="No space left on device"):
|
|
await router._deliver_to_platform(target, long_content, metadata={"job_id": "job7"})
|
|
|
|
|