From 077fff1886ba2cf15b56300c2854ed61d05c686f Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Fri, 5 Sep 2025 14:01:23 -0400 Subject: [PATCH 1/2] fix: only add signature to reasoning blocks if signature is provided Per #790, right now we're adding a blank signature to all reasoning content, which breaks providers (openai.gpt-oss-120b-1:0) which don't provide signatures. We already add a signature if it's not already present, so the fix here is just to blank out the signature. --- src/strands/event_loop/streaming.py | 1 - src/strands/types/_events.py | 4 ++ tests/strands/event_loop/test_streaming.py | 77 +++++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/strands/event_loop/streaming.py b/src/strands/event_loop/streaming.py index efe094e5f..183fe1ec8 100644 --- a/src/strands/event_loop/streaming.py +++ b/src/strands/event_loop/streaming.py @@ -289,7 +289,6 @@ async def process_stream(chunks: AsyncIterable[StreamEvent]) -> AsyncGenerator[T "text": "", "current_tool_use": {}, "reasoningText": "", - "signature": "", "citationsContent": [], } state["content"] = state["message"]["content"] diff --git a/src/strands/types/_events.py b/src/strands/types/_events.py index ccdab1846..06712386a 100644 --- a/src/strands/types/_events.py +++ b/src/strands/types/_events.py @@ -197,6 +197,10 @@ def __init__( """ super().__init__({"stop": (stop_reason, message, usage, metrics)}) + @property + def message(self) -> Message: + return cast(Message, self["stop"][1]) + @property @override def is_callback_event(self) -> bool: diff --git a/tests/strands/event_loop/test_streaming.py b/tests/strands/event_loop/test_streaming.py index ce12b4e98..a76c85215 100644 --- a/tests/strands/event_loop/test_streaming.py +++ b/tests/strands/event_loop/test_streaming.py @@ -1,10 +1,11 @@ import unittest.mock +from typing import cast import pytest import strands import strands.event_loop -from strands.types._events import TypedEvent +from strands.types._events import ModelStopReason, TypedEvent from strands.types.streaming import ( ContentBlockDeltaEvent, ContentBlockStartEvent, @@ -565,6 +566,80 @@ async def test_process_stream(response, exp_events, agenerator, alist): assert non_typed_events == [] +@pytest.mark.asyncio +async def test_process_stream_with_no_signature(agenerator, alist): + response = [ + {"messageStart": {"role": "assistant"}}, + { + "contentBlockDelta": { + "delta": {"reasoningContent": {"text": 'User asks: "Reason about 2+2" so I will do that'}}, + "contentBlockIndex": 0, + } + }, + {"contentBlockDelta": {"delta": {"reasoningContent": {"text": "."}}, "contentBlockIndex": 0}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + { + "contentBlockDelta": { + "delta": {"text": "Sure! Let’s do it"}, + "contentBlockIndex": 1, + } + }, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "end_turn"}}, + { + "metadata": { + "usage": {"inputTokens": 112, "outputTokens": 764, "totalTokens": 876}, + "metrics": {"latencyMs": 2970}, + } + }, + ] + + stream = strands.event_loop.streaming.process_stream(agenerator(response)) + + last_event = cast(ModelStopReason, (await alist(stream))[-1]) + + assert "signature" not in last_event.message["content"][0]["reasoningContent"]["reasoningText"] + assert last_event.message["content"][1]["text"] == "Sure! Let’s do it" + + +@pytest.mark.asyncio +async def test_process_stream_with_signature(agenerator, alist): + response = [ + {"messageStart": {"role": "assistant"}}, + { + "contentBlockDelta": { + "delta": {"reasoningContent": {"text": 'User asks: "Reason about 2+2" so I will do that'}}, + "contentBlockIndex": 0, + } + }, + {"contentBlockDelta": {"delta": {"reasoningContent": {"text": "."}}, "contentBlockIndex": 0}}, + {"contentBlockDelta": {"delta": {"reasoningContent": {"signature": "test-"}}, "contentBlockIndex": 0}}, + {"contentBlockDelta": {"delta": {"reasoningContent": {"signature": "signature"}}, "contentBlockIndex": 0}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + { + "contentBlockDelta": { + "delta": {"text": "Sure! Let’s do it"}, + "contentBlockIndex": 1, + } + }, + {"contentBlockStop": {"contentBlockIndex": 1}}, + {"messageStop": {"stopReason": "end_turn"}}, + { + "metadata": { + "usage": {"inputTokens": 112, "outputTokens": 764, "totalTokens": 876}, + "metrics": {"latencyMs": 2970}, + } + }, + ] + + stream = strands.event_loop.streaming.process_stream(agenerator(response)) + + last_event = cast(ModelStopReason, (await alist(stream))[-1]) + + assert last_event.message["content"][0]["reasoningContent"]["reasoningText"]["signature"] == "test-signature" + assert last_event.message["content"][1]["text"] == "Sure! Let’s do it" + + @pytest.mark.asyncio async def test_stream_messages(agenerator, alist): mock_model = unittest.mock.MagicMock() From bbf38e7b5b8f8d92d8db2fe46d32b78657a00b23 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Fri, 5 Sep 2025 15:11:14 -0400 Subject: [PATCH 2/2] chore: remove message property in favor of a private method in tests --- src/strands/types/_events.py | 4 ---- tests/strands/event_loop/test_streaming.py | 17 +++++++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/strands/types/_events.py b/src/strands/types/_events.py index 06712386a..ccdab1846 100644 --- a/src/strands/types/_events.py +++ b/src/strands/types/_events.py @@ -197,10 +197,6 @@ def __init__( """ super().__init__({"stop": (stop_reason, message, usage, metrics)}) - @property - def message(self) -> Message: - return cast(Message, self["stop"][1]) - @property @override def is_callback_event(self) -> bool: diff --git a/tests/strands/event_loop/test_streaming.py b/tests/strands/event_loop/test_streaming.py index a76c85215..32d1889e5 100644 --- a/tests/strands/event_loop/test_streaming.py +++ b/tests/strands/event_loop/test_streaming.py @@ -6,6 +6,7 @@ import strands import strands.event_loop from strands.types._events import ModelStopReason, TypedEvent +from strands.types.content import Message from strands.types.streaming import ( ContentBlockDeltaEvent, ContentBlockStartEvent, @@ -566,6 +567,10 @@ async def test_process_stream(response, exp_events, agenerator, alist): assert non_typed_events == [] +def _get_message_from_event(event: ModelStopReason) -> Message: + return cast(Message, event["stop"][1]) + + @pytest.mark.asyncio async def test_process_stream_with_no_signature(agenerator, alist): response = [ @@ -598,8 +603,10 @@ async def test_process_stream_with_no_signature(agenerator, alist): last_event = cast(ModelStopReason, (await alist(stream))[-1]) - assert "signature" not in last_event.message["content"][0]["reasoningContent"]["reasoningText"] - assert last_event.message["content"][1]["text"] == "Sure! Let’s do it" + message = _get_message_from_event(last_event) + + assert "signature" not in message["content"][0]["reasoningContent"]["reasoningText"] + assert message["content"][1]["text"] == "Sure! Let’s do it" @pytest.mark.asyncio @@ -636,8 +643,10 @@ async def test_process_stream_with_signature(agenerator, alist): last_event = cast(ModelStopReason, (await alist(stream))[-1]) - assert last_event.message["content"][0]["reasoningContent"]["reasoningText"]["signature"] == "test-signature" - assert last_event.message["content"][1]["text"] == "Sure! Let’s do it" + message = _get_message_from_event(last_event) + + assert message["content"][0]["reasoningContent"]["reasoningText"]["signature"] == "test-signature" + assert message["content"][1]["text"] == "Sure! Let’s do it" @pytest.mark.asyncio