From 5408013369c06bd8fe7de3559764ee5bd85d6854 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:07:07 -0700 Subject: [PATCH] fix(gateway): isolate DM sessions on user_id when chat_id is absent (#41764) build_session_key collapsed every DM that arrived without a chat_id into one shared 'agent:main::dm' key. A single cached AIAgent then served multiple users' conversations, bleeding history across senders. DMs now fall back to the sender's user_id_alt/user_id (mirroring the group-path participant precedence and the telegram auth-path fallback) before the bare per-platform sink. Telegram's normal event path always sets chat_id, so this hardens the synthetic-source / non-standard-adapter paths that don't. --- gateway/session.py | 16 ++++++++++++ tests/gateway/test_session.py | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/gateway/session.py b/gateway/session.py index 4d3f4f42f94..4d1d26b6467 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -635,6 +635,22 @@ def build_session_key( if source.thread_id: return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}" return f"agent:main:{platform}:dm:{dm_chat_id}" + # No chat_id — fall back to the sender's own identifier before the + # bare per-platform sink. Without this, every DM from every user that + # arrives without a chat_id (non-standard adapters / synthetic sources) + # collapses into one shared "agent:main::dm" session, and a + # single cached agent ends up serving multiple people's conversations — + # cross-user history bleed. participant_id keeps DMs isolated per user. + dm_participant_id = source.user_id_alt or source.user_id + if dm_participant_id and source.platform == Platform.WHATSAPP: + dm_participant_id = ( + canonical_whatsapp_identifier(str(dm_participant_id)) + or dm_participant_id + ) + if dm_participant_id: + if source.thread_id: + return f"agent:main:{platform}:dm:{dm_participant_id}:{source.thread_id}" + return f"agent:main:{platform}:dm:{dm_participant_id}" if source.thread_id: return f"agent:main:{platform}:dm:{source.thread_id}" return f"agent:main:{platform}:dm" diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 6e2c39f7972..9b5fff64214 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -784,6 +784,53 @@ class TestWhatsAppSessionKeyConsistency: assert build_session_key(second) == "agent:main:telegram:dm:100" assert build_session_key(first) != build_session_key(second) + def test_dm_without_chat_id_falls_back_to_user_id(self): + """A DM source missing chat_id must isolate on the sender's user_id + rather than collapsing into the shared per-platform sink.""" + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="", + chat_type="dm", + user_id="jordan", + ) + assert build_session_key(source) == "agent:main:telegram:dm:jordan" + + def test_dm_without_chat_id_distinct_users_do_not_collide(self): + """Two different DM senders without chat_id must not share one + session (the cross-user history-bleed footgun).""" + first = SessionSource( + platform=Platform.TELEGRAM, chat_id="", chat_type="dm", user_id="jordan" + ) + second = SessionSource( + platform=Platform.TELEGRAM, chat_id="", chat_type="dm", user_id="dima" + ) + assert build_session_key(first) != build_session_key(second) + assert build_session_key(first) == "agent:main:telegram:dm:jordan" + assert build_session_key(second) == "agent:main:telegram:dm:dima" + + def test_dm_without_chat_id_prefers_user_id_alt(self): + """user_id_alt wins over user_id for the DM fallback, matching the + group-path participant precedence.""" + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="", + chat_type="dm", + user_id="primary", + user_id_alt="alt", + ) + assert build_session_key(source) == "agent:main:telegram:dm:alt" + + def test_dm_without_chat_id_or_user_id_falls_back_to_thread_then_sink(self): + """With neither chat_id nor user identifiers, thread_id is the next + discriminator; only a completely identifier-less DM hits the sink.""" + threaded = SessionSource( + platform=Platform.TELEGRAM, chat_id="", chat_type="dm", thread_id="7" + ) + assert build_session_key(threaded) == "agent:main:telegram:dm:7" + + bare = SessionSource(platform=Platform.TELEGRAM, chat_id="", chat_type="dm") + assert build_session_key(bare) == "agent:main:telegram:dm" + def test_discord_group_includes_chat_id(self): """Group/channel keys include chat_type and chat_id.""" source = SessionSource(