{"repo":"NousResearch/hermes-agent","public_id":"12421","display_label":"Slack thread-context cache key workspace isolation fix","summary":"These issues and PRs address a bug where the Slack thread-context cache key did not account for workspace identity (team_id), potentially causing cross-workspace data leakage. The work involves fixing the cache key to include team_id, with related attention to multi-workspace scenarios and Slack Connect files.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16200,"merged_at":"2026-04-26T19:35:17+00:00"},"resolution":{"state":"resolved","by":"pr-16200"},"freshness":{"state":"stale","last_verified_at":"2026-04-26T19:35:17+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":2950,"pr_number":16200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T19:35:17+00:00"},{"issue_number":11095,"pr_number":16200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T19:35:17+00:00"},{"issue_number":12421,"pr_number":16200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T19:35:17+00:00"}],"members":[{"kind":"issue","number":12421,"title":"Bug: Slack thread-context cache key ignores workspace identity","url":null,"state":"closed"},{"kind":"pr","number":12502,"title":"fix(slack): include team_id in thread-context cache key","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1003ab6c6","number":0},{"kind":"pr","value":"#16200","number":16200},{"kind":"pr","value":"#12561","number":12561},{"kind":"pr","value":"#12421","number":12421}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12889,"title":"fix(slack): key thread-context cache by team_id to prevent cross-workspace leak (#12421)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":16200,"title":"fix(slack): thread-parent context, multi-workspace cache key, Slack Connect files","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/slack.py","scripts/release.py","tests/gateway/test_slack.py","tests/gateway/test_slack_approval_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/slack.py","hunks":["@@ -55,6 +55,7 @@ class _ThreadContextCache:\n     content: str\n     fetched_at: float = field(default_factory=time.monotonic)\n     message_count: int = 0\n+    parent_text: str = \"\"  # Raw text of the thread parent (for reply_to_text injection)\n \n \n def check_slack_requirements() -> bool:","@@ -1194,6 +1195,29 @@ async def _handle_slack_message(self, event: dict) -> None:\n         media_types = []\n         files = event.get(\"files\", [])\n         for f in files:\n+            # Slack Connect channels return stub file objects with\n+            # file_access=\"check_file_info\" and no URL fields. We must\n+            # call files.info to retrieve the full object (including url_private_download)\n+            # before we can download it.\n+            # https://docs.slack.dev/reference/objects/file-object/#slack_connect_files\n+            if f.get(\"file_access\") == \"check_file_info\":\n+                file_id = f.get(\"id\")\n+                if not file_id:\n+                    continue\n+                try:\n+                    info_resp = await self._get_client(channel_id).files_info(file=file_id)\n+                    if info_resp.get(\"ok\"):\n+                        f = info_resp[\"file\"]\n+                    else:\n+                        logger.warning(\n+                            \"[Slack] files.info failed for %s: %s\",\n+                            file_id, info_resp.get(\"error\"),\n+                        )\n+                        continue\n+                except Exception as e:\n+                    logger.warning(\"[Slack] files.info error for %s: %s\", file_id, e, exc_info=True)\n+                    continue\n+\n             mimetype = f.get(\"mimetype\", \"unknown\")\n             url = f.get(\"url_private_download\") or f.get(\"url_private\", \"\")\n             if mimetype.startswith(\"image/\") and url:","@@ -1291,6 +1315,22 @@ async def _handle_slack_message(self, event: dict) -> None:\n             self.config.extra, channel_id, None,\n         )\n \n+        # Extract reply context if this message is a thread reply.\n+        # Mirrors the Telegram/Discord implementations so that gateway.run\n+        # can inject a `[Replying to: \"...\"]` prefix when the parent is not\n+        # already in the session history. Uses the thread-context cache when\n+        # available to avoid redundant conversations.replies calls.\n+        reply_to_text = None\n+        if thread_ts and thread_ts != ts:\n+            try:\n+                reply_to_text = await self._fetch_thread_parent_text(\n+                    channel_id=channel_id,\n+                    thread_ts=thread_ts,\n+                    team_id=team_id,\n+                ) or None\n+            except Exception:  # pragma: no cover - defensive\n+                reply_to_text = None\n+\n         msg_event = MessageEvent(\n             text=text,\n             message_type=msg_type,","@@ -1301,6 +1341,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n             media_types=media_types,\n             reply_to_message_id=thread_ts if thread_ts != ts else None,\n             channel_prompt=_channel_prompt,\n+            reply_to_text=reply_to_text,\n         )\n \n         # Only react when bot is directly addressed (DM or @mention).","@@ -1508,7 +1549,7 @@ async def _fetch_thread_context(\n         Returns a formatted string with prior thread history, or empty string\n         on failure or if the thread has no prior messages.\n         \"\"\"\n-        cache_key = f\"{channel_id}:{thread_ts}\"\n+        cache_key = f\"{channel_id}:{thread_ts}:{team_id}\"\n         now = time.monotonic()\n         cached = self._thread_context_cache.get(cache_key)\n         if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:","@@ -1555,14 +1596,37 @@ async def _fetch_thread_context(\n \n             bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)\n             context_parts = []\n+            parent_text = \"\"\n             for msg in messages:\n                 msg_ts = msg.get(\"ts\", \"\")\n                 # Exclude the current triggering message — it will be delivered\n                 # as the user message itself, so including it here would duplicate it.\n                 if msg_ts == current_ts:\n                     continue\n-                # Exclude our own bot messages to avoid circular context.\n-                if msg.get(\"bot_id\") or msg.get(\"subtype\") == \"bot_message\":\n+\n+                is_parent = msg_ts == thread_ts\n+                is_bot = bool(msg.get(\"bot_id\")) or msg.get(\"subtype\") == \"bot_message\"\n+                msg_user = msg.get(\"user\", \"\")\n+\n+                # Identify \"our own\" bot for this workspace (multi-workspace safe).\n+                msg_team = msg.get(\"team\") or team_id\n+                self_bot_uid = (\n+                    self._team_bot_user_ids.get(msg_team)\n+                    if msg_team\n+                    else None\n+                ) or self._bot_user_id\n+\n+                # Exclude only our own prior bot replies (circular context).\n+                # Keep:\n+                #   - the thread parent even if it was posted by a bot\n+                #     (e.g. a cron job summary we are now replying to);\n+                #   - other bots' child messages (useful third-party context).\n+                if (\n+                    is_bot\n+                    and not is_parent\n+                    and self_bot_uid\n+                    and msg_user == self_bot_uid\n+                ):\n                     continue\n \n                 msg_text = msg.get(\"text\", \"\").strip()","@@ -1573,11 +1637,15 @@ async def _fetch_thread_context(\n                 if bot_uid:\n                     msg_text = msg_text.replace(f\"<@{bot_uid}>\", \"\").strip()\n \n-                msg_user = msg.get(\"user\", \"unknown\")\n-                is_parent = msg_ts == thread_ts\n                 prefix = \"[thread parent] \" if is_parent else \"\"\n-                name = await self._resolve_user_name(msg_user, chat_id=channel_id)\n+                display_user = msg_user or \"unknown\"\n+                # Prefer the bot's own name when the message is a bot post.\n+                if is_bot and not display_user:\n+                    display_user = msg.get(\"username\") or \"bot\"\n+                name = await self._resolve_user_name(display_user, chat_id=channel_id)\n                 context_parts.append(f\"{prefix}{name}: {msg_text}\")\n+                if is_parent:\n+                    parent_text = msg_text\n \n             content = \"\"\n             if context_parts:","@@ -1591,6 +1659,7 @@ async def _fetch_thread_context(\n                 content=content,\n                 fetched_at=now,\n                 message_count=len(context_parts),\n+                parent_text=parent_text,\n             )\n             return content\n ","@@ -1598,6 +1667,47 @@ async def _fetch_thread_context(\n             logger.warning(\"[Slack] Failed to fetch thread context: %s\", e)\n             return \"\"\n \n+    async def _fetch_thread_parent_text(\n+        self, channel_id: str, thread_ts: str, team_id: str = \"\",\n+    ) -> str:\n+        \"\"\"Return the raw text of the thread parent message (for reply_to_text).\n+\n+        Uses the same per-thread cache as :meth:`_fetch_thread_context` to avoid\n+        hitting ``conversations.replies`` twice. Falls back to a cheap single-\n+        message fetch (``limit=1, inclusive=True``) when the cache is cold.\n+\n+        Returns empty string on any failure — callers should treat an empty\n+        return as \"no parent context to inject\".\n+        \"\"\"\n+        cache_key = f\"{channel_id}:{thread_ts}:{team_id}\"\n+        now = time.monotonic()\n+        cached = self._thread_context_cache.get(cache_key)\n+        if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:\n+            return cached.parent_text\n+\n+        try:\n+            client = self._get_client(channel_id)\n+            result = await client.conversations_replies(\n+                channel=channel_id,\n+                ts=thread_ts,\n+                limit=1,\n+                inclusive=True,\n+            )\n+            messages = result.get(\"messages\", []) if result else []\n+            if not messages:\n+                return \"\"\n+            parent = messages[0]\n+            if parent.get(\"ts\", \"\") != thread_ts:\n+                return \"\"\n+            bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)\n+            text = (parent.get(\"text\") or \"\").strip()\n+            if bot_uid:\n+                text = text.replace(f\"<@{bot_uid}>\", \"\").strip()\n+            return text\n+        except Exception as exc:  # pragma: no cover - defensive\n+            logger.debug(\"[Slack] Failed to fetch thread parent text: %s\", exc)\n+            return \"\"\n+\n     async def _handle_slash_command(self, command: dict) -> None:\n         \"\"\"Handle Slack slash commands.\n "]},{"file":"scripts/release.py","hunks":["@@ -121,6 +121,8 @@\n     \"[redacted:email]\": \"zhiyanliu\",\n     \"[redacted:email]\": \"hhhonzik\",\n     \"139848623+hhuang91@users.noreply.github.com\": \"hhuang91\",\n+    \"[redacted:email]\": \"Satoshi-agi\",\n+    \"10774721+kunlabs@users.noreply.github.com\": \"kunlabs\",\n     \"nocoo@users.noreply.github.com\": \"nocoo\",\n     \"30841158+n-WN@users.noreply.github.com\": \"n-WN\",\n     \"[redacted:email]\": \"hiddenpuppy\","]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -2011,3 +2011,76 @@ async def test_channel_mention_progress_uses_thread_ts(self, adapter):\n             \"so each @mention starts its own thread\"\n         )\n         assert msg_event.message_id == \"2000000000.000001\"\n+\n+\n+class TestSlackReplyToText:\n+    \"\"\"Ensure MessageEvent.reply_to_text is populated on thread replies so\n+    gateway.run can inject a ``[Replying to: \"...\"]`` prefix (parity with\n+    Telegram/Discord/Feishu/WeCom).\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_slack_reply_to_text_set_on_thread_reply(self, adapter):\n+        \"\"\"When a thread reply arrives and the parent was posted by a bot\n+        (e.g. cron summary), reply_to_text must carry the parent's text.\"\"\"\n+        adapter._channel_team = {}  # primary workspace only\n+        adapter._team_bot_user_ids = {}\n+\n+        # Mock conversations_replies to return a bot-posted parent\n+        adapter._app.client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\n+                    \"ts\": \"1000.0\",\n+                    \"bot_id\": \"B_CRON\",\n+                    \"text\": \"メール要約: 新着メール3件あります\",\n+                },\n+                {\"ts\": \"1000.5\", \"user\": \"U_USER\", \"text\": \"詳細を教えて\"},\n+            ]\n+        })\n+\n+        # Use a DM so mention-gating doesn't short-circuit the handler.\n+        event = {\n+            \"text\": \"詳細を教えて\",\n+            \"user\": \"U_USER\",\n+            \"channel\": \"D123\",\n+            \"channel_type\": \"im\",\n+            \"ts\": \"1000.5\",\n+            \"thread_ts\": \"1000.0\",  # thread reply\n+        }\n+\n+        with patch.object(\n+            adapter, \"_resolve_user_name\", new=AsyncMock(return_value=\"Alice\")\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert adapter.handle_message.call_args is not None, (\n+            \"handle_message must be invoked for thread-reply DM\"\n+        )\n+        msg_event = adapter.handle_message.call_args[0][0]\n+        assert msg_event.reply_to_message_id == \"1000.0\"\n+        # The critical assertion: parent text is exposed as reply_to_text so the\n+        # gateway can inject it when not already in the session history.\n+        assert msg_event.reply_to_text is not None\n+        assert \"メール要約\" in msg_event.reply_to_text\n+\n+    @pytest.mark.asyncio\n+    async def test_slack_reply_to_text_none_for_top_level_message(self, adapter):\n+        \"\"\"Top-level messages (no thread_ts) must not set reply_to_text.\"\"\"\n+        event = {\n+            \"text\": \"hello\",\n+            \"user\": \"U_USER\",\n+            \"channel\": \"D123\",\n+            \"channel_type\": \"im\",\n+            \"ts\": \"1000.0\",\n+            # no thread_ts — top-level DM\n+        }\n+\n+        with patch.object(\n+            adapter, \"_resolve_user_name\", new=AsyncMock(return_value=\"Alice\")\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert adapter.handle_message.call_args is not None\n+        msg_event = adapter.handle_message.call_args[0][0]\n+        assert msg_event.reply_to_text is None\n+        # Top-level message: reply_to_message_id must be falsy (None or empty).\n+        assert not msg_event.reply_to_message_id"]},{"file":"tests/gateway/test_slack_approval_buttons.py","hunks":["@@ -276,23 +276,44 @@ async def test_fetches_and_formats_context(self):\n \n     @pytest.mark.asyncio\n     async def test_skips_bot_messages(self):\n+        \"\"\"Self-bot child replies are skipped to avoid circular context,\n+        but non-self bots (e.g. cron posts, third-party integrations) are kept.\n+\n+        Regression guard for the fix in _fetch_thread_context: previously ALL\n+        bot messages were dropped, which lost context when the bot was replying\n+        to a cron-posted thread parent.\"\"\"\n         adapter = _make_adapter()\n         mock_client = adapter._team_clients[\"T1\"]\n         mock_client.conversations_replies = AsyncMock(return_value={\n             \"messages\": [\n                 {\"ts\": \"1000.0\", \"user\": \"U1\", \"text\": \"Parent\"},\n-                {\"ts\": \"1000.1\", \"bot_id\": \"B1\", \"text\": \"Bot reply (should be skipped)\"},\n+                # Self-bot reply -> must be skipped (circular)\n+                {\n+                    \"ts\": \"1000.1\",\n+                    \"bot_id\": \"B_SELF\",\n+                    \"user\": \"U_BOT\",\n+                    \"text\": \"Previous bot self-reply (should be skipped)\",\n+                },\n+                # Third-party bot child -> kept (useful context)\n+                {\n+                    \"ts\": \"1000.15\",\n+                    \"bot_id\": \"B_OTHER\",\n+                    \"user\": \"U_OTHER_BOT\",\n+                    \"text\": \"Deploy succeeded\",\n+                },\n                 {\"ts\": \"1000.2\", \"user\": \"U1\", \"text\": \"Current\"},\n             ]\n         })\n-        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+        adapter._user_name_cache = {\"U1\": \"Alice\", \"U_OTHER_BOT\": \"DeployBot\"}\n \n         context = await adapter._fetch_thread_context(\n             channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.2\", team_id=\"T1\"\n         )\n \n-        assert \"Bot reply\" not in context\n+        assert \"Previous bot self-reply\" not in context\n         assert \"Alice: Parent\" in context\n+        # Third-party bot message must now be included\n+        assert \"Deploy succeeded\" in context\n \n     @pytest.mark.asyncio\n     async def test_empty_thread(self):","@@ -316,6 +337,166 @@ async def test_api_failure_returns_empty(self):\n         )\n         assert context == \"\"\n \n+    @pytest.mark.asyncio\n+    async def test_fetch_thread_context_includes_bot_parent(self):\n+        \"\"\"The thread parent posted by a bot (e.g. a cron summary) must be\n+        included in the context, prefixed with ``[thread parent]``.\"\"\"\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                # Bot-posted parent (cron job)\n+                {\n+                    \"ts\": \"1000.0\",\n+                    \"bot_id\": \"B123\",\n+                    \"subtype\": \"bot_message\",\n+                    \"username\": \"cron\",\n+                    \"text\": \"メール要約: 本日の新着3件\",\n+                },\n+                # User reply that triggered the fetch\n+                {\"ts\": \"1000.1\", \"user\": \"U1\", \"text\": \"詳細を教えて\"},\n+            ]\n+        })\n+        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\",\n+            thread_ts=\"1000.0\",\n+            current_ts=\"1000.1\",  # exclude the trigger message itself\n+            team_id=\"T1\",\n+        )\n+\n+        assert \"[thread parent]\" in context\n+        assert \"メール要約: 本日の新着3件\" in context\n+\n+    @pytest.mark.asyncio\n+    async def test_fetch_thread_context_excludes_self_bot_replies(self):\n+        \"\"\"Parent (non-self bot) is kept, self-bot child replies are dropped,\n+        user replies are kept.\"\"\"\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"1000.0\", \"bot_id\": \"B_CRON\", \"text\": \"Cron summary\"},\n+                # Self-bot child reply -> excluded\n+                {\n+                    \"ts\": \"1000.1\",\n+                    \"bot_id\": \"B_SELF\",\n+                    \"user\": \"U_BOT\",  # matches adapter._bot_user_id\n+                    \"text\": \"Previous self reply\",\n+                },\n+                # User reply -> kept\n+                {\"ts\": \"1000.2\", \"user\": \"U1\", \"text\": \"Follow-up question\"},\n+                # Current trigger (excluded by current_ts match)\n+                {\"ts\": \"1000.3\", \"user\": \"U1\", \"text\": \"Current\"},\n+            ]\n+        })\n+        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.3\", team_id=\"T1\"\n+        )\n+\n+        assert \"Cron summary\" in context\n+        assert \"[thread parent]\" in context\n+        assert \"Previous self reply\" not in context\n+        assert \"Follow-up question\" in context\n+        assert \"Current\" not in context\n+\n+    @pytest.mark.asyncio\n+    async def test_fetch_thread_context_multi_workspace(self):\n+        \"\"\"Self-bot filtering must use the per-workspace bot user id so a\n+        self-bot id that belongs to a different workspace does not accidentally\n+        filter out a legitimate message in the current workspace.\"\"\"\n+        adapter = _make_adapter()\n+        # Add a second workspace with a different bot user id\n+        adapter._team_clients[\"T2\"] = AsyncMock()\n+        adapter._team_bot_user_ids = {\"T1\": \"U_BOT_T1\", \"T2\": \"U_BOT_T2\"}\n+        adapter._bot_user_id = \"U_BOT_T1\"\n+        adapter._channel_team[\"C2\"] = \"T2\"\n+\n+        mock_client = adapter._team_clients[\"T2\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"2000.0\", \"user\": \"U2\", \"text\": \"Parent T2\"},\n+                # This has the *T1* bot's user id — from T2's perspective this\n+                # is a third-party bot, so it must be kept.\n+                {\n+                    \"ts\": \"2000.1\",\n+                    \"bot_id\": \"B_FOREIGN\",\n+                    \"user\": \"U_BOT_T1\",\n+                    \"team\": \"T2\",\n+                    \"text\": \"Cross-workspace bot reply\",\n+                },\n+                # Self-bot for T2 — must be skipped\n+                {\n+                    \"ts\": \"2000.2\",\n+                    \"bot_id\": \"B_SELF_T2\",\n+                    \"user\": \"U_BOT_T2\",\n+                    \"team\": \"T2\",\n+                    \"text\": \"Own T2 bot reply\",\n+                },\n+                {\"ts\": \"2000.3\", \"user\": \"U2\", \"text\": \"Current\"},\n+            ]\n+        })\n+        adapter._user_name_cache = {\"U2\": \"Bob\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C2\", thread_ts=\"2000.0\", current_ts=\"2000.3\", team_id=\"T2\"\n+        )\n+\n+        assert \"Parent T2\" in context\n+        assert \"Cross-workspace bot reply\" in context\n+        assert \"Own T2 bot reply\" not in context\n+\n+    @pytest.mark.asyncio\n+    async def test_fetch_thread_context_current_ts_excluded(self):\n+        \"\"\"Regression guard: the message whose ts == current_ts must never\n+        appear in the context output (it will be delivered as the user\n+        message itself).\"\"\"\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"1000.0\", \"user\": \"U1\", \"text\": \"Parent\"},\n+                {\"ts\": \"1000.1\", \"user\": \"U1\", \"text\": \"DO NOT INCLUDE THIS\"},\n+            ]\n+        })\n+        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.1\", team_id=\"T1\"\n+        )\n+\n+        assert \"Parent\" in context\n+        assert \"DO NOT INCLUDE THIS\" not in context\n+\n+    @pytest.mark.asyncio\n+    async def test_fetch_thread_parent_text_from_cache(self):\n+        \"\"\"_fetch_thread_parent_text should reuse the thread-context cache\n+        when it is warm, avoiding an extra conversations.replies call.\"\"\"\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"1000.0\", \"bot_id\": \"B123\", \"text\": \"Parent summary\"},\n+                {\"ts\": \"1000.1\", \"user\": \"U1\", \"text\": \"reply\"},\n+            ]\n+        })\n+\n+        # Warm the cache via _fetch_thread_context\n+        await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.1\", team_id=\"T1\"\n+        )\n+        assert mock_client.conversations_replies.await_count == 1\n+\n+        parent = await adapter._fetch_thread_parent_text(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", team_id=\"T1\"\n+        )\n+        assert parent == \"Parent summary\"\n+        # No additional API call\n+        assert mock_client.conversations_replies.await_count == 1\n+\n \n # ===========================================================================\n # _has_active_session_for_thread — session key fix (#5833)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}