From 619dc4a5610f152ba309dd157c7fc8868187c536 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:05:05 -0700 Subject: [PATCH] fix(whatsapp_cloud): resolve reply-to text so the agent sees reply context (#52957) Replies on WhatsApp Cloud arrived at the agent with reply_to_id set but reply_to_text=None, so run.py never injected the "[Replying to: ...]" disambiguation prefix (it gates on reply_to_text). Meta's webhook context object carries only the quoted message's id, never its text. Index (chat_id, wamid) -> text in rich_sent_store on every inbound message and every outbound text send -- the same store that solved the identical Telegram rich-send problem -- then look up the quoted text in _build_message_event_from_cloud and populate reply_to_text plus reply_to_is_own_message, derived from context.from versus the business number. --- gateway/platforms/whatsapp_cloud.py | 36 +++++++++- tests/gateway/test_whatsapp_cloud.py | 104 +++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index c0bf600032c..bd5ac92b55f 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -79,6 +79,7 @@ from gateway.platforms.base import ( SUPPORTED_DOCUMENT_TYPES, ) from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin +from gateway import rich_sent_store from hermes_constants import get_hermes_dir logger = logging.getLogger(__name__) @@ -489,6 +490,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): except Exception: pass + # Remember (chat_id, wamid) -> text so that when the user replies to + # one of our messages, _build_message_event_from_cloud can resolve the + # quoted text. Meta's inbound webhook ``context`` object carries only + # the quoted message's id, never its text, so without this index the + # agent would never learn what the user was replying to. Best-effort; + # rich_sent_store swallows all errors. + if last_message_id: + rich_sent_store.record(chat_id, last_message_id, formatted) + return SendResult(success=True, message_id=last_message_id) # ------------------------------------------------------------------ typing indicator + read receipts @@ -1923,9 +1933,26 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): doc_path, ) - # context.id is set when the user replied to one of our messages. + # context.id is set when the user replied to a prior message. Meta's + # webhook only gives us the quoted message's id (and its author in + # context.from) — never the quoted text. We resolve the text from + # rich_sent_store, which we populate on every inbound message (below) + # and every outbound send. Without this the agent receives a bare + # reply_to_message_id and run.py can't inject the "[Replying to: ...]" + # disambiguation prefix (it gates on reply_to_text being present). context = raw_message.get("context") or {} reply_to_id = str(context.get("id") or "").strip() or None + reply_to_text: Optional[str] = None + reply_to_is_own = False + if reply_to_id: + reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id) + # context.from is the wa_id of the quoted message's author. When it + # matches our business number the user replied to the bot's own + # message; otherwise they replied to one of their own messages. + quoted_from = str(context.get("from") or "").strip() + our_number = str(metadata.get("display_phone_number") or "").strip() + if quoted_from and our_number: + reply_to_is_own = quoted_from == our_number source = self.build_source( chat_id=chat_id, @@ -1945,6 +1972,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # gating) so filtered messages don't leak typing on # unwanted inbound traffic. self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid) + # Index this message's text by wamid so a later reply to it can + # resolve the quoted text (Meta's webhook context carries only + # the id). Mirrors the outbound record in send(). Best-effort. + if body: + rich_sent_store.record(chat_id, wamid, body) return MessageEvent( text=body, @@ -1953,6 +1985,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): raw_message=raw_message, message_id=wamid, reply_to_message_id=reply_to_id, + reply_to_text=reply_to_text, + reply_to_is_own_message=reply_to_is_own, media_urls=media_urls, media_types=media_types, ) diff --git a/tests/gateway/test_whatsapp_cloud.py b/tests/gateway/test_whatsapp_cloud.py index 4db634e737a..8b28b509538 100644 --- a/tests/gateway/test_whatsapp_cloud.py +++ b/tests/gateway/test_whatsapp_cloud.py @@ -2319,3 +2319,107 @@ class TestMediaIdValidation: path, mime = await adapter._download_media_to_cache("../../etc/passwd") assert path is None and mime is None adapter._http_client.get.assert_not_called() + + +class TestReplyContextResolution: + """The Cloud webhook ``context`` object only carries the quoted message's + id (and author), never its text. We resolve the text from rich_sent_store, + which is populated on every inbound message and every outbound send. Without + a resolved ``reply_to_text`` run.py can't inject the disambiguation prefix, + so the agent never learns the message was a reply (the user-reported bug). + """ + + @pytest.mark.asyncio + async def test_reply_to_own_earlier_message_resolves_text(self): + """User replies to their own earlier message — its text was indexed + on the earlier inbound, so the reply resolves it.""" + adapter = _make_adapter() + # First inbound message gets recorded by wamid. + await adapter._build_message_event_from_cloud( + {"from": "15551234567", "id": "wamid.PRIOR", "type": "text", + "text": {"body": "remind me to buy milk"}}, + {"15551234567": "Alice"}, {}, + ) + # Now the user replies to that earlier message. + event = await adapter._build_message_event_from_cloud( + {"from": "15551234567", "id": "wamid.REPLY", "type": "text", + "text": {"body": "did you?"}, + "context": {"id": "wamid.PRIOR", "from": "15551234567"}}, + {"15551234567": "Alice"}, {}, + ) + assert event is not None + assert event.reply_to_message_id == "wamid.PRIOR" + assert event.reply_to_text == "remind me to buy milk" + assert event.reply_to_is_own_message is False # quoted author == the user + + @pytest.mark.asyncio + async def test_reply_to_bot_message_marks_own(self): + """User replies to one of the bot's messages — context.from matches the + business number, so reply_to_is_own_message is True and text resolves + from the outbound record made in send().""" + from gateway import rich_sent_store + + adapter = _make_adapter() + # Simulate the outbound record send() would have made. + rich_sent_store.record("15551234567", "wamid.BOT", "Sure, milk added.") + event = await adapter._build_message_event_from_cloud( + {"from": "15551234567", "id": "wamid.REPLY", "type": "text", + "text": {"body": "thanks"}, + "context": {"id": "wamid.BOT", "from": "15550009999"}}, + {"15551234567": "Alice"}, + {"display_phone_number": "15550009999"}, + ) + assert event is not None + assert event.reply_to_message_id == "wamid.BOT" + assert event.reply_to_text == "Sure, milk added." + assert event.reply_to_is_own_message is True + + @pytest.mark.asyncio + async def test_reply_to_unknown_message_id_no_text(self): + """Quoted message we never indexed (e.g. before gateway start) — id is + still surfaced, text is None, and we don't crash.""" + adapter = _make_adapter() + event = await adapter._build_message_event_from_cloud( + {"from": "15551234567", "id": "wamid.REPLY", "type": "text", + "text": {"body": "what about this"}, + "context": {"id": "wamid.GONE", "from": "15551234567"}}, + {"15551234567": "Alice"}, {}, + ) + assert event is not None + assert event.reply_to_message_id == "wamid.GONE" + assert event.reply_to_text is None + assert event.reply_to_is_own_message is False + + @pytest.mark.asyncio + async def test_non_reply_message_has_no_reply_context(self): + adapter = _make_adapter() + event = await adapter._build_message_event_from_cloud( + {"from": "15551234567", "id": "wamid.PLAIN", "type": "text", + "text": {"body": "hello"}}, + {"15551234567": "Alice"}, {}, + ) + assert event is not None + assert event.reply_to_message_id is None + assert event.reply_to_text is None + assert event.reply_to_is_own_message is False + + @pytest.mark.asyncio + async def test_send_records_outbound_text_by_wamid(self): + """send() must index its own wamid -> text so replies to the bot + resolve. Verify the round-trip through rich_sent_store.""" + from gateway import rich_sent_store + + adapter = _make_adapter() + adapter._http_client = MagicMock() + adapter._http_client.post = AsyncMock( + return_value=_mock_httpx_response( + 200, {"messages": [{"id": "wamid.OUT"}]} + ) + ) + result = await adapter.send("15551234567", "here is your answer") + assert result.success and result.message_id == "wamid.OUT" + assert ( + rich_sent_store.lookup("15551234567", "wamid.OUT") + == "here is your answer" + ) +