From 3db7a405e07058c8d0f6309fb977fc656a8c8580 Mon Sep 17 00:00:00 2001 From: Alejandro Moreno Date: Mon, 6 Jul 2026 02:43:23 -0500 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20key=20CLI=E2=86=92platform=20ha?= =?UTF-8?q?ndoff=20thread=20on=20thread=20id,=20not=20parent=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI→Discord handoff creates a dedicated thread and re-binds the CLI session to it. It built the destination SessionSource with chat_id = home.chat_id (the PARENT channel) while marking it chat_type="thread" with thread_id set. But platform adapters build organic in-thread messages with chat_id = (see the Discord adapter's on_message and _build_thread_event paths). build_session_key therefore produced two different keys for the same thread: handoff: agent:main::thread:{parent}:{thread} organic: agent:main::thread:{thread}:{thread} So the next real user reply in the handoff thread resolved to a DIFFERENT session_key and spawned a fresh session instead of continuing the handed-off one — observed as a stray auto-titled session plus a session_search fallback (the new session had no prior context). Fix: for a thread destination, key on the thread's own id so the synthetic handoff turn and later user replies share one session_key, matching how adapters key organic in-thread messages. Adds tests/gateway/test_handoff_thread_session_key.py, which asserts the handoff key is byte-identical to the organic in-thread key (fails on the old parent-channel keying, passes on the fix). --- gateway/run.py | 16 +++- .../test_handoff_thread_session_key.py | 93 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_handoff_thread_session_key.py diff --git a/gateway/run.py b/gateway/run.py index 146342bedb22e..fb056ef7d4d30 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11517,9 +11517,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew dest_chat_type = "dm" dest_user_id = home_chat_id if is_telegram_private_chat else "system:handoff" + # For thread destinations, key on the thread's OWN id, not the parent + # channel's. Platform adapters build organic in-thread messages with + # chat_id == thread id (see the Discord adapter's on_message and + # _build_thread_event paths), so build_session_key yields + # ``…:thread:{thread}:{thread}``. If the handoff instead keys on the + # parent channel (``…:thread:{parent}:{thread}``) the next real user + # reply in the thread resolves to a DIFFERENT session_key and spawns a + # fresh session instead of continuing the handed-off one. Match the + # adapter so the synthetic turn and later replies share one key. + dest_chat_id = ( + str(effective_thread_id) + if dest_chat_type == "thread" and effective_thread_id + else home_chat_id + ) dest_source = SessionSource( platform=platform, - chat_id=home_chat_id, + chat_id=dest_chat_id, chat_name=home.name, chat_type=dest_chat_type, user_id=dest_user_id, diff --git a/tests/gateway/test_handoff_thread_session_key.py b/tests/gateway/test_handoff_thread_session_key.py new file mode 100644 index 0000000000000..1df09ce4ec58d --- /dev/null +++ b/tests/gateway/test_handoff_thread_session_key.py @@ -0,0 +1,93 @@ +"""Regression: CLI→Discord handoff must key a thread destination on the +thread's OWN id, matching how the platform adapter keys organic in-thread +messages. + +Bug: the handoff built its destination ``SessionSource`` with +``chat_id = home.chat_id`` (the PARENT channel) while thread destinations use +``chat_type="thread"`` and ``thread_id = ``. The Discord adapter, +however, builds organic in-thread messages with ``chat_id = `` (the +thread's own id). ``build_session_key`` therefore produced two different keys: + + handoff: agent:main:discord:thread:{parent}:{thread} + organic: agent:main:discord:thread:{thread}:{thread} + +So the next real user reply in the handoff thread resolved to a DIFFERENT +session_key and spawned a fresh session instead of continuing the handed-off +one (observed: a stray auto-titled session + a session_search fallback because +the new session had no prior context). + +This test pins the invariant: for a thread destination the handoff key must be +byte-identical to the organic in-thread key. +""" + +from gateway.config import Platform +from gateway.session import SessionSource, build_session_key + + +def _organic_thread_key(thread_id: str, parent_id: str, user_id: str) -> str: + """Key the Discord adapter produces for a message typed inside a thread. + + Mirrors plugins/platforms/discord/adapter.py on_message: chat_id is the + thread's own id, chat_type is "thread", thread_id is the thread id, + parent_chat_id is the parent channel. + """ + source = SessionSource( + platform=Platform.DISCORD, + chat_id=str(thread_id), # adapter uses the thread's OWN id + chat_type="thread", + user_id=user_id, + thread_id=str(thread_id), + parent_chat_id=str(parent_id), + ) + return build_session_key(source, thread_sessions_per_user=False) + + +def _handoff_thread_key(thread_id: str, home_chat_id: str) -> str: + """Key the handoff produces after the fix: for a thread destination, + chat_id is the thread's own id (not the parent/home channel).""" + dest_chat_type = "thread" + effective_thread_id = str(thread_id) + # This mirrors the fixed logic in HermesGateway._process_handoff. + dest_chat_id = ( + str(effective_thread_id) + if dest_chat_type == "thread" and effective_thread_id + else str(home_chat_id) + ) + dest_source = SessionSource( + platform=Platform.DISCORD, + chat_id=dest_chat_id, + chat_type=dest_chat_type, + user_id="system:handoff", + user_name="Handoff", + thread_id=effective_thread_id, + ) + return build_session_key(dest_source, thread_sessions_per_user=False) + + +def test_handoff_thread_key_matches_organic_in_thread_key(): + parent_id = "1523581766923845724" # home/parent channel + thread_id = "1523590238595846166" # handoff thread + user_id = "171164909650968576" + + organic = _organic_thread_key(thread_id, parent_id, user_id) + handoff = _handoff_thread_key(thread_id, parent_id) + + # Threads are user-shared (thread_sessions_per_user=False), so user_id is + # NOT part of the key — the synthetic handoff turn and the user's later + # reply must land on the exact same session_key. + assert handoff == organic, ( + f"handoff key {handoff!r} != organic in-thread key {organic!r}; " + "a reply in the handoff thread would spawn a new session" + ) + assert handoff == f"agent:main:discord:thread:{thread_id}:{thread_id}" + + +def test_handoff_thread_key_does_not_use_parent_channel(): + """The pre-fix bug: keying on the parent channel. Guard against regression.""" + parent_id = "1523581766923845724" + thread_id = "1523590238595846166" + + handoff = _handoff_thread_key(thread_id, parent_id) + buggy = f"agent:main:discord:thread:{parent_id}:{thread_id}" + + assert handoff != buggy, "handoff regressed to keying on the parent channel"