From 3f2da2d51a3ce645d67fa932023eda16de270bff Mon Sep 17 00:00:00 2001 From: Atishay Tiwari Date: Sat, 11 Apr 2026 20:56:12 -0700 Subject: [PATCH 1/3] fix: pass unknown SSE event types through stream instead of dropping silently The __stream__ methods in Stream and AsyncStream used separate if statements with no else clause, causing any unrecognized event type to be silently dropped. This broke client.beta.sessions.events.stream() entirely since managed agents emit different event names (agent.message, session.status_*) that don't match the hardcoded Messages API event list. Fix: Convert if/if/if/if to if/elif/elif/elif/else in both sync and async __stream__ methods so unknown events pass through process_data() instead of being silently discarded. Fixes #1357 --- src/anthropic/_streaming.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/anthropic/_streaming.py b/src/anthropic/_streaming.py index bfb3e821..2062fb28 100644 --- a/src/anthropic/_streaming.py +++ b/src/anthropic/_streaming.py @@ -85,7 +85,7 @@ def __stream__(self) -> Iterator[_T]: if sse.event == "completion": yield process_data(data=sse.json(), cast_to=cast_to, response=response) - if ( + elif ( sse.event == "message_start" or sse.event == "message_delta" or sse.event == "message_stop" @@ -100,10 +100,10 @@ def __stream__(self) -> Iterator[_T]: yield process_data(data=data, cast_to=cast_to, response=response) - if sse.event == "ping": + elif sse.event == "ping": continue - if sse.event == "error": + elif sse.event == "error": body = sse.data try: @@ -206,7 +206,7 @@ async def __stream__(self) -> AsyncIterator[_T]: if sse.event == "completion": yield process_data(data=sse.json(), cast_to=cast_to, response=response) - if ( + elif ( sse.event == "message_start" or sse.event == "message_delta" or sse.event == "message_stop" @@ -221,10 +221,10 @@ async def __stream__(self) -> AsyncIterator[_T]: yield process_data(data=data, cast_to=cast_to, response=response) - if sse.event == "ping": + elif sse.event == "ping": continue - if sse.event == "error": + elif sse.event == "error": body = sse.data try: From 42e073c8aba6a0da5f549a90d1c20b8a0f9b8241 Mon Sep 17 00:00:00 2001 From: Atishay Tiwari Date: Sat, 11 Apr 2026 21:36:00 -0700 Subject: [PATCH 2/3] fix: add missing server tool result types to ParsedContentBlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParsedContentBlock in types/parsed_message.py only included WebSearchToolResultBlock but was missing 5 other server tool result types: - CodeExecutionToolResultBlock - WebFetchToolResultBlock - BashCodeExecutionToolResultBlock - TextEditorCodeExecutionToolResultBlock - ToolSearchToolResultBlock When any of these arrived as content_block_start events during streaming, construct_type() failed discriminated union parsing and silently dropped them from current_snapshot.content. As a result, get_final_message().content was missing server tool results when client tools ran concurrently with server tools — the bug reported in #1325. Fix: add all 5 missing types to ParsedContentBlock to match ContentBlock. Adds sync + async regression tests with a fixture that streams both web_search_tool_result and code_execution_tool_result blocks alongside a concurrent client tool_use, asserting all 4 blocks are present in get_final_message().content. Fixes #1325 --- src/anthropic/types/parsed_message.py | 10 +++ .../fixtures/server_tool_result_response.txt | 35 ++++++++ tests/lib/streaming/test_messages.py | 81 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 tests/lib/streaming/fixtures/server_tool_result_response.txt diff --git a/src/anthropic/types/parsed_message.py b/src/anthropic/types/parsed_message.py index 607e6e2d..86d722de 100644 --- a/src/anthropic/types/parsed_message.py +++ b/src/anthropic/types/parsed_message.py @@ -11,6 +11,11 @@ from .server_tool_use_block import ServerToolUseBlock from .redacted_thinking_block import RedactedThinkingBlock from .web_search_tool_result_block import WebSearchToolResultBlock +from .web_fetch_tool_result_block import WebFetchToolResultBlock +from .code_execution_tool_result_block import CodeExecutionToolResultBlock +from .bash_code_execution_tool_result_block import BashCodeExecutionToolResultBlock +from .text_editor_code_execution_tool_result_block import TextEditorCodeExecutionToolResultBlock +from .tool_search_tool_result_block import ToolSearchToolResultBlock ResponseFormatT = TypeVar("ResponseFormatT", default=None) @@ -37,6 +42,11 @@ class ParsedTextBlock(TextBlock, Generic[ResponseFormatT]): ToolUseBlock, ServerToolUseBlock, WebSearchToolResultBlock, + WebFetchToolResultBlock, + CodeExecutionToolResultBlock, + BashCodeExecutionToolResultBlock, + TextEditorCodeExecutionToolResultBlock, + ToolSearchToolResultBlock, ], PropertyInfo(discriminator="type"), ] diff --git a/tests/lib/streaming/fixtures/server_tool_result_response.txt b/tests/lib/streaming/fixtures/server_tool_result_response.txt new file mode 100644 index 00000000..a8f2d527 --- /dev/null +++ b/tests/lib/streaming/fixtures/server_tool_result_response.txt @@ -0,0 +1,35 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_server_tool_result_test","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"svu_web_001","name":"web_search","input":{}}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu_client_001","name":"ask_internal_tool","caller":{"type":"direct"},"input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"test\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"type":"web_search_tool_result","tool_use_id":"svu_web_001","content":[{"type":"web_search_result","url":"https://example.com","title":"Example Result","encrypted_content":"enc_abc123","page_age":null}]}} + +event: content_block_stop +data: {"type":"content_block_stop","index":2} + +event: content_block_start +data: {"type":"content_block_start","index":3,"content_block":{"type":"code_execution_tool_result","tool_use_id":"svu_code_001","content":{"type":"code_execution_result","return_code":0,"stdout":"hello","stderr":"","content":[]}}} + +event: content_block_stop +data: {"type":"content_block_stop","index":3} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":20}} + +event: message_stop +data: {"type":"message_stop"} diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index 814e7e45..0555b816 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -314,3 +314,84 @@ def test_tracks_tool_input_type_alias_is_up_to_date() -> None: f"ContentBlock type {block_type.__name__} has an input property, " f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." ) + + +# Regression tests for https://github.com/anthropics/anthropic-sdk-python/issues/1325 +# +# ParsedContentBlock was missing 5 of 6 server tool result types. When blocks like +# CodeExecutionToolResultBlock arrived as content_block_start events during streaming, +# construct_type() failed discriminated union parsing and silently dropped them from +# current_snapshot.content, so get_final_message().content was missing server tool results. + + +class TestServerToolResultBlocks: + @pytest.mark.respx(base_url=base_url) + def test_server_tool_result_blocks_not_dropped(self, respx_mock: MockRouter) -> None: + """ + Sync: server tool result blocks (web_search_tool_result, code_execution_tool_result) + must appear in get_final_message().content when streamed concurrently with client tools. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("server_tool_result_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "search the web and run code"}], + model="claude-sonnet-4-20250514", + ) as stream: + events = [event for event in stream] + final_message = stream.get_final_message() + + block_types = [b.type for b in final_message.content] + + assert len(final_message.content) == 4, ( + f"Expected 4 content blocks, got {len(final_message.content)}. " + f"Types present: {block_types}. " + f"server tool result blocks are being silently dropped." + ) + assert final_message.content[0].type == "server_tool_use" + assert final_message.content[1].type == "tool_use" + assert final_message.content[2].type == "web_search_tool_result" + assert final_message.content[3].type == "code_execution_tool_result" + + # All 4 content_block_start events must have been yielded to the caller + block_start_events = [e for e in events if e.type == "content_block_start"] + assert len(block_start_events) == 4, ( + f"Expected 4 content_block_start events, got {len(block_start_events)}" + ) + + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_async_server_tool_result_blocks_not_dropped(self, respx_mock: MockRouter) -> None: + """ + Async: same regression test as above for the async streaming path. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("server_tool_result_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "search the web and run code"}], + model="claude-sonnet-4-20250514", + ) as stream: + events = [event async for event in stream] + final_message = await stream.get_final_message() + + block_types = [b.type for b in final_message.content] + + assert len(final_message.content) == 4, ( + f"Expected 4 content blocks, got {len(final_message.content)}. " + f"Types present: {block_types}. " + f"server tool result blocks are being silently dropped." + ) + assert final_message.content[0].type == "server_tool_use" + assert final_message.content[1].type == "tool_use" + assert final_message.content[2].type == "web_search_tool_result" + assert final_message.content[3].type == "code_execution_tool_result" + + block_start_events = [e for e in events if e.type == "content_block_start"] + assert len(block_start_events) == 4, ( + f"Expected 4 content_block_start events, got {len(block_start_events)}" + ) From 4148637e098db3f4b56b19f26088359acd80dda5 Mon Sep 17 00:00:00 2001 From: Atishay Tiwari Date: Mon, 13 Apr 2026 21:09:16 -0700 Subject: [PATCH 3/3] fix: fire InputJsonEvent for server_tool_use blocks in input_json_delta handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_events() only fired InputJsonEvent for tool_use blocks, skipping server_tool_use entirely. This is inconsistent with TRACKS_TOOL_INPUT which already includes ServerToolUseBlock — the SDK accumulates the input JSON internally for server tool use blocks but never surfaces the InputJsonEvent to the caller. Anyone streaming server tool calls and listening to on_input_json receives no events, even though the underlying JSON is being accumulated correctly. --- src/anthropic/lib/streaming/_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index b6b5f538..d25fbfa9 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -360,7 +360,7 @@ def build_events( ) ) elif event.delta.type == "input_json_delta": - if content_block.type == "tool_use": + if content_block.type == "tool_use" or content_block.type == "server_tool_use": events_to_fire.append( build( InputJsonEvent,