From e40a38aa29facfa8e5e5a445bf9b51246101bd86 Mon Sep 17 00:00:00 2001 From: Doruk Ardahan Date: Thu, 23 Jul 2026 07:56:10 -0700 Subject: [PATCH] fix(slack): avoid assistant status on synthetic top-level threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reply_in_thread=false, top-level channel events carry their own message ts as metadata.thread_id for session keying. Calling assistant.threads.setStatus on that ts activated a Slack assistant thread ('is thinking...') before the actual response was sent, and the flat reply then never cleared it. send_typing now routes through the same _resolve_thread_ts synthetic- thread guard as message sending, and the gateway threads message_id through progress/status metadata so the adapter can distinguish real threads from synthetic top-level session keys. Reapplied from #18859-sibling PR #17184 by @dorukardahan (both commits: fix + progress-metadata test) onto current main via 3-way apply — the original patched gateway/platforms/slack.py, moved to plugins/platforms/slack/adapter.py in the plugin migration. --- gateway/run.py | 24 +++++++++++++++++-- plugins/platforms/slack/adapter.py | 10 +++++++- tests/gateway/test_run_progress_topics.py | 8 +++++-- tests/gateway/test_slack.py | 29 +++++++++++++++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3363631d5172..6b2989d97f52 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16279,6 +16279,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew metadata["direct_messages_topic_id"] = tid if reply_to_message_id is not None: metadata["telegram_reply_to_message_id"] = str(reply_to_message_id) + if platform == Platform.SLACK and reply_to_message_id is not None: + # Slack's reply_in_thread=false path uses message_id to distinguish + # real existing threads from synthetic top-level session keys. + metadata["message_id"] = str(reply_to_message_id) return metadata @staticmethod @@ -20054,7 +20058,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _progress_metadata = ( self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id == source.thread_id - else {"thread_id": _progress_thread_id} + else self._thread_metadata_for_target( + source.platform, + source.chat_id, + _progress_thread_id, + chat_type=getattr(source, "chat_type", None), + reply_to_message_id=event_message_id, + ) ) if _progress_thread_id else None _progress_metadata = _non_conversational_metadata(_progress_metadata, platform=source.platform) _progress_reply_to = ( @@ -20517,7 +20527,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "reply_to_message_id": event_message_id, } else: - _status_thread_metadata = self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id else None + _status_thread_metadata = ( + self._thread_metadata_for_source(source, event_message_id) + if _progress_thread_id == source.thread_id + else self._thread_metadata_for_target( + source.platform, + source.chat_id, + _progress_thread_id, + chat_type=getattr(source, "chat_type", None), + reply_to_message_id=event_message_id, + ) + ) if _progress_thread_id else None def _status_callback_sync(event_type: str, message: str) -> None: if not _status_adapter or not _run_still_current(): diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 8a4d04eb3aa4..1efb23ab7e41 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -2536,7 +2536,15 @@ class SlackAdapter(BasePlatformAdapter): thread_ts = None if metadata: - thread_ts = metadata.get("thread_id") or metadata.get("thread_ts") + # Reuse the same synthetic-thread guard as message sending. When + # reply_in_thread=false, top-level channel events carry their own + # message ts as metadata.thread_id for session keying. Calling + # assistant_threads_setStatus on that ts activates a Slack assistant + # thread before the actual response is sent. + thread_ts = self._resolve_thread_ts( + reply_to=metadata.get("message_id"), + metadata=metadata, + ) if not thread_ts: return # Can only set status in a thread context diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 6e7926588db7..1abfbbe7e64c 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -521,8 +521,12 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch assert result["final_response"] == "done" assert adapter.sent - assert adapter.sent[0]["metadata"] == {"thread_id": "1234567890.000001"} - assert all(call["metadata"] == {"thread_id": "1234567890.000001"} for call in adapter.typing) + expected_metadata = { + "thread_id": "1234567890.000001", + "message_id": "1234567890.000001", + } + assert adapter.sent[0]["metadata"] == expected_metadata + assert all(call["metadata"] == expected_metadata for call in adapter.typing) @pytest.mark.asyncio diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 5cb322c848f6..c6a0e81462ba 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -3398,6 +3398,35 @@ class TestSendTyping: status="is thinking...", ) + @pytest.mark.asyncio + async def test_skips_status_for_synthetic_top_level_when_reply_in_thread_false(self, adapter): + adapter.config.extra["reply_in_thread"] = False + adapter._app.client.assistant_threads_setStatus = AsyncMock() + + await adapter.send_typing( + "C123", + metadata={"thread_id": "171.000", "message_id": "171.000"}, + ) + + adapter._app.client.assistant_threads_setStatus.assert_not_called() + assert adapter._active_status_threads == {} + + @pytest.mark.asyncio + async def test_sets_status_for_real_thread_when_reply_in_thread_false(self, adapter): + adapter.config.extra["reply_in_thread"] = False + adapter._app.client.assistant_threads_setStatus = AsyncMock() + + await adapter.send_typing( + "C123", + metadata={"thread_id": "171.000", "message_id": "171.500"}, + ) + + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="171.000", + status="is thinking...", + ) + @pytest.mark.asyncio async def test_stop_typing_clears_tracked_thread(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock()