diff --git a/gateway/run.py b/gateway/run.py index bdc7122cbd0..0fbc776cbcb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6814,26 +6814,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew str(home.thread_id) if home.thread_id else None ) - # Determine chat_type for the destination source. If we created a - # thread, key the session_key as a thread (build_session_key sets - # thread sessions to user-shared by default, which is what we - # want — the synthetic turn and any later real-user message both - # land on the same key without needing a user_id). - if new_thread_id: + # Determine chat_type/user_id for the destination source. + # + # Telegram private-chat DM topics are represented differently from + # group/forum threads by the inbound adapter. A handoff-created topic + # in a positive Telegram chat_id must therefore use the same DM-topic + # source shape as the user's next real message; otherwise the synthetic + # handoff turn binds a generic `thread` session key while real replies + # arrive on a `dm` session key. + home_chat_id = str(home.chat_id) + is_telegram_private_chat = False + if platform == Platform.TELEGRAM: + try: + is_telegram_private_chat = int(home_chat_id) > 0 + except (TypeError, ValueError): + is_telegram_private_chat = False + + if new_thread_id and not is_telegram_private_chat: dest_chat_type = "thread" + dest_user_id = "system:handoff" else: - # No thread — assume DM-style for the home channel. For - # group/channel home channels without thread support - # (Matrix/WhatsApp/Signal), the platform's own keying makes - # the synthetic turn shared anyway (single-DM platforms). + # No thread — assume DM-style for the home channel. For Telegram + # private-chat topics, use the real user id (same as chat_id) so + # topic-mode checks and binding persistence see the same identity as + # subsequent inbound user messages. dest_chat_type = "dm" + dest_user_id = home_chat_id if is_telegram_private_chat else "system:handoff" dest_source = SessionSource( platform=platform, - chat_id=str(home.chat_id), + chat_id=home_chat_id, chat_name=home.name, chat_type=dest_chat_type, - user_id="system:handoff", + user_id=dest_user_id, user_name="Handoff", thread_id=effective_thread_id, ) diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 37a769bf678..97ef78d4fdb 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from hermes_state import SessionDB -from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.session import SessionEntry, SessionSource, build_session_key @@ -800,6 +800,49 @@ async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkey +@pytest.mark.asyncio +async def test_handoff_to_telegram_dm_topic_uses_dm_lane_not_generic_thread(tmp_path): + """Handoff-created Telegram DM topics must use the real DM-topic lane. + + A positive Telegram chat_id is a private chat. If handoff treats the new + topic as generic chat_type="thread" with user_id="system:handoff", the + synthetic turn lands under agent:...:thread:chat:topic while real user + replies arrive as chat_type="dm" with user_id=chat_id. Recovery then sees + the topic as unbound and can rewrite it to another recent topic. + """ + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=session_db) + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="208214988", + name="Tester DM", + ) + adapter = runner.adapters[Platform.TELEGRAM] + adapter.create_handoff_thread = AsyncMock(return_value="17585") + adapter.send.return_value = SimpleNamespace(success=True) + captured = {} + + async def fake_handle_message(event): + captured["source"] = event.source + return "handoff ok" + + runner._handle_message = AsyncMock(side_effect=fake_handle_message) + + await runner._process_handoff({ + "id": "cli-session", + "title": "CLI work", + "handoff_platform": "telegram", + }) + + expected_source = _make_source(thread_id="17585") + expected_key = build_session_key(expected_source) + runner.session_store.switch_session.assert_called_once_with(expected_key, "cli-session") + assert captured["source"].chat_type == "dm" + assert captured["source"].user_id == "208214988" + assert captured["source"].thread_id == "17585" + + @pytest.mark.asyncio async def test_topic_root_command_creates_and_pins_system_topic(tmp_path, monkeypatch): import gateway.run as gateway_run