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..b2027d4 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -785,20 +785,35 @@ 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"): + 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 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"