From 1f6be2ec83910abb09b33a16a933dcbcdbcb45c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:53:20 +0000 Subject: [PATCH 1/5] feat(whatsapp): typing indicator support (vercel/chat#320) Port of upstream ffc43fc: - start_typing resolves the latest inbound message ID from the ThreadHistoryCache and posts a typing_indicator payload (which also marks the referenced message as read); no-ops with a warning when no inbound message context exists - Graph API default version bumped v21.0 -> v25.0 - _graph_api_request and the typing-indicator failure path raise AdapterError instead of RuntimeError/plain Error - new WhatsAppTypingIndicatorResponse TypedDict https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/whatsapp/adapter.py | 68 ++++++++++++-- src/chat_sdk/adapters/whatsapp/types.py | 8 +- tests/test_whatsapp_adapter.py | 24 +++++ tests/test_whatsapp_webhook.py | 104 ++++++++++++++++++++-- 4 files changed, 191 insertions(+), 13 deletions(-) diff --git a/src/chat_sdk/adapters/whatsapp/adapter.py b/src/chat_sdk/adapters/whatsapp/adapter.py index f64ad309..c50a39b6 100644 --- a/src/chat_sdk/adapters/whatsapp/adapter.py +++ b/src/chat_sdk/adapters/whatsapp/adapter.py @@ -39,7 +39,8 @@ from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_unicode, get_emoji from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.shared.adapter_utils import extract_card -from chat_sdk.shared.errors import ValidationError +from chat_sdk.shared.errors import AdapterError, ValidationError +from chat_sdk.thread_history import ThreadHistoryCache from chat_sdk.types import ( ActionEvent, AdapterPostableMessage, @@ -64,7 +65,7 @@ ) # Default Graph API version -DEFAULT_API_VERSION = "v21.0" +DEFAULT_API_VERSION = "v25.0" # Maximum message length for WhatsApp Cloud API WHATSAPP_MESSAGE_LIMIT = 4096 @@ -944,8 +945,48 @@ async def remove_reaction( ) async def start_typing(self, thread_id: str, status: str | None = None) -> None: - """Start typing indicator. Not supported by WhatsApp Cloud API.""" - pass + """Start typing indicator. + + WhatsApp typing indicators require the most recent inbound message ID. + They also implicitly mark the referenced message as read. + + See: https://developers.facebook.com/documentation/business-messaging/whatsapp/typing-indicators + """ + message_id = await self._resolve_typing_target_message_id(thread_id) + self._logger.debug( + "WhatsApp typing indicator requested", + {"messageId": message_id, "threadId": thread_id}, + ) + + if not message_id: + self._logger.warn( + "WhatsApp typing indicator skipped - no inbound message context", + {"threadId": thread_id}, + ) + return + + if status: + self._logger.warn( + "WhatsApp typing indicator ignores custom status text", + {"status": status, "threadId": thread_id, "messageId": message_id}, + ) + + response = await self._graph_api_request( + f"/{self._phone_number_id}/messages", + { + "messaging_product": "whatsapp", + "status": "read", + "message_id": message_id, + "typing_indicator": {"type": "text"}, + }, + ) + + if not response.get("success"): + self._logger.error( + "WhatsApp typing indicator failed: API returned success=false", + {"messageId": message_id, "threadId": thread_id}, + ) + raise AdapterError("WhatsApp typing indicator failed", "whatsapp") async def fetch_messages( self, @@ -1064,6 +1105,20 @@ async def mark_as_read(self, message_id: str) -> None: # Private helpers # ========================================================================= + async def _resolve_typing_target_message_id(self, thread_id: str) -> str | None: + """Resolve the latest inbound message ID for a thread.""" + if not self._chat: + return None + + state = self._chat.get_state() + history = await ThreadHistoryCache(state).get_messages(thread_id) + + for message in reversed(history): + if not message.author.is_me: + return message.id + + return None + async def _graph_api_request(self, path: str, body: Any) -> Any: """Make a request to the Meta Graph API.""" session = await self._get_http_session() @@ -1085,7 +1140,10 @@ async def _graph_api_request(self, path: str, body: Any) -> Any: "path": path, }, ) - raise RuntimeError(f"WhatsApp API error: {response.status} {error_body}") + raise AdapterError( + f"WhatsApp API error: {response.status} {error_body}", + "whatsapp", + ) return await response.json() diff --git a/src/chat_sdk/adapters/whatsapp/types.py b/src/chat_sdk/adapters/whatsapp/types.py index 551920d7..3269c7aa 100644 --- a/src/chat_sdk/adapters/whatsapp/types.py +++ b/src/chat_sdk/adapters/whatsapp/types.py @@ -38,7 +38,7 @@ class WhatsAppAdapterConfig: user_name: str # Verify token for webhook challenge-response verification verify_token: str - # Meta Graph API version (default: "v21.0") + # Meta Graph API version (default: "v25.0") api_version: str | None = None @@ -209,6 +209,12 @@ class WhatsAppSendResponse(TypedDict): messaging_product: str # "whatsapp" +class WhatsAppTypingIndicatorResponse(TypedDict): + """Response from sending a typing indicator via the Cloud API.""" + + success: bool + + class WhatsAppInteractiveButtonReply(TypedDict): """A single reply button for interactive messages.""" diff --git a/tests/test_whatsapp_adapter.py b/tests/test_whatsapp_adapter.py index f3d08516..fc63e072 100644 --- a/tests/test_whatsapp_adapter.py +++ b/tests/test_whatsapp_adapter.py @@ -363,3 +363,27 @@ def test_adapter_properties(self): assert adapter.lock_scope == "channel" assert adapter.persist_thread_history is True assert adapter.bot_user_id is None # not yet initialized + + def test_default_api_version_is_v25(self): + """vercel/chat#320 bumped the default Graph API version to v25.0. + + Upstream pins this through its custom-apiUrl factory tests; our + config has no apiUrl override, so assert the default URL directly + (an explicit api_version must still win). + """ + adapter = create_whatsapp_adapter( + access_token="tok", + app_secret="sec", + phone_number_id="123", + verify_token="vt", + ) + assert adapter._graph_api_url == "https://graph.facebook.com/v25.0" + + pinned = create_whatsapp_adapter( + access_token="tok", + app_secret="sec", + phone_number_id="123", + verify_token="vt", + api_version="v21.0", + ) + assert pinned._graph_api_url == "https://graph.facebook.com/v21.0" diff --git a/tests/test_whatsapp_webhook.py b/tests/test_whatsapp_webhook.py index 93cfcf3d..9d18cd32 100644 --- a/tests/test_whatsapp_webhook.py +++ b/tests/test_whatsapp_webhook.py @@ -13,7 +13,7 @@ import json from dataclasses import dataclass from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -652,12 +652,6 @@ async def test_open_dm_returns_thread_id(self): tid = await adapter.open_dm("15551234567") assert tid == "whatsapp:123456789:15551234567" - @pytest.mark.asyncio - async def test_start_typing_noop(self): - adapter = _make_adapter() - result = await adapter.start_typing("whatsapp:123456789:15551234567") - assert result is None - def test_is_dm_always_true(self): adapter = _make_adapter() assert adapter.is_dm("whatsapp:123456789:15551234567") is True @@ -668,6 +662,102 @@ def test_channel_id_from_thread_id(self): assert result == "whatsapp:123456789:15551234567" +# --------------------------------------------------------------------------- +# startTyping (vercel/chat#320 — typing indicator via the Cloud API) +# --------------------------------------------------------------------------- + + +def _serialized_history_message(message_id: str, *, is_me: bool) -> dict[str, Any]: + """Build a serialized Message dict as stored by ThreadHistoryCache.""" + user_id = "123456789" if is_me else "15551234567" + name = "bot" if is_me else "User" + return { + "_type": "chat:Message", + "id": message_id, + "threadId": "whatsapp:123456789:15551234567", + "text": "Hello" if is_me else "Hi", + "author": { + "userId": user_id, + "userName": name, + "fullName": name, + "isMe": is_me, + "isBot": is_me, + }, + "formatted": {"type": "root", "children": []}, + "attachments": [], + "metadata": {"dateSent": "2026-06-01T00:00:00Z", "edited": False}, + } + + +def _make_chat_with_history(history: list[dict[str, Any]]) -> MagicMock: + """Mock ChatInstance whose state returns the given thread history.""" + state = MagicMock() + state.get_list = AsyncMock(return_value=history) + chat = MagicMock() + chat.get_state = MagicMock(return_value=state) + chat._state = state + return chat + + +class TestStartTyping: + """Port of upstream describe("startTyping") (vercel/chat#320).""" + + THREAD_ID = "whatsapp:123456789:15551234567" + + @pytest.mark.asyncio + async def test_resolves_latest_inbound_message_id_and_sends_typing_indicator(self): + adapter = _make_adapter() + # History: 1 inbound message, then 1 outbound (bot) message. The + # typing indicator must target the latest *inbound* message, skipping + # the bot's own newer reply. + chat = _make_chat_with_history( + [ + _serialized_history_message("wamid.inbound123", is_me=False), + _serialized_history_message("wamid.outbound456", is_me=True), + ] + ) + await adapter.initialize(chat) + adapter._graph_api_request = AsyncMock(return_value={"success": True}) + + await adapter.start_typing(self.THREAD_ID) + + # History is read through the ThreadHistoryCache key contract. + chat._state.get_list.assert_awaited_once_with(f"msg-history:{self.THREAD_ID}") + + adapter._graph_api_request.assert_awaited_once() + path, body = adapter._graph_api_request.call_args[0] + assert path == "/123456789/messages" + assert body["status"] == "read" + assert body["message_id"] == "wamid.inbound123" + assert body["typing_indicator"]["type"] == "text" + + @pytest.mark.asyncio + async def test_does_nothing_if_no_inbound_message_is_found_in_history(self): + adapter = _make_adapter() + chat = _make_chat_with_history([]) + await adapter.initialize(chat) + adapter._graph_api_request = AsyncMock(return_value={"success": True}) + + await adapter.start_typing(self.THREAD_ID) + + adapter._graph_api_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_start_typing_raises_adapter_error_when_api_reports_failure(self): + """Python-only branch coverage: success=false from the Cloud API must + surface as an AdapterError (upstream throws AdapterError too but has + no test for it).""" + from chat_sdk.shared.errors import AdapterError + + adapter = _make_adapter() + chat = _make_chat_with_history([_serialized_history_message("wamid.inbound123", is_me=False)]) + await adapter.initialize(chat) + adapter._graph_api_request = AsyncMock(return_value={"success": False}) + + with pytest.raises(AdapterError, match="typing indicator failed"): + await adapter.start_typing(self.THREAD_ID) + + # --------------------------------------------------------------------------- # editMessage / deleteMessage -- not supported # --------------------------------------------------------------------------- From 35bdc33936065249518e5a32d2b0f00eca405820 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:02:58 +0000 Subject: [PATCH 2/5] fix(gchat): collapse redundant autolink formatting for email links (vercel/chat#553) Port of upstream 177735a: when a link node's visible label equals its URL minus a mailto:/tel: scheme (e.g. an autolinked email address), from_ast emits the bare value as plain text instead of the verbose form. Labels differing from the address keep . The collapse sits between the linkText==url shortcut and this port's documented divergent branches (empty-label bare-URL emit and the text-(url) fallback for unsafe labels/URLs), matching upstream's check order; when it fires the output is plain text, so it never produces the malformed forms those divergences guard against. Our subset parser does not autolink bare emails/URLs (Known Limitations), so the ported collapse tests build the autolink-shaped node via the explicit markdown form, plus a Python-only round-trip test through the divergent to_ast path. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- .../adapters/google_chat/format_converter.py | 11 +++ tests/test_gchat_format_extended.py | 72 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index bce584d7..897ddd28 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -298,6 +298,17 @@ def _node_to_gchat(self, node: Content) -> str: url = node.get("url", "") if link_text == url: return url + # Collapse redundant mailto:/tel: autolink formatting + # (vercel/chat#553): when the visible label already equals the + # URL minus its scheme (e.g. an autolinked email address), emit + # the bare value as plain text instead of the verbose + # `` form. + for scheme in ("mailto:", "tel:"): + if not url.startswith(scheme): + continue + bare_value = url[len(scheme) :] + if bare_value == link_text: + return bare_value # An empty label can't round-trip in either `` or # `text (url)` form (the parser regex requires ≥1 char in the # label, and "(url)" alone reads as a parenthetical). Emit the diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index caa6d4e4..98a4c130 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -325,6 +325,78 @@ def test_link_different_text_and_url(self): result = converter.from_ast(ast) assert "" in result + def test_collapses_mailto_autolink_for_plain_email_text(self): + """Port of upstream "collapses mailto autolink for plain email text" + (vercel/chat#553). Upstream's remark parser autolinks the bare input + "hello@example.com" into a `mailto:` link node; our subset parser + keeps bare emails as plain text (Known Limitations), so the same + autolink-shaped node is built through the explicit markdown form to + exercise the collapse.""" + converter = _converter() + ast = converter.to_ast("[hello@example.com](mailto:hello@example.com)") + + result = converter.from_ast(ast) + + assert result == "hello@example.com" + + def test_collapses_mailto_autolink_on_gchat_wire_round_trip(self): + """Python-only composition with the documented `` to_ast + divergence: a redundant `` read back from the + Google Chat wire parses to a link node (upstream leaves it as raw + text) and must now collapse to the bare address on re-emit instead + of round-tripping the verbose form.""" + converter = _converter() + ast = converter.to_ast("") + + result = converter.from_ast(ast) + + assert result == "hello@example.com" + + def test_preserves_custom_label_for_mailto_links(self): + """Port of upstream "preserves custom label for mailto links" + (vercel/chat#553): a label that differs from the address keeps the + `` form.""" + converter = _converter() + ast = converter.to_ast("[contact](mailto:hello@example.com)") + + result = converter.from_ast(ast) + + assert result == "" + + def test_formats_http_links_correctly(self): + """Port of upstream "formats http links correctly" (vercel/chat#553). + Upstream autolinks the bare URL to a link node whose text equals its + URL and re-emits it bare; our parser keeps bare URLs as plain text — + the output contract is identical either way.""" + converter = _converter() + ast = converter.to_ast("https://example.com") + + result = converter.from_ast(ast) + + assert result == "https://example.com" + + def test_keeps_phone_numbers_as_plain_text(self): + """Port of upstream "keeps phone numbers as plain text" + (vercel/chat#553): plain phone numbers are not autolinked by either + parser and must survive unchanged.""" + converter = _converter() + ast = converter.to_ast("+1555123456") + + result = converter.from_ast(ast) + + assert result == "+1555123456" + + def test_collapses_tel_autolink_for_plain_phone_text(self): + """Port of upstream "collapses tel autolink for plain phone text" + (vercel/chat#553): a tel: link whose label equals the number + collapses to the bare number.""" + converter = _converter() + ast = converter.to_ast("[+1555123456](tel:+1555123456)") + + result = converter.from_ast(ast) + + assert result == "+1555123456" + def test_should_preserve_custom_link_labels_in_posted_messages(self): # Matches the integration-tests parity case: a posted markdown link # with a custom label must render as Google Chat's syntax From 183831d09ba3bdbca411398be96cd417524d0dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 22:58:49 +0000 Subject: [PATCH 3/5] fix(slack): resolve reaction user display names (vercel/chat#523) Port of upstream b63c042: reaction events now resolve event.user.user_name / full_name / is_bot from the cached _lookup_user() (users.info) path instead of echoing the raw Slack user ID, falling back to the user ID (and is_bot=False) when lookup fails. Also completes the dispatch-key test's mock client/state contract (users_info + async state methods), mirroring the upstream test update - a bare AsyncMock's auto-children made users.info results async and leaked an orphaned coroutine through the new lookup. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/slack/adapter.py | 16 ++++- tests/test_dispatch_key_validation.py | 20 +++++- tests/test_slack_webhook_extended.py | 98 ++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index a2920b47..024e02cd 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -2642,15 +2642,25 @@ async def _resolve_and_process() -> None: thread_id = self.encode_thread_id(SlackThreadId(channel=channel, thread_ts=parent_ts)) + # Resolve display names from the cached users.info lookup so + # reaction handlers see real names instead of raw user IDs + # (vercel/chat#523). Falls back to the user ID on lookup failure. + user_info = await self._lookup_user(user_id) + display_name = user_info.get("display_name") + user_name = display_name if display_name is not None else user_id + real_name = user_info.get("real_name") + full_name = real_name if real_name is not None else user_name + is_bot = user_info.get("is_bot") + reaction_event = ReactionEvent( emoji=normalized_emoji, raw_emoji=raw_emoji, added=event.get("type") == "reaction_added", user=Author( user_id=user_id, - user_name=user_id, - full_name=user_id, - is_bot=False, + user_name=user_name, + full_name=full_name, + is_bot=is_bot if is_bot is not None else False, is_me=is_me, ), message_id=message_id, diff --git a/tests/test_dispatch_key_validation.py b/tests/test_dispatch_key_validation.py index 78c836c2..9acccde2 100644 --- a/tests/test_dispatch_key_validation.py +++ b/tests/test_dispatch_key_validation.py @@ -99,9 +99,14 @@ def _make_mock_chat() -> MagicMock: mock.process_assistant_context_changed = MagicMock() mock.process_app_home_opened = MagicMock() mock.process_member_joined_channel = MagicMock() - # get_state needed by some adapters + # get_state needed by some adapters. All StateAdapter methods are + # async — configure them explicitly so adapter code paths that cache + # lookups (e.g. Slack `_lookup_user`) don't await MagicMock results. mock_state = MagicMock() mock_state.get = AsyncMock(return_value=None) + mock_state.set = AsyncMock() + mock_state.get_list = AsyncMock(return_value=[]) + mock_state.append_to_list = AsyncMock() mock.get_state = MagicMock(return_value=mock_state) mock.get_logger = MagicMock(return_value=MagicMock()) mock.get_user_name = MagicMock(return_value="bot") @@ -175,8 +180,11 @@ async def test_slack_reaction_dispatch_keys(self) -> None: } # The Slack reaction handler is async (it launches a task to resolve - # the parent thread_ts). We need to mock the Slack client so the - # async resolution succeeds. + # the parent thread_ts and the reacting user's display name). We + # need to mock the Slack client so the async resolution succeeds. + # The users.info mock mirrors the upstream test update for + # vercel/chat#523 — auto-children of a bare AsyncMock are async, so + # `result.get(...)` would otherwise return an orphaned coroutine. mock_client = AsyncMock() mock_client.conversations_replies = AsyncMock( return_value={ @@ -185,6 +193,12 @@ async def test_slack_reaction_dispatch_keys(self) -> None: ], } ) + mock_client.users_info = AsyncMock( + return_value={ + "ok": True, + "user": {"name": "user", "profile": {"display_name": "User"}}, + } + ) adapter._get_client = MagicMock(return_value=mock_client) adapter._handle_reaction_event(event) diff --git a/tests/test_slack_webhook_extended.py b/tests/test_slack_webhook_extended.py index 63f0daa4..a7ecc3db 100644 --- a/tests/test_slack_webhook_extended.py +++ b/tests/test_slack_webhook_extended.py @@ -8,6 +8,7 @@ from __future__ import annotations +import asyncio import base64 import hashlib import hmac @@ -642,6 +643,103 @@ async def test_handles_reaction_removed_event(self): response = await adapter.handle_webhook(req) assert response["status"] == 200 + @pytest.mark.asyncio + async def test_resolves_reaction_user_display_name(self): + """Port of upstream "resolves reaction user display name" + (vercel/chat#523): reaction events must carry the resolved + users.info display/real names instead of the raw user ID.""" + adapter = _make_adapter(bot_user_id="U_BOT") + state = _make_mock_state() + chat = _make_mock_chat(state) + await adapter.initialize(chat) + + mock_client = MagicMock() + mock_client.conversations_replies = AsyncMock( + return_value={"ok": True, "messages": [{"ts": "1234567890.123456"}]} + ) + mock_client.users_info = AsyncMock( + return_value={ + "ok": True, + "user": { + "id": "U123", + "name": "alice", + "real_name": "Alice Example", + "profile": {"display_name": "Alice", "real_name": "Alice Example"}, + }, + } + ) + adapter._get_client = MagicMock(return_value=mock_client) + + body = json.dumps( + { + "type": "event_callback", + "event": { + "type": "reaction_added", + "user": "U123", + "reaction": "thumbsup", + "item": { + "type": "message", + "channel": "C456", + "ts": "1234567890.123456", + }, + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + # The reaction handler resolves names in a background task. + await asyncio.sleep(0.05) + + mock_client.users_info.assert_awaited_once_with(user="U123") + assert chat.process_reaction.called + reaction_event = chat.process_reaction.call_args[0][0] + assert reaction_event.user.user_id == "U123" + assert reaction_event.user.user_name == "Alice" + assert reaction_event.user.full_name == "Alice Example" + # users.info returned no is_bot flag -> coalesces to False. + assert reaction_event.user.is_bot is False + + @pytest.mark.asyncio + async def test_reaction_user_names_fall_back_to_user_id_when_lookup_fails(self): + """Upstream falls back to the raw user ID when lookupUser fails; + our `_lookup_user` failure path must surface the same fallback.""" + adapter = _make_adapter(bot_user_id="U_BOT") + state = _make_mock_state() + chat = _make_mock_chat(state) + await adapter.initialize(chat) + + mock_client = MagicMock() + mock_client.conversations_replies = AsyncMock( + return_value={"ok": True, "messages": [{"ts": "1234567890.123456"}]} + ) + mock_client.users_info = AsyncMock(side_effect=RuntimeError("users.info unavailable")) + adapter._get_client = MagicMock(return_value=mock_client) + + body = json.dumps( + { + "type": "event_callback", + "event": { + "type": "reaction_added", + "user": "U999", + "reaction": "eyes", + "item": { + "type": "message", + "channel": "C456", + "ts": "1234567890.123456", + }, + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + await asyncio.sleep(0.05) + + assert chat.process_reaction.called + reaction_event = chat.process_reaction.call_args[0][0] + assert reaction_event.user.user_name == "U999" + assert reaction_event.user.full_name == "U999" + assert reaction_event.user.is_bot is False + # --------------------------------------------------------------------------- # Member joined channel From 0eb628468c4a28df479f4e338d0e59e13245cda0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 23:01:11 +0000 Subject: [PATCH 4/5] fix(slack): pass token through native stream stop (vercel/chat#573) Port of upstream 999d268: SlackAdapter.stream() now passes the resolved bot token on every streamer.append() (markdown deltas and structured chunks) and on streamer.stop(), instead of only on the first append. Fixes not_authed from chat.startStream/chat.stopStream when a stream reaches stop() before a token-bearing append has flushed (e.g. fully buffered markdown). Composes with the existing multi-workspace plumbing: token is resolved once at stream entry via _get_token() (request-context installation token -> per-request resolved default -> static cache), then threaded through all streamer calls; a Python-only regression test locks the request-context token flowing through append and stop. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/slack/adapter.py | 27 ++++----- tests/test_slack_api.py | 84 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 024e02cd..bd67e315 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -3869,7 +3869,6 @@ async def stream( streamer = await client.chat_stream(**stream_kwargs) - first = True last_appended = "" # Use StreamingMarkdownRenderer for safe incremental rendering @@ -3878,18 +3877,20 @@ async def stream( renderer = StreamingMarkdownRenderer(wrap_tables_for_append=False) structured_chunks_supported = True + # The resolved bot token is passed on EVERY append and on stop + # (vercel/chat#573). Passing it only on the first append left + # chat.startStream/chat.stopStream unauthenticated ("not_authed") + # whenever the stream reached stop() before a token-bearing append + # had flushed (e.g. fully buffered markdown). In multi-workspace + # mode `token` is the per-request installation token resolved by + # _get_token() at stream entry. async def flush_markdown_delta(delta: str) -> None: - nonlocal first if not delta: return - if first: - await streamer.append(markdown_text=delta, token=token) - first = False - else: - await streamer.append(markdown_text=delta) + await streamer.append(markdown_text=delta, token=token) async def send_structured_chunk(chunk: StreamChunk | dict[str, Any]) -> None: - nonlocal first, last_appended, structured_chunks_supported + nonlocal last_appended, structured_chunks_supported if not structured_chunks_supported: return committable = renderer.get_committable_text() @@ -3917,11 +3918,7 @@ def _read(name: str) -> Any: if value is not None: chunk_data[field_name] = value - if first: - await streamer.append(chunks=[chunk_data], token=token) - first = False - else: - await streamer.append(chunks=[chunk_data]) + await streamer.append(chunks=[chunk_data], token=token) except Exception as exc: structured_chunks_supported = False self._logger.warn( @@ -3961,10 +3958,10 @@ async def push_text_and_flush(text: str) -> None: final_delta = final_committable[len(last_appended) :] await flush_markdown_delta(final_delta) - stop_kwargs: dict[str, Any] = {} + stop_kwargs: dict[str, Any] = {"token": token} if options.stop_blocks: stop_kwargs["blocks"] = options.stop_blocks - result = await streamer.stop(**stop_kwargs) if stop_kwargs else await streamer.stop() + result = await streamer.stop(**stop_kwargs) message_ts = "" if isinstance(result, dict): diff --git a/tests/test_slack_api.py b/tests/test_slack_api.py index d39d7bad..b0c0dc91 100644 --- a/tests/test_slack_api.py +++ b/tests/test_slack_api.py @@ -1315,6 +1315,90 @@ async def text_gen() -> AsyncIterator[str]: client.chat_stream.assert_awaited_once() assert mock_streamer.append.await_count >= 1 + @pytest.mark.asyncio + async def test_passes_token_on_stream_stop(self): + """Port of upstream "passes token on stream stop" (vercel/chat#573): + stop() must always carry the resolved bot token so chat.stopStream + can't hit not_authed when no token-bearing append flushed first.""" + adapter, client, _ = await _init_adapter() + + mock_streamer = MagicMock() + mock_streamer.append = AsyncMock(return_value=None) + mock_streamer.stop = AsyncMock(return_value={"ok": True, "ts": "1234567890.111111"}) + client.chat_stream = AsyncMock(return_value=mock_streamer) + + async def short_stream() -> AsyncIterator[str]: + yield "hello" + + await adapter.stream( + "slack:C123:1234567890.000000", + short_stream(), + StreamOptions(recipient_user_id="U123", recipient_team_id="T123"), + ) + + mock_streamer.stop.assert_awaited_once() + assert mock_streamer.stop.call_args.kwargs["token"] == "xoxb-test-token" + # No stop_blocks were supplied, so the optional key must be omitted. + assert "blocks" not in mock_streamer.stop.call_args.kwargs + + @pytest.mark.asyncio + async def test_passes_token_on_every_stream_append(self): + """Port of upstream "passes token on every stream append" + (vercel/chat#573): repeated structured chunk appends each carry the + token, not just the first.""" + adapter, client, _ = await _init_adapter() + + mock_streamer = MagicMock() + mock_streamer.append = AsyncMock(return_value={"ok": True}) + mock_streamer.stop = AsyncMock(return_value={"ok": True, "ts": "1234567890.111111"}) + client.chat_stream = AsyncMock(return_value=mock_streamer) + + from chat_sdk.types import TaskUpdateChunk + + async def chunk_stream() -> AsyncIterator[StreamChunk | str]: + yield TaskUpdateChunk(id="task-1", title="Task one", status="in_progress", output="first") + yield TaskUpdateChunk(id="task-2", title="Task two", status="in_progress", output="second") + + await adapter.stream( + "slack:C123:1234567890.000000", + chunk_stream(), + StreamOptions(recipient_user_id="U123", recipient_team_id="T123"), + ) + + assert mock_streamer.append.await_count == 2 + for call in mock_streamer.append.call_args_list: + assert call.kwargs["token"] == "xoxb-test-token" + + @pytest.mark.asyncio + async def test_stream_uses_request_context_token_for_append_and_stop(self): + """Multi-workspace composition: the token resolved from the + per-request context (installation token) must flow through every + append and the stop — not the default bot token. Python-only + regression for the documented bot_token resolver plumbing.""" + adapter, client, _ = await _init_adapter() + + mock_streamer = MagicMock() + mock_streamer.append = AsyncMock(return_value={"ok": True}) + mock_streamer.stop = AsyncMock(return_value={"ok": True, "ts": "1234567890.222222"}) + client.chat_stream = AsyncMock(return_value=mock_streamer) + + async def text_gen() -> AsyncIterator[str]: + yield "workspace-scoped hello" + + await adapter.with_bot_token_async( + "xoxb-workspace-token", + lambda: adapter.stream( + "slack:C123:1234567890.000000", + text_gen(), + StreamOptions(recipient_user_id="U123", recipient_team_id="T123"), + ), + ) + + assert mock_streamer.append.await_count >= 1 + for call in mock_streamer.append.call_args_list: + assert call.kwargs["token"] == "xoxb-workspace-token" + assert mock_streamer.stop.call_args.kwargs["token"] == "xoxb-workspace-token" + # ============================================================================= # Public request-context accessors (issue #47) From c113931a10e5627cfe2b5230630570daabb99fba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 08:29:59 +0000 Subject: [PATCH 5/5] fix(slack): emit uploadedFileIds (camelCase) on raw to match upstream chat@4.30.0 Our Python-only "file-ids" surface (Slack-confirmed upload IDs exposed on RawMessage.raw so consumers can gate on actual delivery) was adopted upstream in chat@4.30.0, which emits the synthetic key as camelCase `uploadedFileIds`. We were emitting snake_case `uploaded_file_ids`, leaving a divergence at the serialization boundary. Rename the emitted dict key to `uploadedFileIds` so consumers reading the raw payload match the upstream/TS surface. The local variable stays snake_case (`uploaded_file_ids`) per the "snake_case internal, camelCase at boundary" rule; only the key merged into `raw` changes. Behavior is otherwise identical (None -> raw unchanged; empty list preserved as the zero-attachments signal). Updates the three Python-only tests that asserted the old key name (test_slack_api file-upload tests and the test_thread_faithful raw- propagation test, which uses Slack's surface as its example payload). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --- src/chat_sdk/adapters/slack/adapter.py | 19 +++++++++++++------ tests/test_slack_api.py | 8 ++++---- tests/test_thread_faithful.py | 6 +++--- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index bd67e315..d1841675 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -3571,10 +3571,12 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> # Check for files to upload. ``files_upload_v2`` returns the # Slack-confirmed file IDs; we surface them on ``RawMessage.raw`` - # so consumers can gate on actual delivery (parity with - # discord/telegram, which upload inline and expose the platform - # response naturally). ``None`` means no upload happened; an empty - # list means Slack confirmed zero attachments (a real signal). + # under the camelCase ``uploadedFileIds`` key (upstream + # chat@4.30.0 adopted this same surface) so consumers can + # gate on actual delivery (parity with discord/telegram, which + # upload inline and expose the platform response naturally). + # ``None`` means no upload happened; an empty list means Slack + # confirmed zero attachments (a real signal). uploaded_file_ids: list[str] | None = None files = extract_files(message) if files: @@ -3651,14 +3653,19 @@ def _augment_raw_with_uploads(raw: Any, uploaded_file_ids: list[str] | None) -> Returns ``raw`` unchanged when no upload occurred (``uploaded_file_ids`` is ``None``). Otherwise returns a NEW dict that merges the existing raw - (Slack never returns an ``uploaded_file_ids`` key, so this is additive + (Slack never returns an ``uploadedFileIds`` key, so this is additive and non-breaking) with the confirmed IDs. An empty list is preserved — it signals that Slack confirmed zero attachments. + + The key is emitted in camelCase (``uploadedFileIds``) to match the + surface upstream adopted in chat@4.30.0; ``uploaded_file_ids`` is the + internal (snake_case) variable, camelCase only at this serialization + boundary. """ if uploaded_file_ids is None: return raw base = raw if isinstance(raw, dict) else {} - return {**base, "uploaded_file_ids": uploaded_file_ids} + return {**base, "uploadedFileIds": uploaded_file_ids} async def edit_message( self, diff --git a/tests/test_slack_api.py b/tests/test_slack_api.py index b0c0dc91..8f7b9ec7 100644 --- a/tests/test_slack_api.py +++ b/tests/test_slack_api.py @@ -254,7 +254,7 @@ async def test_file_only_post_surfaces_confirmed_upload_ids(self): result = await adapter.post_message("slack:C123:1234567890.000000", msg) assert isinstance(result.raw, dict) - assert result.raw["uploaded_file_ids"] == ["F1", "F2"] + assert result.raw["uploadedFileIds"] == ["F1", "F2"] # The original raw payload is preserved (augment, don't replace). assert "files" in result.raw @@ -278,20 +278,20 @@ async def test_text_with_files_surfaces_confirmed_upload_ids(self): assert result.id == "1234567890.222222" assert isinstance(result.raw, dict) - assert result.raw["uploaded_file_ids"] == ["F9"] + assert result.raw["uploadedFileIds"] == ["F9"] # The Slack chat_postMessage response is preserved alongside the IDs. assert result.raw["ok"] is True @pytest.mark.asyncio async def test_text_only_post_does_not_add_uploaded_file_ids(self): - """Posts without files leave ``raw`` unaugmented (no ``uploaded_file_ids`` key).""" + """Posts without files leave ``raw`` unaugmented (no ``uploadedFileIds`` key).""" adapter, client, _ = await _init_adapter() client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.333333"}) result = await adapter.post_message("slack:C123:1234567890.000000", "plain text") assert isinstance(result.raw, dict) - assert "uploaded_file_ids" not in result.raw + assert "uploadedFileIds" not in result.raw @pytest.mark.asyncio async def test_file_upload_uses_channel_kwarg_not_channel_id(self): diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index cffcbd0f..a9c81859 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -269,7 +269,7 @@ async def custom_post(thread_id: str, message: Any) -> RawMessage: async def test_should_propagate_adapter_raw_onto_sent_message(self): """post() must carry the adapter's RawMessage.raw through to SentMessage.raw so consumers can read platform confirmation data - (e.g. Slack's ``uploaded_file_ids``).""" + (e.g. Slack's ``uploadedFileIds``).""" adapter = create_mock_adapter() state = create_mock_state() @@ -277,7 +277,7 @@ async def custom_post(thread_id: str, message: Any) -> RawMessage: return RawMessage( id="msg-3", thread_id=thread_id, - raw={"ok": True, "uploaded_file_ids": ["F1", "F2"]}, + raw={"ok": True, "uploadedFileIds": ["F1", "F2"]}, ) adapter.post_message = custom_post # type: ignore[assignment] @@ -285,7 +285,7 @@ async def custom_post(thread_id: str, message: Any) -> RawMessage: result = await thread.post(PostableMarkdown(markdown="report", files=[])) assert isinstance(result.raw, dict) - assert result.raw["uploaded_file_ids"] == ["F1", "F2"] + assert result.raw["uploadedFileIds"] == ["F1", "F2"] # ===========================================================================