Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c3bdb2d
feat: accept chunks as arguments to chat.{start,append,stop}Stream me…
zimeg Dec 11, 2025
8c56e22
fix: remove unsupported and unused identifiers for full support
zimeg Dec 16, 2025
783ada5
style: remove mypy extra ignore comment for overriden attributes
zimeg Dec 17, 2025
1fb7355
test: confirm chunks parse as expected json values
zimeg Dec 17, 2025
5de794b
feat: support and flush chunks in the chat stream helper
zimeg Dec 17, 2025
61d6d53
test: dump chunks json before comparison for exact parsings
zimeg Dec 18, 2025
85081e1
test: update async tests to expect chunks when flushing buffer
zimeg Dec 18, 2025
a6bb951
style: prefer using markdown text chunks in internal calls
zimeg Dec 18, 2025
92c93e0
fix: support explicit json values as chunk objects
zimeg Dec 18, 2025
9d40cb0
feat: add task_card block
zimeg Jan 13, 2026
8745c7b
feat: export the task_card block from blocks
zimeg Jan 13, 2026
6073ffe
refactor: move url source block element to documented elements
zimeg Jan 14, 2026
17b75d7
feat: add plan block
zimeg Jan 14, 2026
1184a8f
docs: add description to task_card block
zimeg Jan 14, 2026
7a61bbf
fix: ignore overrides for specific attributes of block elements
zimeg Jan 14, 2026
bd9886c
test: confirm plan and task_card json is parsed as expected blocks
zimeg Jan 14, 2026
2fc6abb
docs: include reference for the task_card status argument
zimeg Jan 14, 2026
90fdd0c
chore: merge w main
zimeg Jan 16, 2026
86101a2
chore: merge w base branch
zimeg Jan 16, 2026
9a3e529
fix: import url source element to use in the file later
zimeg Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions slack_sdk/models/blocks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
RichTextBlock,
SectionBlock,
TableBlock,
TaskCardBlock,
VideoBlock,
)

Expand Down Expand Up @@ -137,6 +138,7 @@
"MarkdownBlock",
"SectionBlock",
"TableBlock",
"TaskCardBlock",
"VideoBlock",
"RichTextBlock",
]
46 changes: 46 additions & 0 deletions slack_sdk/models/blocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from slack_sdk.models import show_unknown_key_warning
from slack_sdk.models.basic_objects import JsonObject, JsonValidator
from slack_sdk.models.messages.chunk import URLSource
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thought: This is introduced in #1806 but I'm not certain if it's best kept with the "messages" model or if it should be included as a block element?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗣️ note: In 6073ffe it's moved to the block elements - the url source can be used in standalone messages as well as in chunks.


from ...errors import SlackObjectFormationError
from .basic_components import MarkdownTextObject, PlainTextObject, SlackFile, TextObject
Expand Down Expand Up @@ -97,6 +98,8 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
return RichTextBlock(**block)
elif type == TableBlock.type:
return TableBlock(**block)
elif type == TaskCardBlock.type:
return TaskCardBlock(**block)
else:
cls.logger.warning(f"Unknown block detected and skipped ({block})")
return None
Expand Down Expand Up @@ -777,3 +780,46 @@ def __init__(
@JsonValidator("rows attribute must be specified")
def _validate_rows(self):
return self.rows is not None and len(self.rows) > 0


class TaskCardBlock(Block):
type = "task_card"

@property
def attributes(self) -> Set[str]: # type: ignore[override]
return super().attributes.union(
{
"task_id",
"title",
"details",
"output",
"sources",
"status",
}
)

def __init__(
self,
*,
task_id: str,
title: str,
details: Optional[Union[RichTextBlock, dict]] = None,
output: Optional[Union[RichTextBlock, dict]] = None,
sources: Optional[Sequence[Union[URLSource, dict]]] = None,
status: str, # pending, in_progress, complete, error
block_id: Optional[str] = None,
**others: dict,
):
super().__init__(type=self.type, block_id=block_id)
show_unknown_key_warning(self, others)

self.task_id = task_id
self.title = title
self.details = details
self.output = output
self.sources = sources
self.status = status

@JsonValidator("status must be an expected value (pending, in_progress, complete, or error)")
def _validate_rows(self):
return self.status in ["pending", "in_progress", "complete", "error"]
155 changes: 155 additions & 0 deletions slack_sdk/models/messages/chunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import logging
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


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]:
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: str, # "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)}")
36 changes: 26 additions & 10 deletions slack_sdk/web/async_chat_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +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, MarkdownTextChunk
from slack_sdk.models.metadata import Metadata
from slack_sdk.web.async_slack_response import AsyncSlackResponse

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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: List[Chunk] = []
if len(self._buffer) != 0:
flushings.append(MarkdownTextChunk(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: List[Chunk] = []
if len(self._buffer) != 0:
flushings.append(MarkdownTextChunk(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"
Expand All @@ -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
Loading