Merge pull request #1417 from NousResearch/fix/1056-dm-session-isolation

fix(gateway): isolate DM sessions by chat_id
This commit is contained in:
Teknium 2026-03-15 03:22:39 -07:00 committed by GitHub
commit f6fdb18fe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 16 deletions

View file

@ -321,25 +321,32 @@ def build_session_key(source: SessionSource) -> str:
This is the single source of truth for session key construction. This is the single source of truth for session key construction.
DM rules: DM rules:
- WhatsApp DMs include chat_id (multi-user support). - DMs include chat_id when present, so each private conversation is isolated.
- Other DMs include thread_id when present (e.g. Slack threaded DMs), - thread_id further differentiates threaded DMs within the same DM chat.
so each DM thread gets its own session while top-level DMs share one. - Without chat_id, thread_id is used as a best-effort fallback.
- Without thread_id or chat_id, all DMs share a single session. - Without thread_id or chat_id, DMs share a single session.
Group/channel rules: Group/channel rules:
- thread_id differentiates threads within a channel. - chat_id identifies the parent group/channel.
- Without thread_id, all messages in a channel share one session. - thread_id differentiates threads within that parent chat.
- Without identifiers, messages fall back to one session per platform/chat_type.
""" """
platform = source.platform.value platform = source.platform.value
if source.chat_type == "dm": if source.chat_type == "dm":
if source.chat_id:
if source.thread_id:
return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{source.chat_id}"
if source.thread_id: if source.thread_id:
return f"agent:main:{platform}:dm:{source.thread_id}" return f"agent:main:{platform}:dm:{source.thread_id}"
if platform == "whatsapp" and source.chat_id:
return f"agent:main:{platform}:dm:{source.chat_id}"
return f"agent:main:{platform}:dm" return f"agent:main:{platform}:dm"
if source.chat_id:
if source.thread_id:
return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}:{source.thread_id}"
return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}"
if source.thread_id: if source.thread_id:
return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}:{source.thread_id}" return f"agent:main:{platform}:{source.chat_type}:{source.thread_id}"
return f"agent:main:{platform}:{source.chat_type}:{source.chat_id}" return f"agent:main:{platform}:{source.chat_type}"
class SessionStore: class SessionStore:

View file

@ -50,11 +50,11 @@ class TestInterruptKeyConsistency:
"""Ensure adapter interrupt methods are queried with session_key, not chat_id.""" """Ensure adapter interrupt methods are queried with session_key, not chat_id."""
def test_session_key_differs_from_chat_id_for_dm(self): def test_session_key_differs_from_chat_id_for_dm(self):
"""Session key for a DM is NOT the same as chat_id.""" """Session key for a DM is namespaced and includes the DM chat_id."""
source = _source("123456", "dm") source = _source("123456", "dm")
session_key = build_session_key(source) session_key = build_session_key(source)
assert session_key != source.chat_id assert session_key != source.chat_id
assert session_key == "agent:main:telegram:dm" assert session_key == "agent:main:telegram:dm:123456"
def test_session_key_differs_from_chat_id_for_group(self): def test_session_key_differs_from_chat_id_for_group(self):
"""Session key for a group chat includes prefix, unlike raw chat_id.""" """Session key for a group chat includes prefix, unlike raw chat_id."""

View file

@ -338,7 +338,7 @@ class TestSessionStoreRewriteTranscript:
class TestWhatsAppDMSessionKeyConsistency: class TestWhatsAppDMSessionKeyConsistency:
"""Regression: all session-key construction must go through build_session_key """Regression: all session-key construction must go through build_session_key
so WhatsApp DMs include chat_id while other DMs do not.""" so DMs are isolated by chat_id across platforms."""
@pytest.fixture() @pytest.fixture()
def store(self, tmp_path): def store(self, tmp_path):
@ -369,15 +369,24 @@ class TestWhatsAppDMSessionKeyConsistency:
) )
assert store._generate_session_key(source) == build_session_key(source) assert store._generate_session_key(source) == build_session_key(source)
def test_telegram_dm_omits_chat_id(self): def test_telegram_dm_includes_chat_id(self):
"""Non-WhatsApp DMs should still omit chat_id (single owner DM).""" """Non-WhatsApp DMs should also include chat_id to separate users."""
source = SessionSource( source = SessionSource(
platform=Platform.TELEGRAM, platform=Platform.TELEGRAM,
chat_id="99", chat_id="99",
chat_type="dm", chat_type="dm",
) )
key = build_session_key(source) key = build_session_key(source)
assert key == "agent:main:telegram:dm" assert key == "agent:main:telegram:dm:99"
def test_distinct_dm_chat_ids_get_distinct_session_keys(self):
"""Different DM chats must not collapse into one shared session."""
first = SessionSource(platform=Platform.TELEGRAM, chat_id="99", chat_type="dm")
second = SessionSource(platform=Platform.TELEGRAM, chat_id="100", chat_type="dm")
assert build_session_key(first) == "agent:main:telegram:dm:99"
assert build_session_key(second) == "agent:main:telegram:dm:100"
assert build_session_key(first) != build_session_key(second)
def test_discord_group_includes_chat_id(self): def test_discord_group_includes_chat_id(self):
"""Group/channel keys include chat_type and chat_id.""" """Group/channel keys include chat_type and chat_id."""