Files
hermes-agent/tests/plugins/platforms/photon/test_threaded_replies.py
2026-10-08 17:22:54 +02:00

280 lines
11 KiB
Python

"""Inbound iMessage threaded (swipe) replies for PhotonAdapter (#100663).
spectrum-ts 12.x wraps a threaded reply as ``{type: "reply", content, target}``. The
sidecar normalises it to ``{type: "reply", content, targetMessageId, targetDirection,
targetText}``; the adapter must unwrap it instead of emitting
"[Photon content type not handled: reply]", and carry the quoted message as reply
context. spectrum usually can't hydrate the text of our own outbound bubbles, so the
adapter records what it sends in ``gateway.rich_sent_store`` and falls back to that.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Tuple
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.event import MessageEvent, MessageType
from plugins.platforms.photon import adapter as photon_adapter
from plugins.platforms.photon.adapter import PhotonAdapter
PHONE = "+15550001234"
DM = f"any;-;{PHONE}"
@pytest.fixture(autouse=True)
def _isolated_sent_index(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
# rich_sent_store writes under HERMES_HOME/state; keep each test's index private.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
def _make_adapter(monkeypatch: pytest.MonkeyPatch, **extra: Any) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
return PhotonAdapter(PlatformConfig(enabled=True, token="", extra=dict(extra)))
def _capture_handled(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> list[MessageEvent]:
captured: list[MessageEvent] = []
async def fake_handle(event: MessageEvent) -> None:
captured.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
return captured
def _capture_sidecar(adapter: PhotonAdapter, message_id: str = "out-1") -> list[tuple[str, dict[str, Any]]]:
calls: list[tuple[str, dict[str, Any]]] = []
async def fake_call(path: str, body: dict[str, Any]) -> dict[str, Any]:
calls.append((path, body))
return {"ok": True, "messageId": message_id}
adapter._sidecar_call = fake_call # type: ignore[assignment]
return calls
def _reply_event(inner: dict[str, Any], *, message_id: str = "in-2", target_id: str = "out-0",
direction: str | None = "outbound", target_text: str | None = "earlier answer") -> dict[str, Any]:
return {
"messageId": message_id,
"space": {"id": DM, "type": "dm", "phone": PHONE},
"sender": {"id": PHONE},
"timestamp": "2026-09-25T18:00:00Z",
"content": {"type": "reply", "content": inner, "targetMessageId": target_id,
"targetDirection": direction, "targetText": target_text},
}
@pytest.mark.asyncio
async def test_threaded_text_reply_reaches_agent_with_context(monkeypatch):
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "yes do it"}))
assert len(handled) == 1
event = handled[0]
assert event.text == "yes do it"
assert event.reply_to_message_id == "out-0"
assert event.reply_to_text == "earlier answer"
assert event.reply_to_is_own_message is True
# 1x1 transparent PNG (passes the base adapter's image magic check).
_PNG_1X1_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
@pytest.mark.asyncio
async def test_threaded_photo_reply_keeps_the_photo(monkeypatch):
"""The inner content goes through the normal ladder, so a threaded photo is not reduced to text."""
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
inner = {"type": "attachment", "name": "pic.png", "mimeType": "image/png", "size": 68,
"data": _PNG_1X1_B64, "encoding": "base64"}
await adapter._dispatch_inbound(_reply_event(inner))
event = handled[0]
assert event.message_type == MessageType.PHOTO
assert event.media_types == ["image/png"]
assert len(event.media_urls) == 1
assert event.reply_to_message_id == "out-0"
@pytest.mark.asyncio
async def test_reply_to_users_own_message_is_not_marked_own(monkeypatch):
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "ctx"}, direction="inbound"))
assert handled[0].text == "ctx"
assert handled[0].reply_to_is_own_message is False
@pytest.mark.asyncio
async def test_malformed_reply_envelope_keeps_fallback_marker(monkeypatch):
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reply_event({"type": "unknown"}))
assert handled[0].text == "[Photon content type not handled: reply]"
@pytest.mark.asyncio
async def test_reply_to_our_message_hydrates_quoted_text_from_sent_index(monkeypatch):
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
calls = _capture_sidecar(adapter, message_id="out-1")
# We send, the user later swipe-replies to that bubble; spectrum gives no target text.
await adapter.send(DM, "the plan is A then B")
assert calls[-1][0] == "/send"
await adapter._dispatch_inbound(
_reply_event({"type": "text", "text": "do B first"}, target_id="out-1", target_text=None))
assert handled[-1].text == "do B first"
assert handled[-1].reply_to_message_id == "out-1"
assert handled[-1].reply_to_text == "the plan is A then B"
assert handled[-1].reply_to_is_own_message is True
@pytest.mark.asyncio
async def test_reply_after_restart_is_still_marked_own(monkeypatch):
"""Cloud targets carry no direction; after a restart the index hit alone proves the bubble was ours."""
sender = _make_adapter(monkeypatch)
_capture_sidecar(sender, message_id="out-9")
await sender.send(DM, "the plan is A then B")
restarted = _make_adapter(monkeypatch) # fresh in-memory _sent_message_ids
handled = _capture_handled(restarted, monkeypatch)
event = _reply_event({"type": "text", "text": "ok"}, target_id="out-9", direction=None, target_text=None)
await restarted._dispatch_inbound(event)
assert handled[-1].reply_to_text == "the plan is A then B"
assert handled[-1].reply_to_is_own_message is True
@pytest.mark.asyncio
async def test_send_to_bare_phone_is_found_from_dm_guid(monkeypatch):
"""Cron/standalone sends often target the bare E.164; replies arrive in the DM GUID space."""
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
_capture_sidecar(adapter, message_id="out-7")
await adapter.send(PHONE, "morning briefing")
await adapter._dispatch_inbound(
_reply_event({"type": "text", "text": "more on item 2"}, target_id="out-7", target_text=None))
assert handled[-1].reply_to_text == "morning briefing"
@pytest.mark.asyncio
async def test_attachment_send_is_recorded_as_label(monkeypatch, tmp_path):
monkeypatch.setattr(PhotonAdapter, "validate_media_delivery_path",
staticmethod(lambda p: p if os.path.exists(p) else None))
img = tmp_path / "chart.png"
img.write_bytes(b"\x89PNG fake")
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
_capture_sidecar(adapter, message_id="out-3")
await adapter.send_image_file(DM, str(img))
await adapter._dispatch_inbound(
_reply_event({"type": "text", "text": "what is this"}, target_id="out-3", target_text=None))
assert handled[-1].reply_to_text == "[attachment: chart.png]"
def _fake_standalone_sidecar(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stand-in for the sidecar HTTP API behind ``_standalone_send``: ids are ``cron-<n>``."""
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "tok")
counter = {"n": 0}
class _Resp:
status_code = 200
def __init__(self, message_id: str):
self._id = message_id
def json(self) -> dict[str, Any]:
return {"ok": True, "messageId": self._id}
class _FakeClient:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url: str, json: dict[str, Any], headers=None):
counter["n"] += 1
return _Resp(f"cron-{counter['n']}")
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
async def _reply_text_for(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, target_id: str) -> str | None:
handled = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(
_reply_event({"type": "text", "text": "about this"}, target_id=target_id, target_text=None))
assert handled[-1].text == "about this"
return handled[-1].reply_to_text
@pytest.mark.asyncio
async def test_standalone_send_records_text_and_attachments(monkeypatch, tmp_path):
monkeypatch.setattr(photon_adapter.BasePlatformAdapter, "validate_media_delivery_path",
staticmethod(lambda p: p if os.path.exists(p) else None))
chart = tmp_path / "chart.png"
chart.write_bytes(b"\x89PNG fake")
_fake_standalone_sidecar(monkeypatch)
cfg = PlatformConfig(enabled=True, token="", extra={})
result = await photon_adapter._standalone_send(cfg, PHONE, "daily digest", media_files=[(str(chart), False)])
assert result.get("success") is True
adapter = _make_adapter(monkeypatch)
assert await _reply_text_for(adapter, monkeypatch, "cron-1") == "daily digest"
assert await _reply_text_for(adapter, monkeypatch, "cron-2") == "[attachment: chart.png]"
@pytest.mark.asyncio
async def test_poll_clarify_is_recorded_as_its_question(monkeypatch):
adapter = _make_adapter(monkeypatch)
calls = _capture_sidecar(adapter, message_id="poll-1")
await adapter.send_clarify(DM, "Which plan?", ["A", "B"], "clarify-1", "session-1")
assert calls[-1][0] == "/send-poll"
assert await _reply_text_for(adapter, monkeypatch, "poll-1") == "Which plan?"
@pytest.mark.asyncio
async def test_plain_fallback_resend_is_recorded(monkeypatch):
adapter = _make_adapter(monkeypatch)
_capture_sidecar(adapter, message_id="fallback-1")
result = await adapter._send_plain_fallback(DM, "plain retry", reply_to=None, metadata=None)
assert result.success
assert await _reply_text_for(adapter, monkeypatch, "fallback-1") == "plain retry"
@pytest.mark.asyncio
async def test_unknown_reply_target_leaves_text_empty(monkeypatch):
adapter = _make_adapter(monkeypatch)
handled = _capture_handled(adapter, monkeypatch)
await adapter._dispatch_inbound(_reply_event({"type": "text", "text": "?"}, target_text=None))
assert handled[-1].text == "?"
assert handled[-1].reply_to_text is None