diff --git a/gateway/session.py b/gateway/session.py index 2f74d4543..1778c2e43 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -321,25 +321,32 @@ def build_session_key(source: SessionSource) -> str: This is the single source of truth for session key construction. DM rules: - - WhatsApp DMs include chat_id (multi-user support). - - Other DMs include thread_id when present (e.g. Slack threaded DMs), - so each DM thread gets its own session while top-level DMs share one. - - Without thread_id or chat_id, all DMs share a single session. + - DMs include chat_id when present, so each private conversation is isolated. + - thread_id further differentiates threaded DMs within the same DM chat. + - Without chat_id, thread_id is used as a best-effort fallback. + - Without thread_id or chat_id, DMs share a single session. Group/channel rules: - - thread_id differentiates threads within a channel. - - Without thread_id, all messages in a channel share one session. + - chat_id identifies the parent group/channel. + - thread_id differentiates threads within that parent chat. + - Without identifiers, messages fall back to one session per platform/chat_type. """ platform = source.platform.value 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: 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" + 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: - 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}" + return f"agent:main:{platform}:{source.chat_type}:{source.thread_id}" + return f"agent:main:{platform}:{source.chat_type}" class SessionStore: diff --git a/tests/gateway/test_interrupt_key_match.py b/tests/gateway/test_interrupt_key_match.py index f129977d4..ece3878dd 100644 --- a/tests/gateway/test_interrupt_key_match.py +++ b/tests/gateway/test_interrupt_key_match.py @@ -50,11 +50,11 @@ class TestInterruptKeyConsistency: """Ensure adapter interrupt methods are queried with session_key, not chat_id.""" 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") session_key = build_session_key(source) 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): """Session key for a group chat includes prefix, unlike raw chat_id.""" diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 0737f18d5..cd0104ace 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -338,7 +338,7 @@ class TestSessionStoreRewriteTranscript: class TestWhatsAppDMSessionKeyConsistency: """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() def store(self, tmp_path): @@ -369,15 +369,24 @@ class TestWhatsAppDMSessionKeyConsistency: ) assert store._generate_session_key(source) == build_session_key(source) - def test_telegram_dm_omits_chat_id(self): - """Non-WhatsApp DMs should still omit chat_id (single owner DM).""" + def test_telegram_dm_includes_chat_id(self): + """Non-WhatsApp DMs should also include chat_id to separate users.""" source = SessionSource( platform=Platform.TELEGRAM, chat_id="99", chat_type="dm", ) 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): """Group/channel keys include chat_type and chat_id."""