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:<platform>: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.
This commit is contained in:
Teknium 2026-06-07 22:07:07 -07:00 committed by GitHub
parent a77bc2c08d
commit 5408013369
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 0 deletions

View file

@ -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:<platform>: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"

View file

@ -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(