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: diff --git a/src/anthropic/types/beta/parsed_beta_message.py b/src/anthropic/types/beta/parsed_beta_message.py index 0fca18f8..abca5598 100644 --- a/src/anthropic/types/beta/parsed_beta_message.py +++ b/src/anthropic/types/beta/parsed_beta_message.py @@ -15,6 +15,9 @@ from .beta_container_upload_block import BetaContainerUploadBlock from .beta_redacted_thinking_block import BetaRedactedThinkingBlock from .beta_web_search_tool_result_block import BetaWebSearchToolResultBlock +from .beta_web_fetch_tool_result_block import BetaWebFetchToolResultBlock +from .beta_advisor_tool_result_block import BetaAdvisorToolResultBlock +from .beta_tool_search_tool_result_block import BetaToolSearchToolResultBlock from .beta_code_execution_tool_result_block import BetaCodeExecutionToolResultBlock from .beta_bash_code_execution_tool_result_block import BetaBashCodeExecutionToolResultBlock from .beta_text_editor_code_execution_tool_result_block import BetaTextEditorCodeExecutionToolResultBlock @@ -44,6 +47,9 @@ class ParsedBetaTextBlock(BetaTextBlock, Generic[ResponseFormatT]): BetaToolUseBlock, BetaServerToolUseBlock, BetaWebSearchToolResultBlock, + BetaWebFetchToolResultBlock, + BetaAdvisorToolResultBlock, + BetaToolSearchToolResultBlock, BetaCodeExecutionToolResultBlock, BetaBashCodeExecutionToolResultBlock, BetaTextEditorCodeExecutionToolResultBlock, diff --git a/src/anthropic/types/parsed_message.py b/src/anthropic/types/parsed_message.py index 607e6e2d..e333d9ce 100644 --- a/src/anthropic/types/parsed_message.py +++ b/src/anthropic/types/parsed_message.py @@ -11,6 +11,12 @@ 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 +from .container_upload_block import ContainerUploadBlock ResponseFormatT = TypeVar("ResponseFormatT", default=None) @@ -37,6 +43,12 @@ class ParsedTextBlock(TextBlock, Generic[ResponseFormatT]): ToolUseBlock, ServerToolUseBlock, WebSearchToolResultBlock, + WebFetchToolResultBlock, + CodeExecutionToolResultBlock, + BashCodeExecutionToolResultBlock, + TextEditorCodeExecutionToolResultBlock, + ToolSearchToolResultBlock, + ContainerUploadBlock, ], 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)}" + )