From 319e6615c32ac06113aeae0ed8fc69782d11ea0a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 12 Mar 2026 17:32:06 -0700 Subject: [PATCH] fix: Slack MAX_MESSAGE_LENGTH + typing indicator via assistant.threads.setStatus - Increase MAX_MESSAGE_LENGTH from 3,900 to 39,000 (Slack API allows 40k) - Implement real typing indicator using assistant.threads.setStatus API - Shows 'BotName is thinking...' next to the bot name in threads - Auto-clears when the bot sends a reply - Requires assistant:write or chat:write scope - Falls back silently if scope unavailable (reactions still work) - 4 new tests for typing indicator --- gateway/platforms/slack.py | 30 ++++++++++++++++++++----- tests/gateway/test_slack.py | 45 ++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 314cbbe9..b57dc854 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -66,7 +66,7 @@ class SlackAdapter(BasePlatformAdapter): - Typing indicators (not natively supported by Slack bots) """ - MAX_MESSAGE_LENGTH = 3900 # Slack hard limit is 4000 but leave room for mrkdwn + MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SLACK) @@ -216,12 +216,32 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(e)) async def send_typing(self, chat_id: str, metadata=None) -> None: - """Slack doesn't support typing indicators for bot users. + """Show a typing/status indicator using assistant.threads.setStatus. - The reactions system (👀 on receipt, ✅ on completion) serves as - the visual feedback mechanism instead. + Displays "is thinking..." next to the bot name in a thread. + Requires the assistant:write or chat:write scope. + Auto-clears when the bot sends a reply to the thread. """ - pass + if not self._app: + return + + thread_ts = None + if metadata: + thread_ts = metadata.get("thread_id") or metadata.get("thread_ts") + + if not thread_ts: + return # Can only set status in a thread context + + try: + await self._app.client.assistant_threads_setStatus( + channel_id=chat_id, + thread_ts=thread_ts, + status="is thinking...", + ) + except Exception as e: + # Silently ignore — may lack assistant:write scope or not be + # in an assistant-enabled context. Falls back to reactions. + logger.debug("[Slack] assistant.threads.setStatus failed: %s", e) def _resolve_thread_ts( self, diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index d2b7643e..e300728c 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -532,6 +532,49 @@ class TestMessageRouting: adapter.handle_message.assert_not_called() +# --------------------------------------------------------------------------- +# TestSendTyping — assistant.threads.setStatus +# --------------------------------------------------------------------------- + + +class TestSendTyping: + """Test typing indicator via assistant.threads.setStatus.""" + + @pytest.mark.asyncio + async def test_sets_status_in_thread(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"}) + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="parent_ts", + status="is thinking...", + ) + + @pytest.mark.asyncio + async def test_noop_without_thread(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + await adapter.send_typing("C123") + adapter._app.client.assistant_threads_setStatus.assert_not_called() + + @pytest.mark.asyncio + async def test_handles_missing_scope_gracefully(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock( + side_effect=Exception("missing_scope") + ) + # Should not raise + await adapter.send_typing("C123", metadata={"thread_id": "ts1"}) + + @pytest.mark.asyncio + async def test_uses_thread_ts_fallback(self, adapter): + adapter._app.client.assistant_threads_setStatus = AsyncMock() + await adapter.send_typing("C123", metadata={"thread_ts": "fallback_ts"}) + adapter._app.client.assistant_threads_setStatus.assert_called_once_with( + channel_id="C123", + thread_ts="fallback_ts", + status="is thinking...", + ) + + # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion # --------------------------------------------------------------------------- @@ -760,7 +803,7 @@ class TestMessageSplitting: @pytest.mark.asyncio async def test_long_message_split_into_chunks(self, adapter): """Messages over MAX_MESSAGE_LENGTH should be split.""" - long_text = "x" * 5000 + long_text = "x" * 45000 # Over Slack's 40k API limit adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "ts1"} )