From 38cfae9b5429c7041f9f7668f309a1c992e8d50f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:22:35 +0530 Subject: [PATCH] fix(slack): scope Agent View workspace state --- plugins/platforms/slack/adapter.py | 138 ++++++++++++------- tests/gateway/test_slack.py | 122 +++++++++++++--- tests/gateway/test_slack_approval_buttons.py | 15 +- 3 files changed, 197 insertions(+), 78 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 9b55888be227..e6cd83e1af30 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -440,7 +440,10 @@ class SlackAdapter(BasePlatformAdapter): self._app: Optional[Any] = None self._handler: Optional[Any] = None self._bot_user_id: Optional[str] = None - self._user_name_cache: Dict[str, str] = {} # user_id → display name + # Slack user IDs are workspace-local. Cache names by workspace as well + # so a multi-workspace Socket Mode process never reuses another + # tenant's display name. + self._user_name_cache: Dict[Tuple[str, str], str] = {} self._socket_mode_task: Optional[asyncio.Task] = None # Multi-workspace support self._team_clients: Dict[str, Any] = {} # team_id → WebClient @@ -460,11 +463,10 @@ class SlackAdapter(BasePlatformAdapter): # respond to ALL subsequent messages in that thread automatically. self._mentioned_threads: set = set() self._MENTIONED_THREADS_MAX = 5000 - # Assistant thread metadata keyed by (channel_id, thread_ts). Slack's - # AI Assistant lifecycle events can arrive before/alongside message - # events, and they carry the user/thread identity needed for stable - # session + memory scoping. - self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {} + # Assistant thread metadata keyed by (team_id, channel_id, thread_ts). + # Slack's AI Assistant lifecycle events can arrive before/alongside + # message events, and carry identity needed for stable session scoping. + self._assistant_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {} self._ASSISTANT_THREADS_MAX = 5000 # Agent-view context is per workspace/user (not global): a context # change for one person's Slack split view must never appear in another @@ -477,11 +479,9 @@ class SlackAdapter(BasePlatformAdapter): self._THREAD_CACHE_TTL = 60.0 # Track message IDs that should get reaction lifecycle (DMs / @mentions). self._reacting_message_ids: set = set() - # Track active assistant thread status indicators so stop_typing can - # clear them ((chat_id, thread_ts) → {"thread_ts": str, "team_id": str}). - # Keeping team_id avoids clearing via the wrong WebClient in - # multi-workspace Socket Mode installs. - self._active_status_threads: Dict[Any, Dict[str, str]] = {} + # Track active Assistant statuses by (team_id, channel_id, thread_ts) + # so cleanup cannot clear an overlapping Slack Connect workspace. + self._active_status_threads: Dict[Tuple[str, str, str], Dict[str, str]] = {} # Best-effort guard so automatic Slack AI thread titles are set once # per visible DM thread instead of on every reply. self._titled_assistant_threads: set = set() @@ -1376,11 +1376,6 @@ class SlackAdapter(BasePlatformAdapter): or "" ) - @staticmethod - def _active_status_key(chat_id: str, thread_ts: str) -> Tuple[str, str]: - """Return the per-thread key for active Slack AI status tracking.""" - return (str(chat_id), str(thread_ts)) - def _get_client(self, chat_id: str, team_id: Optional[str] = None) -> Any: """Return the workspace-specific WebClient for a channel.""" if team_id and team_id in self._team_clients: @@ -1586,10 +1581,12 @@ class SlackAdapter(BasePlatformAdapter): if not team_id: team_id = self._channel_team.get(chat_id, "") - self._active_status_threads[self._active_status_key(chat_id, str(thread_ts))] = { - "thread_ts": str(thread_ts), - "team_id": str(team_id) if team_id else "", - } + status_key = self._workspace_thread_key(team_id, chat_id, str(thread_ts)) + if status_key: + self._active_status_threads[status_key] = { + "thread_ts": str(thread_ts), + "team_id": str(team_id) if team_id else "", + } try: await self._get_client(chat_id, team_id=team_id).assistant_threads_setStatus( channel_id=chat_id, @@ -1610,19 +1607,26 @@ class SlackAdapter(BasePlatformAdapter): requested_thread_ts = str( metadata.get("thread_id") or metadata.get("thread_ts") or "" ) - active_key = ( - self._active_status_key(chat_id, requested_thread_ts) - if requested_thread_ts - else chat_id - ) - active = self._active_status_threads.pop(active_key, None) - if active is None and requested_thread_ts: - active = self._active_status_threads.pop(chat_id, None) - if active is None and not requested_thread_ts: + requested_team_id = self._metadata_team_id(metadata) + if not requested_team_id: + requested_team_id = self._channel_team.get(chat_id, "") + active = None + if requested_thread_ts: + active_key = self._workspace_thread_key( + requested_team_id, chat_id, requested_thread_ts + ) + if active_key: + active = self._active_status_threads.pop(active_key, None) + else: + # Metadata-free cleanup is safe only if exactly one status exists + # for this channel; otherwise it may clear another Slack Connect + # workspace's Assistant status. matching_keys = [ key for key in self._active_status_threads - if isinstance(key, tuple) and key[0] == str(chat_id) + if isinstance(key, tuple) + and len(key) == 3 + and key[1] == str(chat_id) ] if len(matching_keys) == 1: active = self._active_status_threads.pop(matching_keys[0], None) @@ -2218,18 +2222,27 @@ class SlackAdapter(BasePlatformAdapter): # ----- User identity resolution ----- - async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str: - """Resolve a Slack user ID to a display name, with caching.""" + async def _resolve_user_name( + self, user_id: str, chat_id: str = "", team_id: str = "" + ) -> str: + """Resolve a workspace-local Slack user ID to a display name.""" if not user_id: return "" - if user_id in self._user_name_cache: - return self._user_name_cache[user_id] + team_id = str(team_id or self._channel_team.get(chat_id, "")) + cache_key = (team_id, str(user_id)) + cached_name = self._user_name_cache.get(cache_key) + if cached_name is not None: + return cached_name if not self._app: return user_id try: - client = self._get_client(chat_id) if chat_id else self._app.client + client = ( + self._get_client(chat_id, team_id=team_id or None) + if chat_id + else self._app.client + ) result = await client.users_info(user=user_id) user = result.get("user", {}) # Prefer display_name → real_name → user_id @@ -2241,11 +2254,11 @@ class SlackAdapter(BasePlatformAdapter): or user.get("name") or user_id ) - self._user_name_cache[user_id] = name + self._user_name_cache[cache_key] = name return name except Exception as e: logger.debug("[Slack] users.info failed for %s: %s", user_id, e) - self._user_name_cache[user_id] = user_id + self._user_name_cache[cache_key] = user_id return user_id async def send_image_file( @@ -2525,13 +2538,19 @@ class SlackAdapter(BasePlatformAdapter): # ----- Internal handlers ----- - def _assistant_thread_key( - self, channel_id: str, thread_ts: str - ) -> Optional[Tuple[str, str]]: - """Return a stable cache key for Slack assistant thread metadata.""" + @staticmethod + def _workspace_thread_key( + team_id: str, channel_id: str, thread_ts: str + ) -> Optional[Tuple[str, str, str]]: + """Return a workspace-scoped key for Slack thread-local state. + + Slack Connect can expose the same channel/thread IDs in more than one + workspace. Keys for cached thread data and Assistant status therefore + need the workspace identity as well as Slack's channel/thread pair. + """ if not channel_id or not thread_ts: return None - return (str(channel_id), str(thread_ts)) + return (str(team_id or ""), str(channel_id), str(thread_ts)) @staticmethod def _agent_view_context_key(team_id: str, user_id: str) -> Optional[Tuple[str, str]]: @@ -2665,10 +2684,11 @@ class SlackAdapter(BasePlatformAdapter): } def _cache_assistant_thread_metadata(self, metadata: Dict[str, str]) -> None: - """Remember assistant thread identity data for later message events.""" + """Remember workspace-local assistant identity for later message events.""" channel_id = metadata.get("channel_id", "") thread_ts = metadata.get("thread_ts", "") - key = self._assistant_thread_key(channel_id, thread_ts) + team_id = metadata.get("team_id", "") + key = self._workspace_thread_key(team_id, channel_id, thread_ts) if not key: return @@ -2677,30 +2697,35 @@ class SlackAdapter(BasePlatformAdapter): merged.update({k: v for k, v in metadata.items() if v}) self._assistant_threads[key] = merged - # Evict oldest entries when the cache exceeds the limit + # Evict oldest entries when the cache exceeds the limit. if len(self._assistant_threads) > self._ASSISTANT_THREADS_MAX: excess = len(self._assistant_threads) - self._ASSISTANT_THREADS_MAX // 2 for old_key in list(self._assistant_threads)[:excess]: del self._assistant_threads[old_key] - team_id = merged.get("team_id", "") if team_id and channel_id: self._channel_team[channel_id] = team_id def _lookup_assistant_thread_metadata( self, event: dict, + *, channel_id: str = "", thread_ts: str = "", + team_id: str = "", + body: Optional[dict] = None, ) -> Dict[str, str]: - """Load cached assistant-thread metadata that matches the current event.""" - metadata = self._extract_assistant_thread_metadata(event) + """Load workspace-scoped assistant metadata for the current event.""" + metadata = self._extract_assistant_thread_metadata(event, body) if channel_id and not metadata.get("channel_id"): metadata["channel_id"] = channel_id if thread_ts and not metadata.get("thread_ts"): metadata["thread_ts"] = thread_ts + if team_id and not metadata.get("team_id"): + metadata["team_id"] = str(team_id) - key = self._assistant_thread_key( + key = self._workspace_thread_key( + metadata.get("team_id", ""), metadata.get("channel_id", ""), metadata.get("thread_ts", ""), ) @@ -2799,7 +2824,7 @@ class SlackAdapter(BasePlatformAdapter): ): return - key = self._assistant_thread_key(channel_id, thread_ts) + key = self._workspace_thread_key(team_id, channel_id, thread_ts) if not key or key in self._titled_assistant_threads: return @@ -3192,15 +3217,18 @@ class SlackAdapter(BasePlatformAdapter): channel_id = event.get("channel", "") ts = event.get("ts", "") + outer_team_id = self._event_team_id(event, payload) assistant_meta = self._lookup_assistant_thread_metadata( event, channel_id=channel_id, thread_ts=event.get("thread_ts", ""), + team_id=outer_team_id, + body=payload, ) user_id = event.get("user") or assistant_meta.get("user_id", "") if not channel_id: channel_id = assistant_meta.get("channel_id", "") - team_id = self._event_team_id(event, payload) or assistant_meta.get("team_id", "") + team_id = outer_team_id or assistant_meta.get("team_id", "") agent_context = self._agent_view_context_for_event( event, str(team_id or ""), str(user_id or "") ) @@ -3613,7 +3641,9 @@ class SlackAdapter(BasePlatformAdapter): msg_type = MessageType.DOCUMENT # Resolve user display name (cached after first lookup) - user_name = await self._resolve_user_name(user_id, chat_id=channel_id) + user_name = await self._resolve_user_name( + user_id, chat_id=channel_id, team_id=team_id + ) # Slack's AI Agent Messages tab shows visible app threads; title the # first DM thread turn from the user's prompt when Slack AI APIs are @@ -4314,7 +4344,9 @@ class SlackAdapter(BasePlatformAdapter): # Prefer the bot's own name when the message is a bot post. if is_bot and not display_user: display_user = msg.get("username") or "bot" - name = await self._resolve_user_name(display_user, chat_id=channel_id) + name = await self._resolve_user_name( + display_user, chat_id=channel_id, team_id=team_id + ) # Mark senders not on the allowlist as [unverified] so the LLM # treats their content as background reference rather than diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 0c934074c250..c99ef5f6d17a 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -2097,7 +2097,7 @@ class TestSendTyping: thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_stop_typing_noop_without_tracked_thread(self, adapter): @@ -2109,7 +2109,10 @@ class TestSendTyping: @pytest.mark.asyncio async def test_stop_typing_handles_api_error_gracefully(self, adapter): - adapter._active_status_threads["C123"] = "parent_ts" + adapter._active_status_threads[("", "C123", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } adapter._app.client.assistant_threads_setStatus = AsyncMock( side_effect=Exception("missing_scope") ) @@ -2121,7 +2124,7 @@ class TestSendTyping: thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_send_clears_status_after_final_post(self, adapter): @@ -2129,7 +2132,10 @@ class TestSendTyping: return_value={"ts": "reply_ts"} ) adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._active_status_threads["C123"] = "parent_ts" + adapter._active_status_threads[("", "C123", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"}) @@ -2140,13 +2146,16 @@ class TestSendTyping: thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_streaming_final_edit_clears_status(self, adapter): adapter._app.client.chat_update = AsyncMock() adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._active_status_threads["C123"] = "parent_ts" + adapter._active_status_threads[("", "C123", "parent_ts")] = { + "thread_ts": "parent_ts", + "team_id": "", + } result = await adapter.edit_message( "C123", @@ -2166,13 +2175,13 @@ class TestSendTyping: thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads @pytest.mark.asyncio async def test_streaming_intermediate_edit_keeps_status(self, adapter): adapter._app.client.chat_update = AsyncMock() adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._active_status_threads["C123"] = { + adapter._active_status_threads[("", "C123", "parent_ts")] = { "thread_ts": "parent_ts", "team_id": "", } @@ -2186,7 +2195,9 @@ class TestSendTyping: assert result.success adapter._app.client.assistant_threads_setStatus.assert_not_called() - assert adapter._active_status_threads["C123"]["thread_ts"] == "parent_ts" + assert adapter._active_status_threads[("", "C123", "parent_ts")][ + "thread_ts" + ] == "parent_ts" @pytest.mark.asyncio async def test_status_uses_workspace_client_from_metadata(self, adapter): @@ -2235,8 +2246,8 @@ class TestSendTyping: call(channel_id="D123", thread_ts="thread_b", status="is thinking..."), call(channel_id="D123", thread_ts="thread_a", status=""), ] - assert ("D123", "thread_a") not in adapter._active_status_threads - assert adapter._active_status_threads[("D123", "thread_b")] == { + assert ("", "D123", "thread_a") not in adapter._active_status_threads + assert adapter._active_status_threads[("", "D123", "thread_b")] == { "thread_ts": "thread_b", "team_id": "", } @@ -2256,7 +2267,30 @@ class TestSendTyping: call(channel_id="D123", thread_ts="thread_b", status="is thinking..."), call(channel_id="D123", thread_ts="thread_a", status=""), ] - assert ("D123", "thread_b") in adapter._active_status_threads + assert ("", "D123", "thread_b") in adapter._active_status_threads + + @pytest.mark.asyncio + async def test_status_tracking_is_scoped_per_workspace(self, adapter): + one, two = AsyncMock(), AsyncMock() + adapter._team_clients.update({"T_ONE": one, "T_TWO": two}) + + await adapter.send_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"} + ) + await adapter.send_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_TWO"} + ) + await adapter.stop_typing( + "D_SHARED", metadata={"thread_id": "171.000", "slack_team_id": "T_ONE"} + ) + + assert one.assistant_threads_setStatus.call_args_list[-1] == call( + channel_id="D_SHARED", thread_ts="171.000", status="" + ) + assert two.assistant_threads_setStatus.call_args_list[-1] == call( + channel_id="D_SHARED", thread_ts="171.000", status="is thinking..." + ) + assert ("T_TWO", "D_SHARED", "171.000") in adapter._active_status_threads @pytest.mark.asyncio async def test_streaming_final_edit_uses_workspace_client_from_metadata( @@ -2266,7 +2300,7 @@ class TestSendTyping: team_client.chat_update = AsyncMock() team_client.assistant_threads_setStatus = AsyncMock() adapter._team_clients["T_OTHER"] = team_client - adapter._active_status_threads["D123"] = { + adapter._active_status_threads[("T_OTHER", "D123", "parent_ts")] = { "thread_ts": "parent_ts", "team_id": "T_OTHER", } @@ -2296,7 +2330,7 @@ class TestSendTyping: async def test_send_failure_clears_status(self, adapter): adapter._app.client.chat_postMessage = AsyncMock(side_effect=Exception("boom")) adapter._app.client.assistant_threads_setStatus = AsyncMock() - adapter._active_status_threads["C123"] = { + adapter._active_status_threads[("", "C123", "parent_ts")] = { "thread_ts": "parent_ts", "team_id": "", } @@ -2309,7 +2343,7 @@ class TestSendTyping: thread_ts="parent_ts", status="", ) - assert "C123" not in adapter._active_status_threads + assert ("", "C123", "parent_ts") not in adapter._active_status_threads # --------------------------------------------------------------------------- @@ -3108,7 +3142,9 @@ class TestAssistantThreadLifecycle: await assistant_adapter._handle_assistant_thread_lifecycle_event(event) assert ( - assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] + assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")][ + "user_id" + ] == "U_USER" ) mock_session_store.get_or_create_session.assert_called_once() @@ -3163,7 +3199,7 @@ class TestAssistantThreadLifecycle: async def test_message_uses_cached_assistant_thread_identity( self, assistant_adapter ): - assistant_adapter._assistant_threads[("D123", "171.000")] = { + assistant_adapter._assistant_threads[("T_TEAM", "D123", "171.000")] = { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", @@ -3214,8 +3250,8 @@ class TestAssistantThreadLifecycle: } ) assert len(assistant_adapter._assistant_threads) <= 10 - # The newest entry must survive eviction - assert ("D999", "999.000") in assistant_adapter._assistant_threads + # The newest entry must survive eviction. + assert ("", "D999", "999.000") in assistant_adapter._assistant_threads def test_suggested_prompts_config_accepts_dict_shape(self, assistant_adapter): assistant_adapter.config.extra["suggested_prompts"] = { @@ -3346,6 +3382,37 @@ class TestAssistantThreadLifecycle: )["context_channel_id"] == "C_CONTEXT_TWO" assert "C_CONTEXT_ONE" not in assistant_adapter._channel_team + @pytest.mark.asyncio + async def test_assistant_thread_cache_is_scoped_per_workspace( + self, assistant_adapter + ): + """Slack Connect can reuse a channel/thread pair in multiple workspaces.""" + for team_id, user_id in (("T_ONE", "U_ONE"), ("T_TWO", "U_TWO")): + await assistant_adapter._handle_assistant_thread_lifecycle_event( + { + "type": "assistant_thread_started", + "team_id": team_id, + "assistant_thread": { + "channel_id": "D_SHARED", + "thread_ts": "171.000", + "user_id": user_id, + }, + } + ) + + assert assistant_adapter._assistant_threads[ + ("T_ONE", "D_SHARED", "171.000") + ]["user_id"] == "U_ONE" + assert assistant_adapter._assistant_threads[ + ("T_TWO", "D_SHARED", "171.000") + ]["user_id"] == "U_TWO" + assert assistant_adapter._lookup_assistant_thread_metadata( + {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_ONE" + )["user_id"] == "U_ONE" + assert assistant_adapter._lookup_assistant_thread_metadata( + {}, channel_id="D_SHARED", thread_ts="171.000", team_id="T_TWO" + )["user_id"] == "U_TWO" + @pytest.mark.asyncio async def test_agent_view_message_preserves_outer_team_and_turn_context( self, assistant_adapter @@ -3495,6 +3562,23 @@ class TestUserNameResolution: name = await adapter._resolve_user_name("U123") assert name == "U123" # Falls back to user_id + @pytest.mark.asyncio + async def test_workspace_scoped_cache_uses_each_workspace_client(self, adapter): + """The same Slack user ID can resolve differently in another workspace.""" + team_one, team_two = AsyncMock(), AsyncMock() + team_one.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Alice"}}} + ) + team_two.users_info = AsyncMock( + return_value={"user": {"profile": {"display_name": "Bob"}}} + ) + adapter._team_clients.update({"T_ONE": team_one, "T_TWO": team_two}) + + assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_ONE") == "Alice" + assert await adapter._resolve_user_name("U_SHARED", "D_SHARED", "T_TWO") == "Bob" + team_one.users_info.assert_awaited_once_with(user="U_SHARED") + team_two.users_info.assert_awaited_once_with(user="U_SHARED") + @pytest.mark.asyncio async def test_user_name_in_message_source(self, adapter): """Message source should include resolved user name.""" diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index 4627fccafa69..13c62ee1aef0 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -431,7 +431,7 @@ class TestSlackThreadContext: }) # Mock user name resolution - adapter._user_name_cache = {"U1": "Alice", "U2": "Bob"} + adapter._user_name_cache = {("T1", "U1"): "Alice", ("T1", "U2"): "Bob"} context = await adapter._fetch_thread_context( channel_id="C1", @@ -478,7 +478,10 @@ class TestSlackThreadContext: {"ts": "1000.2", "user": "U1", "text": "Current"}, ] }) - adapter._user_name_cache = {"U1": "Alice", "U_OTHER_BOT": "DeployBot"} + adapter._user_name_cache = { + ("T1", "U1"): "Alice", + ("T1", "U_OTHER_BOT"): "DeployBot", + } context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1" @@ -531,7 +534,7 @@ class TestSlackThreadContext: {"ts": "1000.1", "user": "U1", "text": "詳細を教えて"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", @@ -565,7 +568,7 @@ class TestSlackThreadContext: {"ts": "1000.3", "user": "U1", "text": "Current"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.3", team_id="T1" @@ -613,7 +616,7 @@ class TestSlackThreadContext: {"ts": "2000.3", "user": "U2", "text": "Current"}, ] }) - adapter._user_name_cache = {"U2": "Bob"} + adapter._user_name_cache = {("T2", "U2"): "Bob"} context = await adapter._fetch_thread_context( channel_id="C2", thread_ts="2000.0", current_ts="2000.3", team_id="T2" @@ -636,7 +639,7 @@ class TestSlackThreadContext: {"ts": "1000.1", "user": "U1", "text": "DO NOT INCLUDE THIS"}, ] }) - adapter._user_name_cache = {"U1": "Alice"} + adapter._user_name_cache = {("T1", "U1"): "Alice"} context = await adapter._fetch_thread_context( channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"