From 42dff4bf55c59aa58a60f5f2f688b366aeb36a72 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Sat, 7 Mar 2026 13:31:22 +0100 Subject: [PATCH] fix(bedrock): emit contentBlockStart for all content block types in non-streaming mode In non-streaming mode (streaming=False), contentBlockStart events were only emitted for toolUse content blocks. Text, reasoning, and citation content blocks went straight to contentBlockDelta without a preceding contentBlockStart event. This broke parity with streaming mode where Bedrock itself sends contentBlockStart for every content block type, and caused consumers iterating stream_async events to miss contentBlockStart events for non-tool-use content. Now emit contentBlockStart with contentBlockIndex for all content block types (text, reasoningContent, citationsContent, toolUse) to match streaming behavior. Fixes #1460 --- src/strands/models/bedrock.py | 27 +++++++++++++++++++++++---- tests/strands/models/test_bedrock.py | 14 +++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 3fa907995..a48e810de 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -926,11 +926,14 @@ def _convert_non_streaming_to_streaming(self, response: dict[str, Any]) -> Itera yield {"messageStart": {"role": response["output"]["message"]["role"]}} # Process content blocks - for content in cast(list[ContentBlock], response["output"]["message"]["content"]): - # Yield contentBlockStart event if needed + for content_block_index, content in enumerate( + cast(list[ContentBlock], response["output"]["message"]["content"]) + ): + # Yield contentBlockStart for all content block types to match streaming behavior if "toolUse" in content: yield { "contentBlockStart": { + "contentBlockIndex": content_block_index, "start": { "toolUse": { "toolUseId": content["toolUse"]["toolUseId"], @@ -945,14 +948,24 @@ def _convert_non_streaming_to_streaming(self, response: dict[str, Any]) -> Itera yield {"contentBlockDelta": {"delta": {"toolUse": {"input": input_value}}}} elif "text" in content: - # Then yield the text as a delta + yield { + "contentBlockStart": { + "contentBlockIndex": content_block_index, + "start": {}, + } + } yield { "contentBlockDelta": { "delta": {"text": content["text"]}, } } elif "reasoningContent" in content: - # Then yield the reasoning content as a delta + yield { + "contentBlockStart": { + "contentBlockIndex": content_block_index, + "start": {}, + } + } yield { "contentBlockDelta": { "delta": {"reasoningContent": {"text": content["reasoningContent"]["reasoningText"]["text"]}} @@ -970,6 +983,12 @@ def _convert_non_streaming_to_streaming(self, response: dict[str, Any]) -> Itera } } elif "citationsContent" in content: + yield { + "contentBlockStart": { + "contentBlockIndex": content_block_index, + "start": {}, + } + } # For non-streaming citations, emit text and metadata deltas in sequence # to match streaming behavior where they flow naturally if "content" in content["citationsContent"]: diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 66fe8ab00..2c121b9bb 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -1092,6 +1092,7 @@ async def test_stream_with_streaming_false(bedrock_client, alist, messages): tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"text": "test"}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "end_turn", "additionalModelResponseFields": None}}, @@ -1122,7 +1123,12 @@ async def test_stream_with_streaming_false_and_tool_use(bedrock_client, alist, m tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, - {"contentBlockStart": {"start": {"toolUse": {"toolUseId": "123", "name": "dummyTool"}}}}, + { + "contentBlockStart": { + "contentBlockIndex": 0, + "start": {"toolUse": {"toolUseId": "123", "name": "dummyTool"}}, + } + }, {"contentBlockDelta": {"delta": {"toolUse": {"input": '{"hello": "world!"}'}}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "tool_use", "additionalModelResponseFields": None}}, @@ -1159,6 +1165,7 @@ async def test_stream_with_streaming_false_and_reasoning(bedrock_client, alist, tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"reasoningContent": {"text": "Thinking really hard...."}}}}, {"contentBlockDelta": {"delta": {"reasoningContent": {"signature": "123"}}}}, {"contentBlockStop": {}}, @@ -1197,6 +1204,7 @@ async def test_stream_and_reasoning_no_signature(bedrock_client, alist, messages tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"reasoningContent": {"text": "Thinking really hard...."}}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "tool_use", "additionalModelResponseFields": None}}, @@ -1224,6 +1232,7 @@ async def test_stream_with_streaming_false_with_metrics_and_usage(bedrock_client tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"text": "test"}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "tool_use", "additionalModelResponseFields": None}}, @@ -1265,6 +1274,7 @@ async def test_stream_input_guardrails(bedrock_client, alist, messages): tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"text": "test"}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "end_turn", "additionalModelResponseFields": None}}, @@ -1316,6 +1326,7 @@ async def test_stream_output_guardrails(bedrock_client, alist, messages): tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"text": "test"}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "end_turn", "additionalModelResponseFields": None}}, @@ -1369,6 +1380,7 @@ async def test_stream_output_guardrails_redacts_output(bedrock_client, alist, me tru_events = await alist(response) exp_events = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, {"contentBlockDelta": {"delta": {"text": "test"}}}, {"contentBlockStop": {}}, {"messageStop": {"stopReason": "end_turn", "additionalModelResponseFields": None}},