From c3bdb2d739769f6b0280bad24b388e2ffeb42005 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 10 Dec 2025 18:13:08 -0800 Subject: [PATCH 01/14] feat: accept chunks as arguments to chat.{start,append,stop}Stream methods --- slack_sdk/models/messages/chunk.py | 157 +++++++++++++++++++++++++++++ slack_sdk/web/async_client.py | 11 +- slack_sdk/web/client.py | 11 +- slack_sdk/web/internal_utils.py | 14 ++- slack_sdk/web/legacy_client.py | 11 +- 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 slack_sdk/models/messages/chunk.py diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py new file mode 100644 index 000000000..e8e988265 --- /dev/null +++ b/slack_sdk/models/messages/chunk.py @@ -0,0 +1,157 @@ +import logging +from typing import Any, Dict, Literal, Optional, Sequence, Set, Union + +from slack_sdk.errors import SlackObjectFormationError +from slack_sdk.models import show_unknown_key_warning +from slack_sdk.models.basic_objects import JsonObject + +LOGGER = logging.getLogger(__name__) + + +class Chunk(JsonObject): + """ + Chunk for streaming messages. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + + attributes = {"type"} + logger = logging.getLogger(__name__) + + def __init__( + self, + *, + type: Optional[str] = None, + ): + self.type = type + + @classmethod + def parse(cls, chunk: Union[Dict, "Chunk"]) -> Optional["Chunk"]: + if chunk is None: + return None + elif isinstance(chunk, Chunk): + return chunk + else: + if "type" in chunk: + type = chunk["type"] + if type == MarkdownTextChunk.type: + return MarkdownTextChunk(**chunk) + elif type == TaskUpdateChunk.type: + return TaskUpdateChunk(**chunk) + else: + cls.logger.warning(f"Unknown chunk detected and skipped ({chunk})") + return None + else: + cls.logger.warning(f"Unknown chunk detected and skipped ({chunk})") + return None + + +class MarkdownTextChunk(Chunk): + type = "markdown_text" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"text"}) + + def __init__( + self, + *, + text: str, + **others: Dict, + ): + """Used for streaming text content with markdown formatting support. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + super().__init__(type=self.type) + show_unknown_key_warning(self, others) + + self.text = text + + +class URLSource(JsonObject): + type = "url" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "url", + "text", + "icon_url", + } + ) + + def __init__( + self, + *, + url: str, + text: str, + icon_url: Optional[str] = None, + **others: Dict, + ): + show_unknown_key_warning(self, others) + self._url = url + self._text = text + self._icon_url = icon_url + + def to_dict(self) -> Dict[str, Any]: + self.validate_json() + json: Dict[str, Union[str, Dict]] = { + "type": self.type, + "url": self._url, + "text": self._text, + } + if self._icon_url: + json["icon_url"] = self._icon_url + return json + + +class TaskUpdateChunk(Chunk): + type = "task_update" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "id", + "title", + "status", + "details", + "output", + "sources", + } + ) + + def __init__( + self, + *, + id: str, + title: str, + status: Literal["pending", "in_progress", "complete", "error"], + details: Optional[str] = None, + output: Optional[str] = None, + sources: Optional[Sequence[Union[Dict, URLSource]]] = None, + **others: Dict, + ): + """Used for displaying tool execution progress in a timeline-style UI. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + super().__init__(type=self.type) + show_unknown_key_warning(self, others) + + self.id = id + self.title = title + self.status = status + self.details = details + self.output = output + if sources is not None: + self.sources = [] + for src in sources: + if isinstance(src, Dict): + self.sources.append(src) + elif isinstance(src, URLSource): + self.sources.append(src.to_dict()) + else: + raise SlackObjectFormationError(f"Unsupported type for source in task update chunk: {type(src)}") diff --git a/slack_sdk/web/async_client.py b/slack_sdk/web/async_client.py index ca163da98..0a9f702b9 100644 --- a/slack_sdk/web/async_client.py +++ b/slack_sdk/web/async_client.py @@ -17,12 +17,13 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from slack_sdk.web.async_chat_stream import AsyncChatStream from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .async_base_client import AsyncBaseClient, AsyncSlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2631,6 +2632,7 @@ async def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Appends text to an existing streaming conversation. @@ -2641,8 +2643,10 @@ async def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return await self.api_call("chat.appendStream", json=kwargs) @@ -2884,6 +2888,7 @@ async def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Starts a new streaming conversation. @@ -2896,8 +2901,10 @@ async def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return await self.api_call("chat.startStream", json=kwargs) @@ -2909,6 +2916,7 @@ async def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Stops a streaming conversation. @@ -2921,6 +2929,7 @@ async def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) diff --git a/slack_sdk/web/client.py b/slack_sdk/web/client.py index dfa771832..1a70681a4 100644 --- a/slack_sdk/web/client.py +++ b/slack_sdk/web/client.py @@ -7,12 +7,13 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from slack_sdk.web.chat_stream import ChatStream from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .base_client import BaseClient, SlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2621,6 +2622,7 @@ def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Appends text to an existing streaming conversation. @@ -2631,8 +2633,10 @@ def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.appendStream", json=kwargs) @@ -2874,6 +2878,7 @@ def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Starts a new streaming conversation. @@ -2886,8 +2891,10 @@ def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.startStream", json=kwargs) @@ -2899,6 +2906,7 @@ def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Stops a streaming conversation. @@ -2911,6 +2919,7 @@ def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) diff --git a/slack_sdk/web/internal_utils.py b/slack_sdk/web/internal_utils.py index 87139559c..ad23f87f8 100644 --- a/slack_sdk/web/internal_utils.py +++ b/slack_sdk/web/internal_utils.py @@ -11,13 +11,14 @@ from ssl import SSLContext from typing import Any, Dict, Optional, Sequence, Union from urllib.parse import urljoin -from urllib.request import OpenerDirector, ProxyHandler, HTTPSHandler, Request, urlopen +from urllib.request import HTTPSHandler, OpenerDirector, ProxyHandler, Request, urlopen from slack_sdk import version from slack_sdk.errors import SlackRequestError from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block -from slack_sdk.models.metadata import Metadata, EventAndEntityMetadata, EntityMetadata +from slack_sdk.models.messages.chunk import Chunk +from slack_sdk.models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata def convert_bool_to_0_or_1(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: @@ -187,11 +188,13 @@ def _build_req_args( def _parse_web_class_objects(kwargs) -> None: - def to_dict(obj: Union[Dict, Block, Attachment, Metadata, EventAndEntityMetadata, EntityMetadata]): + def to_dict(obj: Union[Dict, Block, Attachment, Chunk, Metadata, EventAndEntityMetadata, EntityMetadata]): if isinstance(obj, Block): return obj.to_dict() if isinstance(obj, Attachment): return obj.to_dict() + if isinstance(obj, Chunk): + return obj.to_dict() if isinstance(obj, Metadata): return obj.to_dict() if isinstance(obj, EventAndEntityMetadata): @@ -211,6 +214,11 @@ def to_dict(obj: Union[Dict, Block, Attachment, Metadata, EventAndEntityMetadata dict_attachments = [to_dict(a) for a in attachments] kwargs.update({"attachments": dict_attachments}) + chunks = kwargs.get("chunks", None) + if chunks is not None and isinstance(chunks, Sequence) and (not isinstance(chunks, str)): + dict_chunks = [to_dict(c) for c in chunks] + kwargs.update({"chunks": dict_chunks}) + metadata = kwargs.get("metadata", None) if metadata is not None and ( isinstance(metadata, Metadata) diff --git a/slack_sdk/web/legacy_client.py b/slack_sdk/web/legacy_client.py index df2bcc370..f11bbc495 100644 --- a/slack_sdk/web/legacy_client.py +++ b/slack_sdk/web/legacy_client.py @@ -19,11 +19,12 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .legacy_base_client import LegacyBaseClient, SlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2632,6 +2633,7 @@ def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Appends text to an existing streaming conversation. @@ -2642,8 +2644,10 @@ def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.appendStream", json=kwargs) @@ -2885,6 +2889,7 @@ def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Starts a new streaming conversation. @@ -2897,8 +2902,10 @@ def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.startStream", json=kwargs) @@ -2910,6 +2917,7 @@ def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Stops a streaming conversation. @@ -2922,6 +2930,7 @@ def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) From 8c56e22dc96d76f6825600d418e47189666d3ea9 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Dec 2025 22:32:58 -0800 Subject: [PATCH 02/14] fix: remove unsupported and unused identifiers for full support --- slack_sdk/models/messages/chunk.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index e8e988265..05ddff2c9 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -1,12 +1,10 @@ import logging -from typing import Any, Dict, Literal, Optional, Sequence, Set, Union +from typing import Any, Dict, Optional, Sequence, Set, Union from slack_sdk.errors import SlackObjectFormationError from slack_sdk.models import show_unknown_key_warning from slack_sdk.models.basic_objects import JsonObject -LOGGER = logging.getLogger(__name__) - class Chunk(JsonObject): """ @@ -128,7 +126,7 @@ def __init__( *, id: str, title: str, - status: Literal["pending", "in_progress", "complete", "error"], + status: str, # "pending", "in_progress", "complete", "error" details: Optional[str] = None, output: Optional[str] = None, sources: Optional[Sequence[Union[Dict, URLSource]]] = None, From 783ada59bb3b0d87c1432a16409824e678f2c2cc Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 14:10:07 -0800 Subject: [PATCH 03/14] style: remove mypy extra ignore comment for overriden attributes --- slack_sdk/models/messages/chunk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index 05ddff2c9..837714af0 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -71,7 +71,7 @@ class URLSource(JsonObject): type = "url" @property - def attributes(self) -> Set[str]: # type: ignore[override] + def attributes(self) -> Set[str]: return super().attributes.union( { "url", From 1fb735585b6cc3250c758d69b2e3e434983dcfd0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 14:35:46 -0800 Subject: [PATCH 04/14] test: confirm chunks parse as expected json values --- tests/slack_sdk/models/test_chunks.py | 72 ++++++++++++++++++++++ tests/slack_sdk/web/test_internal_utils.py | 31 ++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/slack_sdk/models/test_chunks.py diff --git a/tests/slack_sdk/models/test_chunks.py b/tests/slack_sdk/models/test_chunks.py new file mode 100644 index 000000000..1b8b58c96 --- /dev/null +++ b/tests/slack_sdk/models/test_chunks.py @@ -0,0 +1,72 @@ +import unittest + +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk, URLSource + + +class MarkdownTextChunkTests(unittest.TestCase): + def test_json(self): + self.assertDictEqual( + MarkdownTextChunk(text="greetings!").to_dict(), + { + "type": "markdown_text", + "text": "greetings!", + }, + ) + + +class TaskUpdateChunkTests(unittest.TestCase): + def test_json(self): + self.assertDictEqual( + TaskUpdateChunk(id="001", title="Waiting...", status="pending").to_dict(), + { + "type": "task_update", + "id": "001", + "title": "Waiting...", + "status": "pending", + }, + ) + self.assertDictEqual( + TaskUpdateChunk( + id="002", + title="Wondering...", + status="in_progress", + details="- Gathering information...", + ).to_dict(), + { + "type": "task_update", + "id": "002", + "title": "Wondering...", + "status": "in_progress", + "details": "- Gathering information...", + }, + ) + self.assertDictEqual( + TaskUpdateChunk( + id="003", + title="Answering...", + status="complete", + output="Found a solution", + sources=[ + URLSource( + text="The Free Encyclopedia", + url="https://wikipedia.org", + icon_url="https://example.com/globe.png", + ), + ], + ).to_dict(), + { + "type": "task_update", + "id": "003", + "title": "Answering...", + "status": "complete", + "output": "Found a solution", + "sources": [ + { + "type": "url", + "text": "The Free Encyclopedia", + "url": "https://wikipedia.org", + "icon_url": "https://example.com/globe.png", + }, + ], + }, + ) diff --git a/tests/slack_sdk/web/test_internal_utils.py b/tests/slack_sdk/web/test_internal_utils.py index ac7704b30..fc6574aab 100644 --- a/tests/slack_sdk/web/test_internal_utils.py +++ b/tests/slack_sdk/web/test_internal_utils.py @@ -2,18 +2,18 @@ import unittest from io import BytesIO from pathlib import Path -from typing import Dict, Sequence, Union +from typing import Dict -import pytest from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, DividerBlock +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk from slack_sdk.web.internal_utils import ( _build_unexpected_body_error_message, + _get_url, + _next_cursor_is_present, _parse_web_class_objects, _to_v2_file_upload_item, - _next_cursor_is_present, - _get_url, ) @@ -57,6 +57,20 @@ def test_can_parse_sequence_of_attachments(self): for attachment in kwargs["attachments"]: assert isinstance(attachment, Dict) + def test_can_parse_sequence_of_chunks(self): + for chunks in [ + [MarkdownTextChunk(text="fiz"), TaskUpdateChunk(id="001", title="baz", status="complete")], # list + ( + MarkdownTextChunk(text="fiz"), + TaskUpdateChunk(id="001", title="baz", status="complete"), + ), # tuple + ]: + kwargs = {"chunks": chunks} + _parse_web_class_objects(kwargs) + assert kwargs["chunks"] + for chunks in kwargs["chunks"]: + assert isinstance(chunks, Dict) + def test_can_parse_str_blocks(self): input = json.dumps([Block(block_id="42").to_dict(), Block(block_id="24").to_dict()]) kwargs = {"blocks": input} @@ -71,6 +85,15 @@ def test_can_parse_str_attachments(self): assert isinstance(kwargs["attachments"], str) assert input == kwargs["attachments"] + def test_can_parse_str_chunks(self): + input = json.dumps( + [MarkdownTextChunk(text="fiz").to_dict(), TaskUpdateChunk(id="001", title="baz", status="complete").to_dict()] + ) + kwargs = {"chunks": input} + _parse_web_class_objects(kwargs) + assert isinstance(kwargs["chunks"], str) + assert input == kwargs["chunks"] + def test_can_parse_user_auth_blocks(self): kwargs = { "channel": "C12345", From 5de794b8d34726a188f8811f2e3383cfce87d04a Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 15:37:52 -0800 Subject: [PATCH 05/14] feat: support and flush chunks in the chat stream helper --- slack_sdk/web/async_chat_stream.py | 34 ++++++++---- slack_sdk/web/async_client.py | 2 +- slack_sdk/web/chat_stream.py | 34 ++++++++---- slack_sdk/web/client.py | 2 +- slack_sdk/web/legacy_client.py | 2 +- tests/slack_sdk/web/test_chat_stream.py | 70 +++++++++++++++++++++++-- 6 files changed, 119 insertions(+), 25 deletions(-) diff --git a/slack_sdk/web/async_chat_stream.py b/slack_sdk/web/async_chat_stream.py index 4661f19dd..e25eaae61 100644 --- a/slack_sdk/web/async_chat_stream.py +++ b/slack_sdk/web/async_chat_stream.py @@ -14,6 +14,7 @@ import slack_sdk.errors as e from slack_sdk.models.blocks.blocks import Block +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.metadata import Metadata from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -75,7 +76,8 @@ def __init__( async def append( self, *, - markdown_text: str, + markdown_text: Optional[str] = None, + chunks: Optional[Sequence[Chunk]] = None, **kwargs, ) -> Optional[AsyncSlackResponse]: """Append to the stream. @@ -84,6 +86,7 @@ async def append( is stopped this method cannot be called. Args: + chunks: An array of streaming chunks that can contain either markdown text or task updates. markdown_text: Accepts message text formatted in markdown. Limit this field to 12,000 characters. This text is what will be appended to the message received so far. **kwargs: Additional arguments passed to the underlying API calls. @@ -111,9 +114,10 @@ async def append( raise e.SlackRequestError(f"Cannot append to stream: stream state is {self._state}") if kwargs.get("token"): self._token = kwargs.pop("token") - self._buffer += markdown_text - if len(self._buffer) >= self._buffer_size: - return await self._flush_buffer(**kwargs) + if markdown_text is not None: + self._buffer += markdown_text + if len(self._buffer) >= self._buffer_size or chunks is not None: + return await self._flush_buffer(chunks=chunks, **kwargs) details = { "buffer_length": len(self._buffer), "buffer_size": self._buffer_size, @@ -129,6 +133,7 @@ async def stop( self, *, markdown_text: Optional[str] = None, + chunks: Optional[Sequence[Chunk]] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, **kwargs, @@ -137,6 +142,7 @@ async def stop( Args: blocks: A list of blocks that will be rendered at the bottom of the finalized message. + chunks: An array of streaming chunks that can contain either markdown text or task updates. markdown_text: Accepts message text formatted in markdown. Limit this field to 12,000 characters. This text is what will be appended to the message received so far. metadata: JSON object with event_type and event_payload fields, presented as a URL-encoded string. Metadata you @@ -177,26 +183,36 @@ async def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" + flushings = [] + if len(self._buffer) != 0: + flushings.append({"type": "markdown_text", "text": self._buffer}) + if chunks is not None: + flushings.extend(chunks) response = await self._client.chat_stopStream( token=self._token, channel=self._stream_args["channel"], ts=self._stream_ts, blocks=blocks, - markdown_text=self._buffer, + chunks=flushings, metadata=metadata, **kwargs, ) self._state = "completed" return response - async def _flush_buffer(self, **kwargs) -> AsyncSlackResponse: - """Flush the internal buffer by making appropriate API calls.""" + async def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> AsyncSlackResponse: + """Flush the internal buffer with chunks by making appropriate API calls.""" + flushings = [] + if len(self._buffer) != 0: + flushings.append({"type": "markdown_text", "text": self._buffer}) + if chunks is not None: + flushings.extend(chunks) if not self._stream_ts: response = await self._client.chat_startStream( **self._stream_args, token=self._token, **kwargs, - markdown_text=self._buffer, + chunks=flushings, ) self._stream_ts = response.get("ts") self._state = "in_progress" @@ -206,7 +222,7 @@ async def _flush_buffer(self, **kwargs) -> AsyncSlackResponse: channel=self._stream_args["channel"], ts=self._stream_ts, **kwargs, - markdown_text=self._buffer, + chunks=flushings, ) self._buffer = "" return response diff --git a/slack_sdk/web/async_client.py b/slack_sdk/web/async_client.py index 0a9f702b9..5aaa2c610 100644 --- a/slack_sdk/web/async_client.py +++ b/slack_sdk/web/async_client.py @@ -2631,7 +2631,7 @@ async def chat_appendStream( *, channel: str, ts: str, - markdown_text: str, + markdown_text: Optional[str] = None, chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: diff --git a/slack_sdk/web/chat_stream.py b/slack_sdk/web/chat_stream.py index 1a379c9cb..fc7024f96 100644 --- a/slack_sdk/web/chat_stream.py +++ b/slack_sdk/web/chat_stream.py @@ -4,6 +4,7 @@ import slack_sdk.errors as e from slack_sdk.models.blocks.blocks import Block +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.metadata import Metadata from slack_sdk.web.slack_response import SlackResponse @@ -65,7 +66,8 @@ def __init__( def append( self, *, - markdown_text: str, + markdown_text: Optional[str] = None, + chunks: Optional[Sequence[Chunk]] = None, **kwargs, ) -> Optional[SlackResponse]: """Append to the stream. @@ -74,6 +76,7 @@ def append( is stopped this method cannot be called. Args: + chunks: An array of streaming chunks that can contain either markdown text or task updates. markdown_text: Accepts message text formatted in markdown. Limit this field to 12,000 characters. This text is what will be appended to the message received so far. **kwargs: Additional arguments passed to the underlying API calls. @@ -101,9 +104,10 @@ def append( raise e.SlackRequestError(f"Cannot append to stream: stream state is {self._state}") if kwargs.get("token"): self._token = kwargs.pop("token") - self._buffer += markdown_text - if len(self._buffer) >= self._buffer_size: - return self._flush_buffer(**kwargs) + if markdown_text is not None: + self._buffer += markdown_text + if len(self._buffer) >= self._buffer_size or chunks is not None: + return self._flush_buffer(chunks=chunks, **kwargs) details = { "buffer_length": len(self._buffer), "buffer_size": self._buffer_size, @@ -119,6 +123,7 @@ def stop( self, *, markdown_text: Optional[str] = None, + chunks: Optional[Sequence[Chunk]] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, **kwargs, @@ -127,6 +132,7 @@ def stop( Args: blocks: A list of blocks that will be rendered at the bottom of the finalized message. + chunks: An array of streaming chunks that can contain either markdown text or task updates. markdown_text: Accepts message text formatted in markdown. Limit this field to 12,000 characters. This text is what will be appended to the message received so far. metadata: JSON object with event_type and event_payload fields, presented as a URL-encoded string. Metadata you @@ -167,26 +173,36 @@ def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" + flushings = [] + if len(self._buffer) != 0: + flushings.append({"type": "markdown_text", "text": self._buffer}) + if chunks is not None: + flushings.extend(chunks) response = self._client.chat_stopStream( token=self._token, channel=self._stream_args["channel"], ts=self._stream_ts, blocks=blocks, - markdown_text=self._buffer, + chunks=flushings, metadata=metadata, **kwargs, ) self._state = "completed" return response - def _flush_buffer(self, **kwargs) -> SlackResponse: - """Flush the internal buffer by making appropriate API calls.""" + def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> SlackResponse: + """Flush the internal buffer with chunks by making appropriate API calls.""" + flushings = [] + if len(self._buffer) != 0: + flushings.append({"type": "markdown_text", "text": self._buffer}) + if chunks is not None: + flushings.extend(chunks) if not self._stream_ts: response = self._client.chat_startStream( **self._stream_args, token=self._token, **kwargs, - markdown_text=self._buffer, + chunks=flushings, ) self._stream_ts = response.get("ts") self._state = "in_progress" @@ -196,7 +212,7 @@ def _flush_buffer(self, **kwargs) -> SlackResponse: channel=self._stream_args["channel"], ts=self._stream_ts, **kwargs, - markdown_text=self._buffer, + chunks=flushings, ) self._buffer = "" return response diff --git a/slack_sdk/web/client.py b/slack_sdk/web/client.py index 1a70681a4..392a261ad 100644 --- a/slack_sdk/web/client.py +++ b/slack_sdk/web/client.py @@ -2621,7 +2621,7 @@ def chat_appendStream( *, channel: str, ts: str, - markdown_text: str, + markdown_text: Optional[str] = None, chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: diff --git a/slack_sdk/web/legacy_client.py b/slack_sdk/web/legacy_client.py index f11bbc495..7bb0609c5 100644 --- a/slack_sdk/web/legacy_client.py +++ b/slack_sdk/web/legacy_client.py @@ -2632,7 +2632,7 @@ def chat_appendStream( *, channel: str, ts: str, - markdown_text: str, + markdown_text: Optional[str] = None, chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: diff --git a/tests/slack_sdk/web/test_chat_stream.py b/tests/slack_sdk/web/test_chat_stream.py index 75c13c8c2..0dbcd4caf 100644 --- a/tests/slack_sdk/web/test_chat_stream.py +++ b/tests/slack_sdk/web/test_chat_stream.py @@ -7,6 +7,7 @@ from slack_sdk.models.blocks.basic_components import FeedbackButtonObject from slack_sdk.models.blocks.block_elements import FeedbackButtonsElement, IconButtonElement from slack_sdk.models.blocks.blocks import ContextActionsBlock +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.slack_sdk.web.mock_web_api_handler import MockHandler @@ -105,7 +106,7 @@ def test_streams_a_short_message(self): stop_request = self.thread.server.chat_stream_requests.get("/chat.stopStream", {}) self.assertEqual(stop_request.get("channel"), "C0123456789") self.assertEqual(stop_request.get("ts"), "123.123") - self.assertEqual(stop_request.get("markdown_text"), "nice!") + self.assertEqual(stop_request.get("chunks"), [{"type": "markdown_text", "text": "nice!"}]) def test_streams_a_long_message(self): streamer = self.client.chat_stream( @@ -146,13 +147,13 @@ def test_streams_a_long_message(self): start_request = self.thread.server.chat_stream_requests.get("/chat.startStream", {}) self.assertEqual(start_request.get("channel"), "C0123456789") self.assertEqual(start_request.get("thread_ts"), "123.000") - self.assertEqual(start_request.get("markdown_text"), "**this messag") + self.assertEqual(start_request.get("chunks"), [{"type": "markdown_text", "text": "**this messag"}]) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") append_request = self.thread.server.chat_stream_requests.get("/chat.appendStream", {}) self.assertEqual(append_request.get("channel"), "C0123456789") - self.assertEqual(append_request.get("markdown_text"), "e is bold!") + self.assertEqual(append_request.get("chunks"), [{"type": "markdown_text", "text": "e is bold!"}]) self.assertEqual(append_request.get("token"), "xoxb-chat_stream_test_token1") self.assertEqual(append_request.get("ts"), "123.123") @@ -162,10 +163,71 @@ def test_streams_a_long_message(self): '[{"elements": [{"negative_button": {"text": {"emoji": true, "text": "bad", "type": "plain_text"}, "value": "-1"}, "positive_button": {"text": {"emoji": true, "text": "good", "type": "plain_text"}, "value": "+1"}, "type": "feedback_buttons"}, {"icon": "trash", "text": {"emoji": true, "text": "delete", "type": "plain_text"}, "type": "icon_button"}], "type": "context_actions"}]', ) self.assertEqual(stop_request.get("channel"), "C0123456789") - self.assertEqual(stop_request.get("markdown_text"), "**") + self.assertEqual(stop_request.get("chunks"), [{"type": "markdown_text", "text": "**"}]) self.assertEqual(stop_request.get("token"), "xoxb-chat_stream_test_token2") self.assertEqual(stop_request.get("ts"), "123.123") + def test_streams_a_chunk_message(self): + streamer = self.client.chat_stream( + channel="C0123456789", + recipient_team_id="T0123456789", + recipient_user_id="U0123456789", + thread_ts="123.000", + ) + streamer.append(markdown_text="**this is ") + streamer.append(markdown_text="buffered**") + streamer.append( + chunks=[ + TaskUpdateChunk( + id="001", + title="Counting...", + status="pending", + ), + ], + ) + streamer.append( + chunks=[ + MarkdownTextChunk(text="**this is unbuffered**"), + ], + ) + streamer.append(markdown_text="\n") + streamer.stop( + chunks=[ + MarkdownTextChunk(text=":space_invader:"), + ], + ) + + self.assertEqual(self.received_requests.get("/chat.startStream", 0), 1) + self.assertEqual(self.received_requests.get("/chat.appendStream", 0), 1) + self.assertEqual(self.received_requests.get("/chat.stopStream", 0), 1) + + if hasattr(self.thread.server, "chat_stream_requests"): + start_request = self.thread.server.chat_stream_requests.get("/chat.startStream", {}) + self.assertEqual(start_request.get("channel"), "C0123456789") + self.assertEqual(start_request.get("thread_ts"), "123.000") + self.assertEqual( + json.dumps(start_request.get("chunks")), + '[{"type": "markdown_text", "text": "**this is buffered**"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', + ) + self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") + self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") + + append_request = self.thread.server.chat_stream_requests.get("/chat.appendStream", {}) + self.assertEqual(append_request.get("channel"), "C0123456789") + self.assertEqual(append_request.get("ts"), "123.123") + self.assertEqual( + json.dumps(append_request.get("chunks")), + '[{"text": "**this is unbuffered**", "type": "markdown_text"}]', + ) + + stop_request = self.thread.server.chat_stream_requests.get("/chat.stopStream", {}) + self.assertEqual(stop_request.get("channel"), "C0123456789") + self.assertEqual(stop_request.get("ts"), "123.123") + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "\\n"}, {"text": ":space_invader:", "type": "markdown_text"}]', + ) + def test_streams_errors_when_appending_to_an_unstarted_stream(self): streamer = self.client.chat_stream( channel="C0123456789", From 61d6d535e6df3266f1c6f4b84f49097236dfbe6b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 16:22:25 -0800 Subject: [PATCH 06/14] test: dump chunks json before comparison for exact parsings --- tests/slack_sdk/web/test_chat_stream.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/slack_sdk/web/test_chat_stream.py b/tests/slack_sdk/web/test_chat_stream.py index 0dbcd4caf..00c62b584 100644 --- a/tests/slack_sdk/web/test_chat_stream.py +++ b/tests/slack_sdk/web/test_chat_stream.py @@ -106,7 +106,10 @@ def test_streams_a_short_message(self): stop_request = self.thread.server.chat_stream_requests.get("/chat.stopStream", {}) self.assertEqual(stop_request.get("channel"), "C0123456789") self.assertEqual(stop_request.get("ts"), "123.123") - self.assertEqual(stop_request.get("chunks"), [{"type": "markdown_text", "text": "nice!"}]) + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "nice!"}]', + ) def test_streams_a_long_message(self): streamer = self.client.chat_stream( @@ -147,13 +150,19 @@ def test_streams_a_long_message(self): start_request = self.thread.server.chat_stream_requests.get("/chat.startStream", {}) self.assertEqual(start_request.get("channel"), "C0123456789") self.assertEqual(start_request.get("thread_ts"), "123.000") - self.assertEqual(start_request.get("chunks"), [{"type": "markdown_text", "text": "**this messag"}]) + self.assertEqual( + json.dumps(start_request.get("chunks")), + '[{"type": "markdown_text", "text": "**this messag"}]', + ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") append_request = self.thread.server.chat_stream_requests.get("/chat.appendStream", {}) self.assertEqual(append_request.get("channel"), "C0123456789") - self.assertEqual(append_request.get("chunks"), [{"type": "markdown_text", "text": "e is bold!"}]) + self.assertEqual( + json.dumps(append_request.get("chunks")), + '[{"type": "markdown_text", "text": "e is bold!"}]', + ) self.assertEqual(append_request.get("token"), "xoxb-chat_stream_test_token1") self.assertEqual(append_request.get("ts"), "123.123") @@ -163,7 +172,10 @@ def test_streams_a_long_message(self): '[{"elements": [{"negative_button": {"text": {"emoji": true, "text": "bad", "type": "plain_text"}, "value": "-1"}, "positive_button": {"text": {"emoji": true, "text": "good", "type": "plain_text"}, "value": "+1"}, "type": "feedback_buttons"}, {"icon": "trash", "text": {"emoji": true, "text": "delete", "type": "plain_text"}, "type": "icon_button"}], "type": "context_actions"}]', ) self.assertEqual(stop_request.get("channel"), "C0123456789") - self.assertEqual(stop_request.get("chunks"), [{"type": "markdown_text", "text": "**"}]) + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "**"}]', + ) self.assertEqual(stop_request.get("token"), "xoxb-chat_stream_test_token2") self.assertEqual(stop_request.get("ts"), "123.123") From 85081e137f2be4a1400a665ecad472f4af5a4a09 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 16:32:57 -0800 Subject: [PATCH 07/14] test: update async tests to expect chunks when flushing buffer --- .../web/test_async_chat_stream.py | 83 ++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/tests/slack_sdk_async/web/test_async_chat_stream.py b/tests/slack_sdk_async/web/test_async_chat_stream.py index 212fee1e2..f9a97ecd4 100644 --- a/tests/slack_sdk_async/web/test_async_chat_stream.py +++ b/tests/slack_sdk_async/web/test_async_chat_stream.py @@ -6,6 +6,7 @@ from slack_sdk.models.blocks.basic_components import FeedbackButtonObject from slack_sdk.models.blocks.block_elements import FeedbackButtonsElement, IconButtonElement from slack_sdk.models.blocks.blocks import ContextActionsBlock +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk from slack_sdk.web.async_client import AsyncWebClient from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server from tests.slack_sdk.web.mock_web_api_handler import MockHandler @@ -107,7 +108,10 @@ async def test_streams_a_short_message(self): stop_request = self.thread.server.chat_stream_requests.get("/chat.stopStream", {}) self.assertEqual(stop_request.get("channel"), "C0123456789") self.assertEqual(stop_request.get("ts"), "123.123") - self.assertEqual(stop_request.get("markdown_text"), "nice!") + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "nice!"}]', + ) @async_test async def test_streams_a_long_message(self): @@ -149,13 +153,19 @@ async def test_streams_a_long_message(self): start_request = self.thread.server.chat_stream_requests.get("/chat.startStream", {}) self.assertEqual(start_request.get("channel"), "C0123456789") self.assertEqual(start_request.get("thread_ts"), "123.000") - self.assertEqual(start_request.get("markdown_text"), "**this messag") + self.assertEqual( + json.dumps(start_request.get("chunks")), + '[{"type": "markdown_text", "text": "**this messag"}]', + ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") append_request = self.thread.server.chat_stream_requests.get("/chat.appendStream", {}) self.assertEqual(append_request.get("channel"), "C0123456789") - self.assertEqual(append_request.get("markdown_text"), "e is bold!") + self.assertEqual( + json.dumps(append_request.get("chunks")), + '[{"type": "markdown_text", "text": "e is bold!"}]', + ) self.assertEqual(append_request.get("token"), "xoxb-chat_stream_test_token1") self.assertEqual(append_request.get("ts"), "123.123") @@ -165,10 +175,75 @@ async def test_streams_a_long_message(self): '[{"elements": [{"negative_button": {"text": {"emoji": true, "text": "bad", "type": "plain_text"}, "value": "-1"}, "positive_button": {"text": {"emoji": true, "text": "good", "type": "plain_text"}, "value": "+1"}, "type": "feedback_buttons"}, {"icon": "trash", "text": {"emoji": true, "text": "delete", "type": "plain_text"}, "type": "icon_button"}], "type": "context_actions"}]', ) self.assertEqual(stop_request.get("channel"), "C0123456789") - self.assertEqual(stop_request.get("markdown_text"), "**") + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "**"}]', + ) self.assertEqual(stop_request.get("token"), "xoxb-chat_stream_test_token2") self.assertEqual(stop_request.get("ts"), "123.123") + @async_test + async def test_streams_a_chunk_message(self): + streamer = await self.client.chat_stream( + channel="C0123456789", + recipient_team_id="T0123456789", + recipient_user_id="U0123456789", + thread_ts="123.000", + ) + await streamer.append(markdown_text="**this is ") + await streamer.append(markdown_text="buffered**") + await streamer.append( + chunks=[ + TaskUpdateChunk( + id="001", + title="Counting...", + status="pending", + ), + ], + ) + await streamer.append( + chunks=[ + MarkdownTextChunk(text="**this is unbuffered**"), + ], + ) + await streamer.append(markdown_text="\n") + await streamer.stop( + chunks=[ + MarkdownTextChunk(text=":space_invader:"), + ], + ) + + self.assertEqual(self.received_requests.get("/chat.startStream", 0), 1) + self.assertEqual(self.received_requests.get("/chat.appendStream", 0), 1) + self.assertEqual(self.received_requests.get("/chat.stopStream", 0), 1) + + if hasattr(self.thread.server, "chat_stream_requests"): + start_request = self.thread.server.chat_stream_requests.get("/chat.startStream", {}) + self.assertEqual(start_request.get("channel"), "C0123456789") + self.assertEqual(start_request.get("thread_ts"), "123.000") + self.assertEqual( + json.dumps(start_request.get("chunks")), + '[{"type": "markdown_text", "text": "**this is buffered**"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', + ) + self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") + self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") + + append_request = self.thread.server.chat_stream_requests.get("/chat.appendStream", {}) + self.assertEqual(append_request.get("channel"), "C0123456789") + self.assertEqual(append_request.get("ts"), "123.123") + self.assertEqual( + json.dumps(append_request.get("chunks")), + '[{"text": "**this is unbuffered**", "type": "markdown_text"}]', + ) + + stop_request = self.thread.server.chat_stream_requests.get("/chat.stopStream", {}) + self.assertEqual(stop_request.get("channel"), "C0123456789") + self.assertEqual(stop_request.get("ts"), "123.123") + self.assertEqual( + json.dumps(stop_request.get("chunks")), + '[{"type": "markdown_text", "text": "\\n"}, {"text": ":space_invader:", "type": "markdown_text"}]', + ) + @async_test async def test_streams_errors_when_appending_to_an_unstarted_stream(self): streamer = await self.client.chat_stream( From a6bb951d3cbbbbfe63dc5a83c50e5d7ccfe637b7 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 16:48:28 -0800 Subject: [PATCH 08/14] style: prefer using markdown text chunks in internal calls --- slack_sdk/web/async_chat_stream.py | 12 ++++++------ slack_sdk/web/chat_stream.py | 12 ++++++------ tests/slack_sdk/web/test_chat_stream.py | 12 ++++++------ tests/slack_sdk_async/web/test_async_chat_stream.py | 12 ++++++------ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/slack_sdk/web/async_chat_stream.py b/slack_sdk/web/async_chat_stream.py index e25eaae61..9785d123e 100644 --- a/slack_sdk/web/async_chat_stream.py +++ b/slack_sdk/web/async_chat_stream.py @@ -10,11 +10,11 @@ import json import logging -from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e from slack_sdk.models.blocks.blocks import Block -from slack_sdk.models.messages.chunk import Chunk +from slack_sdk.models.messages.chunk import Chunk, MarkdownTextChunk from slack_sdk.models.metadata import Metadata from slack_sdk.web.async_slack_response import AsyncSlackResponse @@ -183,9 +183,9 @@ async def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" - flushings = [] + flushings: List[Chunk] = [] if len(self._buffer) != 0: - flushings.append({"type": "markdown_text", "text": self._buffer}) + flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: flushings.extend(chunks) response = await self._client.chat_stopStream( @@ -202,9 +202,9 @@ async def stop( async def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> AsyncSlackResponse: """Flush the internal buffer with chunks by making appropriate API calls.""" - flushings = [] + flushings: List[Chunk] = [] if len(self._buffer) != 0: - flushings.append({"type": "markdown_text", "text": self._buffer}) + flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: flushings.extend(chunks) if not self._stream_ts: diff --git a/slack_sdk/web/chat_stream.py b/slack_sdk/web/chat_stream.py index fc7024f96..63f5935f1 100644 --- a/slack_sdk/web/chat_stream.py +++ b/slack_sdk/web/chat_stream.py @@ -1,10 +1,10 @@ import json import logging -from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e from slack_sdk.models.blocks.blocks import Block -from slack_sdk.models.messages.chunk import Chunk +from slack_sdk.models.messages.chunk import Chunk, MarkdownTextChunk from slack_sdk.models.metadata import Metadata from slack_sdk.web.slack_response import SlackResponse @@ -173,9 +173,9 @@ def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" - flushings = [] + flushings: List[Chunk] = [] if len(self._buffer) != 0: - flushings.append({"type": "markdown_text", "text": self._buffer}) + flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: flushings.extend(chunks) response = self._client.chat_stopStream( @@ -192,9 +192,9 @@ def stop( def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> SlackResponse: """Flush the internal buffer with chunks by making appropriate API calls.""" - flushings = [] + flushings: List[Chunk] = [] if len(self._buffer) != 0: - flushings.append({"type": "markdown_text", "text": self._buffer}) + flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: flushings.extend(chunks) if not self._stream_ts: diff --git a/tests/slack_sdk/web/test_chat_stream.py b/tests/slack_sdk/web/test_chat_stream.py index 00c62b584..a6d846769 100644 --- a/tests/slack_sdk/web/test_chat_stream.py +++ b/tests/slack_sdk/web/test_chat_stream.py @@ -108,7 +108,7 @@ def test_streams_a_short_message(self): self.assertEqual(stop_request.get("ts"), "123.123") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "nice!"}]', + '[{"text": "nice!", "type": "markdown_text"}]', ) def test_streams_a_long_message(self): @@ -152,7 +152,7 @@ def test_streams_a_long_message(self): self.assertEqual(start_request.get("thread_ts"), "123.000") self.assertEqual( json.dumps(start_request.get("chunks")), - '[{"type": "markdown_text", "text": "**this messag"}]', + '[{"text": "**this messag", "type": "markdown_text"}]', ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") @@ -161,7 +161,7 @@ def test_streams_a_long_message(self): self.assertEqual(append_request.get("channel"), "C0123456789") self.assertEqual( json.dumps(append_request.get("chunks")), - '[{"type": "markdown_text", "text": "e is bold!"}]', + '[{"text": "e is bold!", "type": "markdown_text"}]', ) self.assertEqual(append_request.get("token"), "xoxb-chat_stream_test_token1") self.assertEqual(append_request.get("ts"), "123.123") @@ -174,7 +174,7 @@ def test_streams_a_long_message(self): self.assertEqual(stop_request.get("channel"), "C0123456789") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "**"}]', + '[{"text": "**", "type": "markdown_text"}]', ) self.assertEqual(stop_request.get("token"), "xoxb-chat_stream_test_token2") self.assertEqual(stop_request.get("ts"), "123.123") @@ -219,7 +219,7 @@ def test_streams_a_chunk_message(self): self.assertEqual(start_request.get("thread_ts"), "123.000") self.assertEqual( json.dumps(start_request.get("chunks")), - '[{"type": "markdown_text", "text": "**this is buffered**"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', + '[{"text": "**this is buffered**", "type": "markdown_text"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") @@ -237,7 +237,7 @@ def test_streams_a_chunk_message(self): self.assertEqual(stop_request.get("ts"), "123.123") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "\\n"}, {"text": ":space_invader:", "type": "markdown_text"}]', + '[{"text": "\\n", "type": "markdown_text"}, {"text": ":space_invader:", "type": "markdown_text"}]', ) def test_streams_errors_when_appending_to_an_unstarted_stream(self): diff --git a/tests/slack_sdk_async/web/test_async_chat_stream.py b/tests/slack_sdk_async/web/test_async_chat_stream.py index f9a97ecd4..2a4f5b931 100644 --- a/tests/slack_sdk_async/web/test_async_chat_stream.py +++ b/tests/slack_sdk_async/web/test_async_chat_stream.py @@ -110,7 +110,7 @@ async def test_streams_a_short_message(self): self.assertEqual(stop_request.get("ts"), "123.123") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "nice!"}]', + '[{"text": "nice!", "type": "markdown_text"}]', ) @async_test @@ -155,7 +155,7 @@ async def test_streams_a_long_message(self): self.assertEqual(start_request.get("thread_ts"), "123.000") self.assertEqual( json.dumps(start_request.get("chunks")), - '[{"type": "markdown_text", "text": "**this messag"}]', + '[{"text": "**this messag", "type": "markdown_text"}]', ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") @@ -164,7 +164,7 @@ async def test_streams_a_long_message(self): self.assertEqual(append_request.get("channel"), "C0123456789") self.assertEqual( json.dumps(append_request.get("chunks")), - '[{"type": "markdown_text", "text": "e is bold!"}]', + '[{"text": "e is bold!", "type": "markdown_text"}]', ) self.assertEqual(append_request.get("token"), "xoxb-chat_stream_test_token1") self.assertEqual(append_request.get("ts"), "123.123") @@ -177,7 +177,7 @@ async def test_streams_a_long_message(self): self.assertEqual(stop_request.get("channel"), "C0123456789") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "**"}]', + '[{"text": "**", "type": "markdown_text"}]', ) self.assertEqual(stop_request.get("token"), "xoxb-chat_stream_test_token2") self.assertEqual(stop_request.get("ts"), "123.123") @@ -223,7 +223,7 @@ async def test_streams_a_chunk_message(self): self.assertEqual(start_request.get("thread_ts"), "123.000") self.assertEqual( json.dumps(start_request.get("chunks")), - '[{"type": "markdown_text", "text": "**this is buffered**"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', + '[{"text": "**this is buffered**", "type": "markdown_text"}, {"id": "001", "status": "pending", "title": "Counting...", "type": "task_update"}]', ) self.assertEqual(start_request.get("recipient_team_id"), "T0123456789") self.assertEqual(start_request.get("recipient_user_id"), "U0123456789") @@ -241,7 +241,7 @@ async def test_streams_a_chunk_message(self): self.assertEqual(stop_request.get("ts"), "123.123") self.assertEqual( json.dumps(stop_request.get("chunks")), - '[{"type": "markdown_text", "text": "\\n"}, {"text": ":space_invader:", "type": "markdown_text"}]', + '[{"text": "\\n", "type": "markdown_text"}, {"text": ":space_invader:", "type": "markdown_text"}]', ) @async_test From 92c93e0d951c346e7d77a7ed4a47482b4e2329aa Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 16:56:59 -0800 Subject: [PATCH 09/14] fix: support explicit json values as chunk objects --- slack_sdk/web/chat_stream.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/slack_sdk/web/chat_stream.py b/slack_sdk/web/chat_stream.py index 63f5935f1..acdac728a 100644 --- a/slack_sdk/web/chat_stream.py +++ b/slack_sdk/web/chat_stream.py @@ -67,7 +67,7 @@ def append( self, *, markdown_text: Optional[str] = None, - chunks: Optional[Sequence[Chunk]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Optional[SlackResponse]: """Append to the stream. @@ -123,7 +123,7 @@ def stop( self, *, markdown_text: Optional[str] = None, - chunks: Optional[Sequence[Chunk]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, **kwargs, @@ -173,7 +173,7 @@ def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" - flushings: List[Chunk] = [] + flushings: List[Union[Dict, Chunk]] = [] if len(self._buffer) != 0: flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: @@ -190,9 +190,9 @@ def stop( self._state = "completed" return response - def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> SlackResponse: + def _flush_buffer(self, chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs) -> SlackResponse: """Flush the internal buffer with chunks by making appropriate API calls.""" - flushings: List[Chunk] = [] + flushings: List[Union[Dict, Chunk]] = [] if len(self._buffer) != 0: flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: From 7c3281442894dab4ae849cb8f9fda42be3773f4d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 13 Jan 2026 15:21:27 -0800 Subject: [PATCH 10/14] fix: mirror dictionary chunk option to async streamer --- slack_sdk/web/async_chat_stream.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/slack_sdk/web/async_chat_stream.py b/slack_sdk/web/async_chat_stream.py index 9785d123e..550902182 100644 --- a/slack_sdk/web/async_chat_stream.py +++ b/slack_sdk/web/async_chat_stream.py @@ -77,7 +77,7 @@ async def append( self, *, markdown_text: Optional[str] = None, - chunks: Optional[Sequence[Chunk]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Optional[AsyncSlackResponse]: """Append to the stream. @@ -133,7 +133,7 @@ async def stop( self, *, markdown_text: Optional[str] = None, - chunks: Optional[Sequence[Chunk]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, **kwargs, @@ -183,7 +183,7 @@ async def stop( raise e.SlackRequestError("Failed to stop stream: stream not started") self._stream_ts = str(response["ts"]) self._state = "in_progress" - flushings: List[Chunk] = [] + flushings: List[Union[Dict, Chunk]] = [] if len(self._buffer) != 0: flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: @@ -200,9 +200,9 @@ async def stop( self._state = "completed" return response - async def _flush_buffer(self, chunks: Optional[Sequence[Chunk]] = None, **kwargs) -> AsyncSlackResponse: + async def _flush_buffer(self, chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs) -> AsyncSlackResponse: """Flush the internal buffer with chunks by making appropriate API calls.""" - flushings: List[Chunk] = [] + flushings: List[Union[Dict, Chunk]] = [] if len(self._buffer) != 0: flushings.append(MarkdownTextChunk(text=self._buffer)) if chunks is not None: From 00b86529d19ce577858f6add4349500fc55ba036 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 13 Jan 2026 16:01:45 -0800 Subject: [PATCH 11/14] feat: add plan_update chunk for chat streaming --- slack_sdk/models/messages/chunk.py | 23 +++++++++++++++++++++++ tests/slack_sdk/models/test_chunks.py | 13 ++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index 837714af0..78e09b127 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -67,6 +67,29 @@ def __init__( self.text = text +class PlanUpdateChunk(Chunk): + type = "plan_update" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"title"}) + + def __init__( + self, + *, + title: str, + **others: Dict, + ): + """An updated title of plans for task and tool calls. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + super().__init__(type=self.type) + show_unknown_key_warning(self, others) + + self.title = title + + class URLSource(JsonObject): type = "url" diff --git a/tests/slack_sdk/models/test_chunks.py b/tests/slack_sdk/models/test_chunks.py index 1b8b58c96..435b48ab0 100644 --- a/tests/slack_sdk/models/test_chunks.py +++ b/tests/slack_sdk/models/test_chunks.py @@ -1,6 +1,6 @@ import unittest -from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk, URLSource +from slack_sdk.models.messages.chunk import MarkdownTextChunk, PlanUpdateChunk, TaskUpdateChunk, URLSource class MarkdownTextChunkTests(unittest.TestCase): @@ -14,6 +14,17 @@ def test_json(self): ) +class PlanUpdateChunkTests(unittest.TestCase): + def test_json(self): + self.assertDictEqual( + PlanUpdateChunk(title="Crunching numbers...").to_dict(), + { + "type": "plan_update", + "text": "Crunching numbers...", + }, + ) + + class TaskUpdateChunkTests(unittest.TestCase): def test_json(self): self.assertDictEqual( From b3da1b25f7aebc538fc09e306a048f9ba73a8f54 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 13 Jan 2026 17:48:40 -0800 Subject: [PATCH 12/14] test: update expected output to match actual usage --- tests/slack_sdk/models/test_chunks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/slack_sdk/models/test_chunks.py b/tests/slack_sdk/models/test_chunks.py index 435b48ab0..6d64bd17c 100644 --- a/tests/slack_sdk/models/test_chunks.py +++ b/tests/slack_sdk/models/test_chunks.py @@ -20,7 +20,7 @@ def test_json(self): PlanUpdateChunk(title="Crunching numbers...").to_dict(), { "type": "plan_update", - "text": "Crunching numbers...", + "title": "Crunching numbers...", }, ) From 4d355da88c927f15074f2a13d1b36337b3c637b2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 13 Jan 2026 17:57:27 -0800 Subject: [PATCH 13/14] fix: parse stringified plan_update chunks --- slack_sdk/models/messages/chunk.py | 2 ++ tests/slack_sdk/web/test_internal_utils.py | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index 78e09b127..5f225bdcc 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -34,6 +34,8 @@ def parse(cls, chunk: Union[Dict, "Chunk"]) -> Optional["Chunk"]: type = chunk["type"] if type == MarkdownTextChunk.type: return MarkdownTextChunk(**chunk) + elif type == PlanUpdateChunk.type: + return PlanUpdateChunk(**chunk) elif type == TaskUpdateChunk.type: return TaskUpdateChunk(**chunk) else: diff --git a/tests/slack_sdk/web/test_internal_utils.py b/tests/slack_sdk/web/test_internal_utils.py index fc6574aab..3e44f0c9c 100644 --- a/tests/slack_sdk/web/test_internal_utils.py +++ b/tests/slack_sdk/web/test_internal_utils.py @@ -4,10 +4,9 @@ from pathlib import Path from typing import Dict - from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, DividerBlock -from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk +from slack_sdk.models.messages.chunk import MarkdownTextChunk, PlanUpdateChunk, TaskUpdateChunk from slack_sdk.web.internal_utils import ( _build_unexpected_body_error_message, _get_url, @@ -59,9 +58,14 @@ def test_can_parse_sequence_of_attachments(self): def test_can_parse_sequence_of_chunks(self): for chunks in [ - [MarkdownTextChunk(text="fiz"), TaskUpdateChunk(id="001", title="baz", status="complete")], # list + [ + MarkdownTextChunk(text="fiz"), + PlanUpdateChunk(title="fuz"), + TaskUpdateChunk(id="001", title="baz", status="complete"), + ], # list ( MarkdownTextChunk(text="fiz"), + PlanUpdateChunk(title="fuz"), TaskUpdateChunk(id="001", title="baz", status="complete"), ), # tuple ]: @@ -87,7 +91,11 @@ def test_can_parse_str_attachments(self): def test_can_parse_str_chunks(self): input = json.dumps( - [MarkdownTextChunk(text="fiz").to_dict(), TaskUpdateChunk(id="001", title="baz", status="complete").to_dict()] + [ + MarkdownTextChunk(text="fiz").to_dict(), + PlanUpdateChunk(title="fuz").to_dict(), + TaskUpdateChunk(id="001", title="baz", status="complete").to_dict(), + ] ) kwargs = {"chunks": input} _parse_web_class_objects(kwargs) From 33f745a1b3d4b11583af444e332d4f3f789f1d84 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Fri, 16 Jan 2026 16:15:54 -0800 Subject: [PATCH 14/14] test: import for tests to pass without errors again --- tests/slack_sdk/models/test_chunks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/slack_sdk/models/test_chunks.py b/tests/slack_sdk/models/test_chunks.py index c86cbdb44..293f5acb2 100644 --- a/tests/slack_sdk/models/test_chunks.py +++ b/tests/slack_sdk/models/test_chunks.py @@ -1,7 +1,7 @@ import unittest from slack_sdk.models.blocks.block_elements import UrlSourceElement -from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk +from slack_sdk.models.messages.chunk import MarkdownTextChunk, PlanUpdateChunk, TaskUpdateChunk class MarkdownTextChunkTests(unittest.TestCase):