diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index f60b6beed0..3208a80a6a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -734,6 +734,10 @@ class SlackAdapter(BasePlatformAdapter): last_result = await self._get_client(chat_id).chat_postMessage(**kwargs) + # Clear Slack Assistant status as soon as the final message is posted. + if thread_ts: + await self.stop_typing(chat_id) + # Track the sent message ts so we can auto-respond to thread # replies without requiring @mention. sent_ts = last_result.get("ts") if last_result else None @@ -811,6 +815,8 @@ class SlackAdapter(BasePlatformAdapter): ts=message_id, text=formatted, ) + if finalize: + await self.stop_typing(chat_id) return SendResult(success=True, message_id=message_id) except Exception as e: # pragma: no cover - defensive logging logger.error( @@ -851,7 +857,7 @@ class SlackAdapter(BasePlatformAdapter): # in an assistant-enabled context. Falls back to reactions. logger.debug("[Slack] assistant.threads.setStatus failed: %s", e) - async def stop_typing(self, chat_id: str) -> None: + async def stop_typing(self, chat_id: str, metadata=None) -> None: """Clear the assistant thread status indicator.""" if not self._app: return diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index c45cc53a5b..0eebf49c88 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1153,6 +1153,104 @@ class TestSendTyping: status="is thinking...", ) + @pytest.mark.asyncio + async def test_stop_typing_clears_tracked_thread(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"}) + + await adapter.stop_typing("C123", metadata={"thread_id": "parent_ts"}) + + assert adapter._app.client.assistant_threads_setStatus.call_args_list[1] == call( + channel_id="C123", + thread_ts="parent_ts", + status="", + ) + assert "C123" not in adapter._active_status_threads + + @pytest.mark.asyncio + async def test_stop_typing_noop_without_tracked_thread(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + + await adapter.stop_typing("C123") + + adapter._app.client.assistant_threads_setStatus.assert_not_called() + + @pytest.mark.asyncio + async def test_stop_typing_handles_api_error_gracefully(self, adapter): + adapter._active_status_threads["C123"] = "parent_ts" + adapter._app.client.assistant_threads_setStatus = AsyncMock( + side_effect=Exception("missing_scope") + ) + + await adapter.stop_typing("C123") + + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="parent_ts", + status="", + ) + assert "C123" not in adapter._active_status_threads + + @pytest.mark.asyncio + async def test_send_clears_status_after_final_post(self, adapter): + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) + adapter._app.client.assistant_threads_setStatus = AsyncMock() + adapter._active_status_threads["C123"] = "parent_ts" + + result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"}) + + assert result.success + adapter._app.client.chat_postMessage.assert_called_once() + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="parent_ts", + status="", + ) + assert "C123" 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" + + result = await adapter.edit_message( + "C123", + "reply_ts", + "done", + finalize=True, + ) + + assert result.success + adapter._app.client.chat_update.assert_called_once_with( + channel="C123", + ts="reply_ts", + text="done", + ) + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="parent_ts", + status="", + ) + assert "C123" 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"] = "parent_ts" + + result = await adapter.edit_message( + "C123", + "reply_ts", + "partial", + finalize=False, + ) + + assert result.success + adapter._app.client.assistant_threads_setStatus.assert_not_called() + assert adapter._active_status_threads["C123"] == "parent_ts" + # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion