Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions gateway/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,11 @@ async def _deliver_to_platform(
)

# Step 2 — truncation (only for non-chunking adapters).
if getattr(adapter, "splits_long_messages", False):
# Adapter chunks natively — deliver full payload.
if (
getattr(adapter, "splits_long_messages", False)
or getattr(adapter, "preserves_long_messages", False)
):
# Adapter chunks natively or its transport accepts the body directly.
if saved_path:
logger.info(
"Cron output preserved for chunking adapter (%d chars) — "
Expand Down
4 changes: 4 additions & 0 deletions plugins/platforms/email/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ class EmailAdapter(BasePlatformAdapter):
# design — after a full restart the usual mark-all-seen baseline applies.
_seen_uids_snapshot: Dict[str, set] = {}

# SMTP accepts report-sized plaintext bodies directly; unlike chat adapters,
# Email does not need to split the body to preserve it.
preserves_long_messages = True

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.EMAIL)

Expand Down
116 changes: 116 additions & 0 deletions tests/gateway/test_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,119 @@ def test_local_delivery_writes_non_ascii_on_windows_codepage(tmp_path, monkeypat
written = Path(result["path"]).read_text(encoding="utf-8")
assert "完了 ✅ café" in written
assert "日次レポート" in written
@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_long_output_reaches_email_adapter_without_truncation(tmp_path, monkeypatch):
"""DeliveryRouter passes a report-sized body unchanged to SMTP email."""
from plugins.platforms.email.adapter import EmailAdapter

monkeypatch.setattr("gateway.delivery.get_hermes_home", lambda: tmp_path)
adapter = EmailAdapter(PlatformConfig(enabled=True))
smtp_calls = []

def capture_smtp(to_addr, body, reply_to_msg_id=None):
smtp_calls.append((to_addr, body, reply_to_msg_id))
return "<test-message@example.com>"

# Keep EmailAdapter.send() real so this covers the executor handoff to the
# synchronous SMTP/MIME boundary rather than merely replacing the adapter.
monkeypatch.setattr(adapter, "_send_email", capture_smtp)
router = DeliveryRouter(GatewayConfig(), adapters={Platform.EMAIL: adapter})
target = DeliveryTarget.parse("email:ops@example.com")
long_content = "report-line\n" * 500

await router._deliver_to_platform(
target, long_content, metadata={"job_id": "email-job"}
)

assert smtp_calls == [("ops@example.com", long_content, None)]
assert "truncated" not in smtp_calls[0][1].lower()


@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"})
6 changes: 6 additions & 0 deletions tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ def _make_adapter(self):
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter

def test_email_adapter_preserves_long_cron_reports(self):
"""Cron live delivery must not apply the 4K chat truncation guard to email."""
adapter = self._make_adapter()
self.assertTrue(adapter.preserves_long_messages)
self.assertFalse(getattr(adapter, "splits_long_messages", False))

def test_self_message_filtered(self):
"""Messages from the agent's own address should be skipped."""
import asyncio
Expand Down