fix(telegram): keep /loop and synthetic sends in the active DM topic
Fixes #87051
This commit is contained in:
@@ -1564,9 +1564,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
Messages topics can opt in with explicit ``direct_messages_topic_id``
|
||||
metadata. Hermes-created private-chat topic lanes are marked with
|
||||
``telegram_dm_topic_reply_fallback``. Live replies send the private
|
||||
topic thread id together with a reply anchor; synthetic/resumed sends
|
||||
without an anchor use ``direct_messages_topic_id`` when metadata has it.
|
||||
``message_thread_id`` alone can render outside the visible lane.
|
||||
topic thread id together with a reply anchor. Synthetic/resumed sends
|
||||
without an anchor (loop wakeups, background-process notifications,
|
||||
queued follow-ups after a gateway restart) prefer the Hermes topic's
|
||||
``message_thread_id`` so they stay in the active topic lane (#87051);
|
||||
``direct_messages_topic_id`` is only used when no topic thread
|
||||
resolves, since the native DM-topic id does not match the Hermes
|
||||
topic lane and can render the message in a different chat lane.
|
||||
|
||||
When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for
|
||||
DM topic fallback sends while preserving the ``message_thread_id`` so
|
||||
@@ -1578,6 +1582,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if reply_to_message_id is None:
|
||||
reply_to_message_id = cls._metadata_reply_to_message_id(metadata)
|
||||
if reply_to_message_id is None:
|
||||
# Anchor-less synthetic sends (loop wakeups, watch
|
||||
# notifications, restart-resumed follow-ups) must stay in the
|
||||
# active topic lane: prefer the Hermes topic thread id when it
|
||||
# resolves (#87051). Routing via direct_messages_topic_id here
|
||||
# sent these to a different lane than the topic the session
|
||||
# runs in.
|
||||
thread_message_id = cls._message_thread_id_for_send(thread_id)
|
||||
if thread_message_id is not None:
|
||||
return {"message_thread_id": thread_message_id}
|
||||
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
||||
if direct_topic_id is not None:
|
||||
return {
|
||||
|
||||
@@ -227,3 +227,105 @@ class TestDMTopicFallbackReplyToMode:
|
||||
call = adapter._bot.send_message.call_args_list[0]
|
||||
assert call.kwargs.get("reply_to_message_id") is None
|
||||
|
||||
|
||||
class TestDMTopicSyntheticSendRouting:
|
||||
"""Anchor-less synthetic sends must stay in the active DM topic lane.
|
||||
|
||||
Regression tests for https://github.com/NousResearch/hermes-agent/issues/87051:
|
||||
after a gateway restart, /loop wakeups and background-process notifications
|
||||
are injected as synthetic events with no reply anchor. The DM-topic
|
||||
fallback's no-anchor branch routed them via Telegram's native
|
||||
``direct_messages_topic_id`` (or dropped the thread entirely), landing the
|
||||
response in a different chat lane than the Hermes topic the session runs
|
||||
in. The no-anchor branch must prefer the Hermes topic's
|
||||
``message_thread_id`` whenever it resolves.
|
||||
"""
|
||||
|
||||
def test_no_anchor_prefers_hermes_topic_thread_id(self):
|
||||
"""Synthetic send (no anchor) keeps message_thread_id of the topic."""
|
||||
metadata = {
|
||||
"thread_id": "42",
|
||||
"telegram_dm_topic_reply_fallback": True,
|
||||
"direct_messages_topic_id": "20189",
|
||||
}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", "42", metadata, reply_to_message_id=None,
|
||||
)
|
||||
assert result == {"message_thread_id": 42}
|
||||
|
||||
def test_no_anchor_no_direct_topic_keeps_thread_id(self):
|
||||
"""No anchor and no native DM-topic id: still route to the topic."""
|
||||
metadata = {"thread_id": "42", "telegram_dm_topic_reply_fallback": True}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", "42", metadata, reply_to_message_id=None,
|
||||
)
|
||||
assert result == {"message_thread_id": 42}
|
||||
|
||||
def test_no_anchor_no_thread_falls_back_to_direct_topic_id(self):
|
||||
"""When no topic thread resolves, the native DM-topic route survives."""
|
||||
metadata = {
|
||||
"telegram_dm_topic_reply_fallback": True,
|
||||
"direct_messages_topic_id": "20189",
|
||||
}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", None, metadata, reply_to_message_id=None,
|
||||
)
|
||||
assert result == {
|
||||
"message_thread_id": None,
|
||||
"direct_messages_topic_id": 20189,
|
||||
}
|
||||
|
||||
def test_no_anchor_plain_dm_omits_thread_id(self):
|
||||
"""Genuinely thread-less DM sends must not grow a thread id."""
|
||||
metadata = {"telegram_dm_topic_reply_fallback": True}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", None, metadata, reply_to_message_id=None,
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
def test_no_anchor_general_topic_omits_thread_id(self):
|
||||
"""The forum General topic ('1') still maps to no thread id on send."""
|
||||
metadata = {
|
||||
"thread_id": "1",
|
||||
"telegram_dm_topic_reply_fallback": True,
|
||||
"direct_messages_topic_id": "20189",
|
||||
}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", "1", metadata, reply_to_message_id=None,
|
||||
)
|
||||
assert result == {
|
||||
"message_thread_id": None,
|
||||
"direct_messages_topic_id": 20189,
|
||||
}
|
||||
|
||||
def test_anchored_reply_unchanged(self):
|
||||
"""Live replies with an anchor keep the topic thread id (unchanged)."""
|
||||
metadata = {
|
||||
"thread_id": "42",
|
||||
"telegram_dm_topic_reply_fallback": True,
|
||||
"telegram_reply_to_message_id": "12345",
|
||||
}
|
||||
result = TelegramAdapter._thread_kwargs_for_send(
|
||||
"100", "42", metadata, reply_to_message_id=12345,
|
||||
)
|
||||
assert result == {"message_thread_id": 42}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_synthetic_loop_wakeup_lands_in_topic(self, adapter_factory):
|
||||
"""send() for an anchor-less synthetic event delivers in-topic."""
|
||||
adapter = adapter_factory()
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
|
||||
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1"]
|
||||
metadata = {
|
||||
"thread_id": "42",
|
||||
"telegram_dm_topic_reply_fallback": True,
|
||||
"direct_messages_topic_id": "20189",
|
||||
}
|
||||
|
||||
result = await adapter.send("12345", "loop wakeup reply", metadata=metadata)
|
||||
|
||||
assert result.success is True
|
||||
call = adapter._bot.send_message.call_args_list[0]
|
||||
assert call.kwargs.get("message_thread_id") == 42
|
||||
assert call.kwargs.get("direct_messages_topic_id") is None
|
||||
|
||||
Reference in New Issue
Block a user