From 6590b750de78b6c2824a2264e59f178ec9c10b01 Mon Sep 17 00:00:00 2001 From: tony-chinchill-ai Date: Fri, 10 Apr 2026 14:33:49 -0700 Subject: [PATCH 1/2] feat: add ThinkingChunk to StreamChunk protocol for AI reasoning display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges pydantic-ai thinking events to SDK StreamChunks so agent reasoning chains can be rendered in chat UIs. Additive-only — consumers that don't handle "thinking" chunks silently ignore them. Co-Authored-By: Claude Sonnet 4.6 --- src/chat_sdk/__init__.py | 2 ++ src/chat_sdk/adapters/slack/adapter.py | 2 ++ src/chat_sdk/adapters/teams/adapter.py | 15 +++++++- src/chat_sdk/types.py | 15 +++++++- tests/test_teams_coverage.py | 47 ++++++++++++++++++++++++++ tests/test_types.py | 19 +++++++++++ 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 4a41c76..11f85c4 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -158,6 +158,7 @@ StreamChunk, StreamOptions, TaskUpdateChunk, + ThinkingChunk, Thread, ThreadInfo, ThreadSummary, @@ -339,6 +340,7 @@ "StreamChunk", "StreamOptions", "TaskUpdateChunk", + "ThinkingChunk", "Thread", "ThreadInfo", "ThreadSummary", diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 9020530..57926d0 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -2057,6 +2057,8 @@ async def send_structured_chunk(chunk: StreamChunk) -> None: chunk_data["output"] = chunk.output # type: ignore[union-attr] if hasattr(chunk, "text"): chunk_data["text"] = chunk.text # type: ignore[union-attr] + if hasattr(chunk, "content"): + chunk_data["content"] = chunk.content # type: ignore[union-attr] if first: await streamer.append(chunks=[chunk_data], token=token) diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index ee2c7e4..4c4edbc 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -785,20 +785,33 @@ async def stream( accumulated = "" message_id: str | None = None + thinking = "" + async for chunk in text_stream: text = "" if isinstance(chunk, str): text = chunk elif isinstance(chunk, dict) and chunk.get("type") == "markdown_text": text = chunk.get("text", "") + elif hasattr(chunk, "type") and getattr(chunk, "type", None) == "markdown_text": + text = getattr(chunk, "text", "") + elif hasattr(chunk, "type") and getattr(chunk, "type", None) == "thinking": + thinking = getattr(chunk, "content", "") + # Render thinking as italic context above the main text + if accumulated or not message_id: + continue # Will be included in next text update if not text: continue accumulated += text + display_text = accumulated + if thinking: + display_text = f"*{thinking}*\n\n{accumulated}" + activity_payload = { "type": "message", - "text": accumulated, + "text": display_text, "textFormat": "markdown", } diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index ec6b9d4..002c2e9 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -740,6 +740,19 @@ class TaskUpdateChunk: output: str | None = None +@dataclass +class ThinkingChunk: + """AI reasoning/thinking display. + + Used by agents that emit structured thinking events (e.g. pydantic-ai + ``ThinkingPart``). Adapters can render these as context blocks (Slack), + Adaptive Card sections (Teams), or styled text on other platforms. + """ + + type: Literal["thinking"] = "thinking" + content: str = "" + + @dataclass class PlanUpdateChunk: """Plan title update.""" @@ -748,7 +761,7 @@ class PlanUpdateChunk: title: str = "" -StreamChunk = MarkdownTextChunk | TaskUpdateChunk | PlanUpdateChunk +StreamChunk = MarkdownTextChunk | TaskUpdateChunk | ThinkingChunk | PlanUpdateChunk @dataclass diff --git a/tests/test_teams_coverage.py b/tests/test_teams_coverage.py index 2ac031b..415121d 100644 --- a/tests/test_teams_coverage.py +++ b/tests/test_teams_coverage.py @@ -1085,6 +1085,53 @@ async def text_stream(): result = await adapter.stream(tid, text_stream()) assert result.id == "" # nothing sent + async def test_stream_thinking_chunk_renders_italic(self): + adapter = _make_adapter(logger=_make_logger()) + update_mock = AsyncMock() + adapter._teams_send = AsyncMock(return_value={"id": "s1"}) + adapter._teams_update = update_mock + + tid = adapter.encode_thread_id( + TeamsThreadId( + conversation_id="19:abc@thread.tacv2", + service_url="https://smba.trafficmanager.net/teams/", + ) + ) + + from chat_sdk.types import ThinkingChunk + + async def text_stream(): + yield ThinkingChunk(content="Analyzing request") + yield "Hello " + yield "World" + + result = await adapter.stream(tid, text_stream()) + assert result.raw["text"] == "Hello World" + # The update call should have included the italic thinking prefix + last_update_payload = update_mock.call_args_list[-1][0][2] + assert "*Analyzing request*" in last_update_payload["text"] + + async def test_stream_thinking_chunk_without_text_is_skipped(self): + adapter = _make_adapter(logger=_make_logger()) + adapter._teams_send = AsyncMock(return_value={"id": "s1"}) + adapter._teams_update = AsyncMock() + + tid = adapter.encode_thread_id( + TeamsThreadId( + conversation_id="19:abc@thread.tacv2", + service_url="https://smba.trafficmanager.net/teams/", + ) + ) + + from chat_sdk.types import ThinkingChunk + + async def text_stream(): + yield ThinkingChunk(content="Just thinking...") + + result = await adapter.stream(tid, text_stream()) + # No text was yielded so nothing should have been sent + assert result.raw["text"] == "" + # --------------------------------------------------------------------------- # _extract_card_title diff --git a/tests/test_types.py b/tests/test_types.py index 3b81430..59aeb36 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -12,6 +12,7 @@ Message, MessageMetadata, RawMessage, + ThinkingChunk, ) @@ -237,3 +238,21 @@ def test_creation(self): assert raw.id == "raw-001" assert raw.thread_id == "thread-001" assert raw.raw["platform"] == "test" + + +class TestThinkingChunk: + """Tests for ThinkingChunk dataclass.""" + + def test_defaults(self): + chunk = ThinkingChunk() + assert chunk.type == "thinking" + assert chunk.content == "" + + def test_with_content(self): + chunk = ThinkingChunk(content="Analyzing the user's request") + assert chunk.type == "thinking" + assert chunk.content == "Analyzing the user's request" + + def test_type_is_literal(self): + chunk = ThinkingChunk(content="step 1") + assert chunk.type == "thinking" From 3b72aa5ef558ff3a71f5832a28a56d2188b05267 Mon Sep 17 00:00:00 2001 From: tony-chinchill-ai Date: Fri, 10 Apr 2026 14:44:42 -0700 Subject: [PATCH 2/2] refactor: group hasattr chunk-type checks in Teams stream() Co-Authored-By: Claude Sonnet 4.6 --- src/chat_sdk/adapters/teams/adapter.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index 4c4edbc..b2027d4 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -793,13 +793,15 @@ async def stream( text = chunk elif isinstance(chunk, dict) and chunk.get("type") == "markdown_text": text = chunk.get("text", "") - elif hasattr(chunk, "type") and getattr(chunk, "type", None) == "markdown_text": - text = getattr(chunk, "text", "") - elif hasattr(chunk, "type") and getattr(chunk, "type", None) == "thinking": - thinking = getattr(chunk, "content", "") - # Render thinking as italic context above the main text - if accumulated or not message_id: - continue # Will be included in next text update + elif hasattr(chunk, "type"): + chunk_type = getattr(chunk, "type", None) + if chunk_type == "markdown_text": + text = getattr(chunk, "text", "") + elif chunk_type == "thinking": + thinking = getattr(chunk, "content", "") + # Render thinking as italic context above the main text + if accumulated or not message_id: + continue # Will be included in next text update if not text: continue