diff --git a/src/strands/event_loop/streaming.py b/src/strands/event_loop/streaming.py index 954633807..5f409e0a6 100644 --- a/src/strands/event_loop/streaming.py +++ b/src/strands/event_loop/streaming.py @@ -400,6 +400,11 @@ async def process_stream( state["message"] = handle_message_start(chunk["messageStart"], state["message"]) elif "contentBlockStart" in chunk: state["current_tool_use"] = handle_content_block_start(chunk["contentBlockStart"]) + if state["current_tool_use"]: + yield ToolUseStreamEvent( + delta={"toolUse": {"input": ""}}, + current_tool_use=state["current_tool_use"], + ) elif "contentBlockDelta" in chunk: state, typed_event = handle_content_block_delta(chunk["contentBlockDelta"], state) yield typed_event diff --git a/tests/strands/event_loop/test_streaming.py b/tests/strands/event_loop/test_streaming.py index b2cc152cb..bf147b68c 100644 --- a/tests/strands/event_loop/test_streaming.py +++ b/tests/strands/event_loop/test_streaming.py @@ -603,6 +603,19 @@ def test_extract_usage_metrics_empty_metadata(): }, }, }, + { + "current_tool_use": { + "input": {"key": "value"}, + "name": "test", + "toolUseId": "123", + }, + "delta": { + "toolUse": { + "input": "", + }, + }, + "type": "tool_use_stream", + }, { "event": { "contentBlockDelta": { @@ -616,9 +629,7 @@ def test_extract_usage_metrics_empty_metadata(): }, { "current_tool_use": { - "input": { - "key": "value", - }, + "input": {"key": "value"}, "name": "test", "toolUseId": "123", }, @@ -1257,6 +1268,39 @@ async def test_stream_messages_none_system_prompt_content(agenerator, alist): assert non_typed_events == [] +@pytest.mark.asyncio +async def test_process_stream_emits_tool_use_event_on_content_block_start(agenerator, alist): + """Test that a ToolUseStreamEvent is emitted on contentBlockStart with toolUse. + + This ensures tool calls are visible to users even when executed during the model's + thinking/reasoning phase before any contentBlockDelta events are received. + See: https://github.com/strands-agents/sdk-python/issues/1551 + """ + response = [ + {"messageStart": {"role": "assistant"}}, + { + "contentBlockStart": {"start": {"toolUse": {"toolUseId": "tool-1", "name": "get_user_info"}}}, + }, + {"contentBlockStop": {}}, + {"messageStop": {"stopReason": "tool_use"}}, + { + "metadata": { + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 1}, + "metrics": {"latencyMs": 1}, + } + }, + ] + + stream = strands.event_loop.streaming.process_stream(agenerator(response)) + events = await alist(stream) + + # Find the ToolUseStreamEvent - should be emitted right after contentBlockStart + tool_use_events = [e for e in events if e.get("type") == "tool_use_stream"] + assert len(tool_use_events) == 1 + assert tool_use_events[0]["current_tool_use"]["name"] == "get_user_info" + assert tool_use_events[0]["current_tool_use"]["toolUseId"] == "tool-1" + + @pytest.mark.asyncio async def test_stream_messages_normalizes_messages(agenerator, alist): mock_model = unittest.mock.MagicMock()