From 918a0e65ba8cef9f3179ffb88756ff57bbae8d71 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 28 May 2026 20:31:38 -0700 Subject: [PATCH 1/2] fix(slack): surface files_upload_v2 confirmation in SentMessage.raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SlackAdapter._upload_files already returns the list of Slack-confirmed file IDs, but post_message discarded the return and ThreadImpl._create_sent_message hardcoded raw=None, so the confirmation never reached SentMessage.raw. Slack was the only file-capable adapter to drop upload confirmation; discord/telegram upload inline and expose the platform response naturally. - post_message: capture _upload_files' return and augment RawMessage.raw with "uploaded_file_ids" on every return path that can carry files (file-only, card, table, text), via a new _augment_raw_with_uploads helper. None means no upload; empty list means Slack confirmed zero attachments. raw is augmented, not replaced — non-breaking. - thread.py: _create_sent_message accepts and propagates a raw parameter; the post() call site passes raw_msg.raw through. Platform-agnostic, so discord/telegram real platform responses now flow through too. Tests: 3 in test_slack_api.py (file-only, text+files, text-only no-augment) and 1 end-to-end in test_thread_faithful.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++++++ pyproject.toml | 2 +- src/chat_sdk/adapters/slack/adapter.py | 42 ++++++++++++++++--- src/chat_sdk/thread.py | 5 ++- tests/test_slack_api.py | 57 ++++++++++++++++++++++++++ tests/test_thread_faithful.py | 23 +++++++++++ 6 files changed, 132 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ead5f66..953ef17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.4.27.1 (2026-05-29) + +Python-only patch on the 0.4.27 line. No upstream version change; does NOT include the 0.4.29 alpha sync work. + +### Fixes + +- **Slack now surfaces `files_upload_v2` confirmation through `post()`** — `SlackAdapter._upload_files` already computed the list of Slack-confirmed file IDs but `post_message` discarded the return, and `ThreadImpl._create_sent_message` hardcoded `raw=None`, so the confirmation never reached `SentMessage.raw`. Slack was the only file-capable adapter to drop this; discord/telegram upload inline and expose the platform response naturally. `post_message` now augments `RawMessage.raw` with `"uploaded_file_ids"` on every return path that can carry files (file-only, card, table, text), and `ThreadImpl._create_sent_message` accepts and propagates the adapter's `raw` into `SentMessage.raw`. `None` means no upload occurred; an empty list signals Slack confirmed zero attachments. The `raw` payload is augmented, not replaced, so existing consumers are unaffected. Backported from #117 (which targets the 0.4.29 line). Unblocks chinchill gating UX on actual delivery success. Upstream convergence tracked in vercel/chat#564. + +### Test quality + +- Added 4 tests: three in `tests/test_slack_api.py` (file-only, text+files, text-only-no-augment paths) and one end-to-end `tests/test_thread_faithful.py` test verifying `post()` propagates `RawMessage.raw` to `SentMessage.raw`. + ## 0.4.27 (2026-05-28) Synced to upstream `vercel/chat@4.27.0` (release commit `f55378a`, Apr 30 2026). Highlights: Slack Socket Mode + dynamic bot-token resolver, Teams native DM streaming, `chat.get_user()` across all 8 adapters, Telegram MarkdownV2 rendering, and a sweep of adapter bug fixes. Sets `UPSTREAM_PARITY = "4.27.0"`. diff --git a/pyproject.toml b/pyproject.toml index 1092edd..a202936 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.27" +version = "0.4.27.1" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 4de18ce..93f8f03 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -3389,10 +3389,16 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> try: client = self._get_client() - # Check for files to upload + # 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). + uploaded_file_ids: list[str] | None = None files = extract_files(message) if files: - await self._upload_files(files, channel, thread_ts or None) + uploaded_file_ids = await self._upload_files(files, channel, thread_ts or None) has_text = ( isinstance(message, str) or (hasattr(message, "raw") and getattr(message, "raw", None)) @@ -3404,7 +3410,7 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> return RawMessage( id=f"file-{int(time.time() * 1000)}", thread_id=thread_id, - raw={"files": files}, + raw=self._augment_raw_with_uploads({"files": files}, uploaded_file_ids), ) card = extract_card(message) @@ -3426,7 +3432,10 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> return RawMessage( id=result.get("ts", ""), thread_id=thread_id, - raw=result.data if hasattr(result, "data") else result, + raw=self._augment_raw_with_uploads( + result.data if hasattr(result, "data") else result, + uploaded_file_ids, + ), ) # Table blocks @@ -3443,7 +3452,10 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> return RawMessage( id=result.get("ts", ""), thread_id=thread_id, - raw=result.data if hasattr(result, "data") else result, + raw=self._augment_raw_with_uploads( + result.data if hasattr(result, "data") else result, + uploaded_file_ids, + ), ) # Regular text @@ -3462,11 +3474,29 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> return RawMessage( id=result.get("ts", ""), thread_id=thread_id, - raw=result.data if hasattr(result, "data") else result, + raw=self._augment_raw_with_uploads( + result.data if hasattr(result, "data") else result, + uploaded_file_ids, + ), ) except Exception as error: self._handle_slack_error(error) + @staticmethod + def _augment_raw_with_uploads(raw: Any, uploaded_file_ids: list[str] | None) -> Any: + """Add Slack-confirmed file IDs to a ``RawMessage.raw`` payload. + + 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 + and non-breaking) with the confirmed IDs. An empty list is preserved — + it signals that Slack confirmed zero attachments. + """ + if uploaded_file_ids is None: + return raw + base = raw if isinstance(raw, dict) else {} + return {**base, "uploaded_file_ids": uploaded_file_ids} + async def edit_message( self, thread_id: str, diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 2a1869e..232246c 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -566,7 +566,7 @@ async def post( postable: AdapterPostableMessage = message # type: ignore[assignment] raw_msg = await self.adapter.post_message(self._id, postable) - result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id) + result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id, raw=raw_msg.raw) if self._message_history is not None: await self._message_history.append(self._id, _to_message(result)) @@ -1122,6 +1122,7 @@ def _create_sent_message( message_id: str, postable: AdapterPostableMessage, thread_id_override: str | None = None, + raw: Any = None, ) -> SentMessage: adapter = self.adapter thread_id = thread_id_override or self._id @@ -1160,7 +1161,7 @@ async def _remove_reaction(emoji: EmojiValue | str) -> None: ), attachments=attachments, links=[], - raw=None, + raw=raw, _edit=_edit, _delete=_delete, _add_reaction=_add_reaction, diff --git a/tests/test_slack_api.py b/tests/test_slack_api.py index 54517c7..4fd7e19 100644 --- a/tests/test_slack_api.py +++ b/tests/test_slack_api.py @@ -236,6 +236,63 @@ async def test_file_only_post_returns_file_id(self): # chat_postMessage should not have been called assert len(client.get_calls("chat_postMessage")) == chat_post_calls_before + @pytest.mark.asyncio + async def test_file_only_post_surfaces_confirmed_upload_ids(self): + """File-only post exposes Slack-confirmed file IDs on ``RawMessage.raw``.""" + adapter, client, _ = await _init_adapter() + client.set_response( + "files_upload_v2", + {"ok": True, "files": [{"files": [{"id": "F1"}, {"id": "F2"}]}]}, + ) + + from chat_sdk.types import FileUpload, PostableMarkdown + + msg = PostableMarkdown( + markdown="", + files=[FileUpload(data=b"hello", filename="test.txt")], + ) + result = await adapter.post_message("slack:C123:1234567890.000000", msg) + + assert isinstance(result.raw, dict) + assert result.raw["uploaded_file_ids"] == ["F1", "F2"] + # The original raw payload is preserved (augment, don't replace). + assert "files" in result.raw + + @pytest.mark.asyncio + async def test_text_with_files_surfaces_confirmed_upload_ids(self): + """Text + files post augments the chat_postMessage raw with confirmed IDs.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.222222"}) + client.set_response( + "files_upload_v2", + {"ok": True, "files": [{"files": [{"id": "F9"}]}]}, + ) + + from chat_sdk.types import FileUpload, PostableMarkdown + + msg = PostableMarkdown( + markdown="here is the report", + files=[FileUpload(data=b"hello", filename="report.txt")], + ) + result = await adapter.post_message("slack:C123:1234567890.000000", msg) + + assert result.id == "1234567890.222222" + assert isinstance(result.raw, dict) + assert result.raw["uploaded_file_ids"] == ["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).""" + 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 + @pytest.mark.asyncio async def test_file_upload_uses_channel_kwarg_not_channel_id(self): """Regression: slack-sdk's files_upload_v2 takes ``channel=``, not ``channel_id=``. diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index a130283..13f7245 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -260,6 +260,29 @@ async def custom_post(thread_id: str, message: Any) -> RawMessage: result = await thread.post("Hello") assert result.thread_id == "slack:C123:new-thread-id" + # it("should propagate adapter RawMessage.raw onto SentMessage.raw") + @pytest.mark.asyncio + 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``).""" + adapter = create_mock_adapter() + state = create_mock_state() + + 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"]}, + ) + + adapter.post_message = custom_post # type: ignore[assignment] + thread = _make_thread(adapter, state) + result = await thread.post(PostableMarkdown(markdown="report", files=[])) + + assert isinstance(result.raw, dict) + assert result.raw["uploaded_file_ids"] == ["F1", "F2"] + # =========================================================================== # Streaming From e45069f686206158f79e56d90d43229b3e028301 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 05:06:40 +0000 Subject: [PATCH 2/2] fix(chat): null out SentMessage.raw before persisting to history Post-merge review of PR #117 caught that _MessageHistoryCache.append in chat.py did not null out raw before persisting (unlike its sibling MessageHistoryCache.append in message_history.py, which already does). Without this, the platform raw payload (Slack team_id/user_id, Discord guild IDs, etc.) that #117 now correctly populates on SentMessage.raw would persist to Redis/Postgres-backed state on every reply, inflating storage size and PII surface. Mirrors the null-out in message_history.py:62. --- CHANGELOG.md | 1 + src/chat_sdk/chat.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 953ef17..1227e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Python-only patch on the 0.4.27 line. No upstream version change; does NOT inclu ### Fixes - **Slack now surfaces `files_upload_v2` confirmation through `post()`** — `SlackAdapter._upload_files` already computed the list of Slack-confirmed file IDs but `post_message` discarded the return, and `ThreadImpl._create_sent_message` hardcoded `raw=None`, so the confirmation never reached `SentMessage.raw`. Slack was the only file-capable adapter to drop this; discord/telegram upload inline and expose the platform response naturally. `post_message` now augments `RawMessage.raw` with `"uploaded_file_ids"` on every return path that can carry files (file-only, card, table, text), and `ThreadImpl._create_sent_message` accepts and propagates the adapter's `raw` into `SentMessage.raw`. `None` means no upload occurred; an empty list signals Slack confirmed zero attachments. The `raw` payload is augmented, not replaced, so existing consumers are unaffected. Backported from #117 (which targets the 0.4.29 line). Unblocks chinchill gating UX on actual delivery success. Upstream convergence tracked in vercel/chat#564. +- **`chat._MessageHistoryCache` now nulls `raw` before persisting to history** — mirror of the existing null-out in `message_history.MessageHistoryCache.append`. Without this, the platform payload that the above fix populates on `SentMessage.raw` (Slack `team_id`/`user_id`, Discord `guildId`, etc.) would persist on every reply to Redis/Postgres-backed state, inflating storage and PII surface. Caught by post-merge review of #117. ### Test quality diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 8330a33..4acfa03 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -2454,7 +2454,13 @@ def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) -> async def append(self, thread_id: str, message: Message) -> None: key = f"msg-history:{thread_id}" + # Serialize with raw nulled out to save storage (matches + # MessageHistoryCache.append in message_history.py). Without this, + # SentMessage.raw — now populated post-#117 with platform payloads + # like Slack team_id/user_id, Discord guild IDs — would persist to + # the state adapter on every reply, inflating storage and PII surface. data = message.to_json() + data["raw"] = None await self._state.append_to_list(key, data, max_length=self._max_messages, ttl_ms=self._ttl_ms) async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: