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.
This commit is contained in:
Teknium 2026-06-26 01:05:05 -07:00 committed by GitHub
parent 19b2624404
commit 619dc4a561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 139 additions and 1 deletions

View file

@ -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,
)

View file

@ -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"
)