{"repo":"NousResearch/hermes-agent","public_id":"2662","display_label":"Slack reply_in_thread config option behavior and fixes","summary":"These issues and pull requests concern adding, configuring, and correcting the behavior of the reply_in_thread option for the Slack platform, including problems where the setting was not being properly passed through or respected in channels and DMs.","subsystem":"gateway","counts":{"members":16,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":3,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4643},"resolution":{"state":"partially_resolved","by":"pr-4643,pr-16188,pr-41703"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T04:19:59+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13766666666666666},"fix_edges":[{"issue_number":2662,"pr_number":4643,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7532,"pr_number":16188,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9268,"pr_number":16188,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15421,"pr_number":41703,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2662,"title":"Add reply_in_thread config option for Slack platform","url":null,"state":"closed"},{"kind":"issue","number":7532,"title":"[Bug]: slack.reply_in_thread config option not applied to platform extra dict","url":null,"state":"closed"},{"kind":"issue","number":9268,"title":"Slack: reply_in_thread: false still threads all channel replies","url":null,"state":"closed"},{"kind":"issue","number":15421,"title":"Slack: top-level messages create isolated sessions; sessions.json not persisting","url":null,"state":"closed"},{"kind":"issue","number":29921,"title":"reply_in_thread: false does not prevent thread replies in Slack","url":null,"state":"closed"},{"kind":"pr","number":2664,"title":"feat(slack): add reply_in_thread config option","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2726","number":2726}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":2726,"title":"feat(slack): add reply_in_thread config option","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4643","number":4643},{"kind":"pr","value":"#2662","number":2662},{"kind":"pr","value":"#2664","number":2664}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4643,"title":"feat(slack): add reply_in_thread config option","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/slack.py","website/docs/user-guide/messaging/slack.md"],"fix_hunks":[{"file":"gateway/platforms/slack.py","hunks":["@@ -323,7 +323,18 @@ def _resolve_thread_ts(\n \n         Prefers metadata thread_id (the thread parent's ts, set by the\n         gateway) over reply_to (which may be a child message's ts).\n+\n+        When ``reply_in_thread`` is ``false`` in the platform extra config,\n+        top-level channel messages receive direct channel replies instead of\n+        thread replies.  Messages that originate inside an existing thread are\n+        always replied to in-thread to preserve conversation context.\n         \"\"\"\n+        # When reply_in_thread is disabled (default: True for backward compat),\n+        # only thread messages that are already part of an existing thread.\n+        if not self.config.extra.get(\"reply_in_thread\", True):\n+            existing_thread = (metadata or {}).get(\"thread_id\") or (metadata or {}).get(\"thread_ts\")\n+            return existing_thread or None\n+\n         if metadata:\n             if metadata.get(\"thread_id\"):\n                 return metadata[\"thread_id\"]"]},{"file":"website/docs/user-guide/messaging/slack.md","hunks":["@@ -217,6 +217,23 @@ In channels, always @mention the bot. Simply typing a message without mentioning\n This is intentional — it prevents the bot from responding to every message in busy channels.\n :::\n \n+### Reply Threading\n+\n+By default, Hermes replies in a **thread** attached to the original message in channels. If your team prefers replies to go **directly to the channel** instead, you can disable threading:\n+\n+```yaml\n+platforms:\n+  slack:\n+    extra:\n+      reply_in_thread: false\n+```\n+\n+When `reply_in_thread` is `false`:\n+- **Channel messages** — Hermes replies directly in the channel (no thread created)\n+- **Thread messages** — Hermes still replies inside the existing thread to preserve conversation context\n+\n+The default is `true` (threaded replies), which matches the original behavior.\n+\n ---\n \n \n"]}]},{"kind":"pr","number":7534,"title":"fix(config): bridge reply_in_thread from platform config to extra dict","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7153","number":7153},{"kind":"pr","value":"#7226","number":7226},{"kind":"pr","value":"#7265","number":7265},{"kind":"pr","value":"#7305","number":7305},{"kind":"pr","value":"#7398","number":7398},{"kind":"pr","value":"#7513","number":7513}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":7561,"title":"fix(config): bridge reply_in_thread to platform extra dict","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b1be86ef9","number":0},{"kind":"commit","value":"b1be86ef96706cada99b1fa04673d301b98d5325","number":0},{"kind":"pr","value":"#7532","number":7532}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":8598,"title":"fix(slack): respect reply_in_thread and reply_to_mode for channel replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17184","number":17184},{"kind":"pr","value":"#16188","number":16188}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":10566,"title":"fix(gateway): honor slack.reply_in_thread from config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7e97ea561","number":0},{"kind":"commit","value":"2f2765b4d","number":0},{"kind":"pr","value":"#16188","number":16188},{"kind":"pr","value":"#9268","number":9268}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15464,"title":"fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"133e0271e","number":0},{"kind":"commit","value":"ab0a6270c","number":0},{"kind":"pr","value":"#41703","number":41703}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16188,"title":"fix(slack): honor reply_in_thread=false in DMs and channels","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/slack.py","tests/gateway/test_slack_mention.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -570,6 +570,8 @@ def load_gateway_config() -> GatewayConfig:\n                     )\n                 if \"reply_prefix\" in platform_cfg:\n                     bridged[\"reply_prefix\"] = platform_cfg[\"reply_prefix\"]\n+                if \"reply_in_thread\" in platform_cfg:\n+                    bridged[\"reply_in_thread\"] = platform_cfg[\"reply_in_thread\"]\n                 if \"require_mention\" in platform_cfg:\n                     bridged[\"require_mention\"] = platform_cfg[\"require_mention\"]\n                 if \"free_response_channels\" in platform_cfg:"]},{"file":"gateway/platforms/slack.py","hunks":["@@ -450,8 +450,18 @@ def _resolve_thread_ts(\n         \"\"\"\n         # When reply_in_thread is disabled (default: True for backward compat),\n         # only thread messages that are already part of an existing thread.\n+        # For top-level channel messages, the inbound handler sets\n+        # metadata.thread_id to the message's own ts as a session-keying\n+        # fallback (see the `thread_ts = event.get(\"thread_ts\") or ts` branch),\n+        # so metadata alone can't distinguish a real thread reply from a\n+        # top-level message. reply_to is the incoming message's own id, so\n+        # when thread_id == reply_to the \"thread\" is synthetic and we reply\n+        # directly in the channel instead.\n         if not self.config.extra.get(\"reply_in_thread\", True):\n-            existing_thread = (metadata or {}).get(\"thread_id\") or (metadata or {}).get(\"thread_ts\")\n+            md = metadata or {}\n+            existing_thread = md.get(\"thread_id\") or md.get(\"thread_ts\")\n+            if existing_thread and reply_to and existing_thread == reply_to:\n+                existing_thread = None\n             return existing_thread or None\n \n         if metadata:"]},{"file":"tests/gateway/test_slack_mention.py","hunks":["@@ -310,3 +310,43 @@ def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):\n     import os as _os\n     assert _os.environ[\"SLACK_REQUIRE_MENTION\"] == \"false\"\n     assert _os.environ[\"SLACK_FREE_RESPONSE_CHANNELS\"] == \"C0AQWDLHY9M,C9999999999\"\n+\n+\n+def test_config_bridges_slack_reply_in_thread(monkeypatch, tmp_path):\n+    from gateway.config import load_gateway_config\n+\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"slack:\\n\"\n+        \"  reply_in_thread: false\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.setenv(\"SLACK_BOT_TOKEN\", \"xoxb-test\")\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    slack_config = config.platforms[Platform.SLACK]\n+    assert slack_config.extra.get(\"reply_in_thread\") is False\n+\n+    adapter = SlackAdapter(slack_config)\n+    assert adapter._resolve_thread_ts(reply_to=\"171.000\", metadata={}) is None\n+\n+    # Top-level channel messages arrive with metadata.thread_id == reply_to\n+    # because the inbound handler uses event.ts as a session-keying fallback.\n+    # Those must be treated as non-threaded so reply_in_thread=false takes\n+    # effect in channels, not just DMs.\n+    assert adapter._resolve_thread_ts(\n+        reply_to=\"171.000\",\n+        metadata={\"thread_id\": \"171.000\"},\n+    ) is None\n+\n+    # Real thread replies (reply_to differs from thread parent) must still\n+    # resolve to the parent thread so conversation context is preserved.\n+    assert adapter._resolve_thread_ts(\n+        reply_to=\"171.500\",\n+        metadata={\"thread_id\": \"171.000\"},\n+    ) == \"171.000\"\n"]}]},{"kind":"pr","number":29947,"title":"fix(slack): drop message_ts fallback from assistant-thread metadata extraction (#29921)","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":41703,"title":"fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/slack.py","tests/gateway/test_slack_channel_session_scope.py"],"fix_hunks":[{"file":"gateway/platforms/slack.py","hunks":["@@ -2291,7 +2291,38 @@ async def _handle_slack_message(self, event: dict) -> None:\n             if not thread_ts and self._dm_top_level_threads_as_sessions():\n                 thread_ts = ts\n         else:\n-            thread_ts = event.get(\"thread_ts\") or ts  # ts fallback for channels\n+            # Channel message session scoping.\n+            #\n+            # Three cases:\n+            #   (a) genuine thread reply   → scope session per thread\n+            #   (b) top-level, reply_in_thread=true (the default)  →\n+            #       legacy behaviour: each top-level message becomes its\n+            #       own thread, so the UX still \"replies in a thread\"\n+            #       and sessions are keyed per thread root\n+            #   (c) top-level, reply_in_thread=false → scope one session\n+            #       across the whole channel so context accumulates across\n+            #       messages (#15421 bug 1)\n+            event_thread_ts_raw = event.get(\"thread_ts\")\n+            # Align with ``is_thread_reply`` below — a ``thread_ts ==\n+            # ts`` payload (some thread-root shapes) is not a real reply\n+            # and must not prevent the shared-session path from taking\n+            # effect.  Matching the same invariant here keeps the two\n+            # branches in sync even if Slack introduces new payload\n+            # variants (Copilot on #15464).\n+            if event_thread_ts_raw and event_thread_ts_raw != ts:\n+                thread_ts = event_thread_ts_raw\n+            elif self.config.extra.get(\"reply_in_thread\", True):\n+                # Legacy default: treat ts as a synthetic thread root so\n+                # this top-level message gets its own session.\n+                thread_ts = ts\n+            else:\n+                # reply_in_thread=false: no thread key → session manager\n+                # groups by (platform, channel_id, None) and the channel\n+                # shares one conversation.  reply_to_message_id at the\n+                # outbound side is already gated on ``thread_ts != ts``\n+                # so None here produces a non-threaded reply without\n+                # further changes.\n+                thread_ts = None\n \n         # In channels, respond if:\n         #   0. Channel is in free_response_channels, OR require_mention is"]},{"file":"tests/gateway/test_slack_channel_session_scope.py","hunks":["@@ -0,0 +1,259 @@\n+\"\"\"Regression guard for #15421 bug 1 — Slack channel session scoping.\n+\n+Before this fix, every top-level Slack channel message got a unique\n+``thread_id`` (the message's own ``ts``) stamped onto its\n+``MessageSource``.  The gateway session store keys sessions by\n+``(platform, channel_id, thread_id)``, so each top-level message\n+spawned a **brand new session** and channel context never accumulated\n+across messages — even when the operator set ``reply_in_thread: false``\n+in ``config.yaml`` expecting channel-wide conversation.\n+\n+The fix: when ``reply_in_thread: false`` is configured, top-level\n+channel messages now land on ``thread_id = None`` so the session store\n+groups them under a single channel-scoped session.  Genuine thread\n+replies (``event.thread_ts != ts``) still scope sessions per thread in\n+both modes — threading UX is unchanged when the operator actually\n+asks for it.\n+\n+These tests drive the real ``SlackAdapter._handle_slack_message`` code\n+path with mocked aiohttp / user-resolution so the ``MessageEvent``\n+that reaches ``handle_message`` exposes exactly what the session store\n+will key on.  Asserting on the event keeps the seam tight against the\n+production function's behaviour rather than a re-implementation.\n+\"\"\"\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+from gateway.platforms.slack import SlackAdapter\n+\n+\n[redacted:email]\n+def adapter():\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    a = SlackAdapter(config)\n+    a._app = MagicMock()\n+    a._app.client = AsyncMock()\n+    a._bot_user_id = \"U_BOT\"\n+    a._running = True\n+    a.handle_message = AsyncMock()\n+    return a\n+\n+\n[redacted:email](autouse=True)\n+def _redirect_cache(tmp_path, monkeypatch):\n+    \"\"\"Point document cache to tmp_path so tests don't touch ~/.hermes.\"\"\"\n+    monkeypatch.setattr(\n+        \"gateway.platforms.base.DOCUMENT_CACHE_DIR\", tmp_path / \"doc_cache\"\n+    )\n+\n+\n+def _channel_event(text: str, ts: str, thread_ts: str = None) -> dict:\n+    \"\"\"Build a minimal ``message`` event for the Slack Events API\n+    resembling what ``handle_message_event`` would pass through.\"\"\"\n+    event = {\n+        \"channel\": \"C_CHAN\",\n+        \"channel_type\": \"channel\",\n+        \"user\": \"U_USER\",\n+        \"text\": text,\n+        \"ts\": ts,\n+    }\n+    if thread_ts is not None:\n+        event[\"thread_ts\"] = thread_ts\n+    return event\n+\n+\n+class TestChannelSessionScopeDefault:\n+    \"\"\"``reply_in_thread: true`` is the historical default.  Top-level\n+    channel messages still map ``thread_id = ts`` so each new message\n+    becomes its own threaded session — unchanged from the pre-#15421\n+    behaviour.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_top_level_maps_to_ts_when_reply_in_thread_true(self, adapter):\n+        adapter.config.extra[\"reply_in_thread\"] = True\n+        event = _channel_event(\n+            \"<@U_BOT> hello\",\n+            ts=\"1700000000.000001\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert len(captured) == 1, (\n+            \"handler dropped the top-level channel mention — \"\n+            \"mention gating misfired\"\n+        )\n+        source = captured[0].source\n+        assert source.thread_id == \"1700000000.000001\", (\n+            \"legacy default (reply_in_thread=true) must keep stamping \"\n+            \"thread_id = ts so each top-level message gets its own \"\n+            \"threaded session — regression guard\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_top_level_default_behaves_like_true(self, adapter):\n+        \"\"\"Operators who never set ``reply_in_thread`` must see the\n+        historical behaviour (true).  Pin the default explicitly.\"\"\"\n+        # Note: no adapter.config.extra[\"reply_in_thread\"] set here.\n+        event = _channel_event(\n+            \"<@U_BOT> hello\",\n+            ts=\"1700000000.000002\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert len(captured) == 1\n+        assert captured[0].source.thread_id == \"1700000000.000002\"\n+\n+\n+class TestChannelSessionScopeShared:\n+    \"\"\"``reply_in_thread: false`` is the #15421 fix: top-level channel\n+    messages get ``thread_id = None`` so all of them share one\n+    channel-scoped session.  Genuine thread replies still get their\n+    real ``thread_ts``.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_top_level_maps_to_none_when_reply_in_thread_false(self, adapter):\n+        adapter.config.extra[\"reply_in_thread\"] = False\n+        event = _channel_event(\n+            \"<@U_BOT> hello\",\n+            ts=\"1700000000.000003\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert len(captured) == 1\n+        source = captured[0].source\n+        assert source.thread_id is None, (\n+            \"reply_in_thread=false must set thread_id=None for top-level \"\n+            \"channel messages so the session store groups them under a \"\n+            \"single channel-scoped session (#15421 bug 1)\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_top_level_reply_to_id_stays_none_when_shared(self, adapter):\n+        \"\"\"In shared-session mode (``reply_in_thread=false``), top-level\n+        channel messages are normalised to ``thread_ts = None``.  The\n+        outbound check on the ``MessageEvent`` is:\n+\n+            reply_to_message_id = thread_ts if thread_ts != ts else None\n+\n+        With ``thread_ts = None``, ``None != ts`` is True, so the\n+        expression evaluates to ``thread_ts`` itself — which IS\n+        ``None``.  That leaves ``reply_to_message_id`` as ``None`` and\n+        the bot posts a fresh un-threaded channel reply, matching what\n+        ``reply_in_thread=false`` means end-to-end.  This regression\n+        test locks in that invariant (Copilot noted the pre-fix\n+        docstring had the logic reversed).\n+        \"\"\"\n+        adapter.config.extra[\"reply_in_thread\"] = False\n+        event = _channel_event(\n+            \"<@U_BOT> hello\",\n+            ts=\"1700000000.000004\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert captured[0].reply_to_message_id is None, (\n+            \"top-level channel messages with reply_in_thread=false \"\n+            \"must not be threaded (reply_to_message_id=None)\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_thread_reply_scopes_by_thread_even_when_shared(self, adapter):\n+        \"\"\"Bug 1's fix targets ONLY top-level channel messages.  Genuine\n+        thread replies (``thread_ts != ts``) must still scope per-thread\n+        sessions so multi-person threaded conversations don't collide\n+        with unrelated channel chatter.\"\"\"\n+        adapter.config.extra[\"reply_in_thread\"] = False\n+        # Reply to an earlier thread root at ts=1700000000.000000\n+        event = _channel_event(\n+            \"<@U_BOT> following up\",\n+            ts=\"1700000000.000005\",\n+            thread_ts=\"1700000000.000000\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert len(captured) == 1\n+        source = captured[0].source\n+        assert source.thread_id == \"1700000000.000000\", (\n+            \"genuine thread replies must still scope by thread even \"\n+            \"when reply_in_thread=false — only TOP-LEVEL messages share \"\n+            \"the channel-wide session\"\n+        )\n+        assert captured[0].reply_to_message_id == \"1700000000.000000\", (\n+            \"reply should thread under the existing thread root\"\n+        )\n+\n+\n+class TestThreadReplyAlwaysScopesByThread:\n+    \"\"\"Cross-cutting invariant: genuine thread replies always scope by\n+    ``thread_ts`` regardless of ``reply_in_thread``.  If this ever\n+    regresses, every thread-scoped conversation leaks across threads.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    @pytest.mark.parametrize(\"reply_in_thread\", [True, False])\n+    async def test_thread_reply_keyed_by_thread_ts(self, adapter, reply_in_thread):\n+        adapter.config.extra[\"reply_in_thread\"] = reply_in_thread\n+        event = _channel_event(\n+            \"<@U_BOT> thread reply\",\n+            ts=\"1700000000.000010\",\n+            thread_ts=\"1700000000.000009\",\n+        )\n+\n+        captured = []\n+        adapter.handle_message = AsyncMock(\n+            side_effect=lambda e: captured.append(e)\n+        )\n+        with patch.object(\n+            adapter, \"_resolve_user_name\",\n+            new=AsyncMock(return_value=\"testuser\"),\n+        ):\n+            await adapter._handle_slack_message(event)\n+\n+        assert len(captured) == 1, (\n+            f\"thread reply dropped with reply_in_thread={reply_in_thread}\"\n+        )\n+        assert captured[0].source.thread_id == \"1700000000.000009\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}