From 545b8e04d87ee951f5f63038aeefee447338375c Mon Sep 17 00:00:00 2001 From: Illia Oleksiuk Date: Fri, 15 May 2026 13:30:40 -0700 Subject: [PATCH] fix: guard None refusal in ItemHelpers.extract_last_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3394 (just merged) that fixed the text branch of `extract_last_content` against `None` from `model_construct` / provider gateway paths. The refusal branch has the same `-> str` return type contract and the same Pydantic-typed `str` source field, so it can violate the contract in the same way: >>> from openai.types.responses import ( ... ResponseOutputMessage, ResponseOutputRefusal, ... ) >>> from agents.items import ItemHelpers >>> refusal_part = ResponseOutputRefusal.model_construct( ... refusal=None, type="refusal") >>> msg = ResponseOutputMessage.model_construct( ... id="m", role="assistant", status="completed", ... type="message", content=[refusal_part]) >>> ItemHelpers.extract_last_content(msg) None # but the function is typed `-> str` Apply the same `or ""` coercion and rationale comment that the text branch now has (items.py:687-691). Note: `extract_last_text` (declared `-> str | None`) is intentionally left alone — same reasoning as in #3394's final scope. --- src/agents/items.py | 7 +++++- tests/utils/test_pretty_print_and_items.py | 27 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/agents/items.py b/src/agents/items.py index c761cc221f..c626c43228 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -691,7 +691,12 @@ def extract_last_content(cls, message: TResponseOutputItem) -> str: # ``extract_text`` below. return last_content.text or "" elif isinstance(last_content, ResponseOutputRefusal): - return last_content.refusal + # ``last_content.refusal`` is typed as ``str`` per the Responses API + # schema, but provider gateways and ``model_construct`` paths during + # streaming have been observed surfacing ``None``. Coerce so callers + # relying on the ``-> str`` return type don't see a ``None``. Same + # rationale as the text branch above. + return last_content.refusal or "" else: raise ModelBehaviorError(f"Unexpected content type: {type(last_content)}") diff --git a/tests/utils/test_pretty_print_and_items.py b/tests/utils/test_pretty_print_and_items.py index ab0fd6b821..7d1b4cc306 100644 --- a/tests/utils/test_pretty_print_and_items.py +++ b/tests/utils/test_pretty_print_and_items.py @@ -62,6 +62,33 @@ def test_extract_last_content_returns_text_normally(): assert ItemHelpers.extract_last_content(msg) == "hello" +def _make_refusal_message(refusal: str | None) -> ResponseOutputMessage: + from openai.types.responses import ResponseOutputRefusal + + return ResponseOutputMessage.model_construct( + id="msg_1", + role="assistant", + status="completed", + content=[ResponseOutputRefusal.model_construct(type="refusal", refusal=refusal)], + ) + + +def test_extract_last_content_returns_empty_string_for_none_refusal(): + """extract_last_content is declared `-> str` and must not return None even if + the underlying ResponseOutputRefusal.refusal is None (observed via provider + gateways and ``model_construct`` paths, matching the text branch fix in + items.py:687-691).""" + msg = _make_refusal_message(None) + result = ItemHelpers.extract_last_content(msg) + assert isinstance(result, str) + assert result == "" + + +def test_extract_last_content_returns_refusal_normally(): + msg = _make_refusal_message("I cannot do that") + assert ItemHelpers.extract_last_content(msg) == "I cannot do that" + + def _make_run_error_details(n_input: int = 0, n_output: int = 0) -> RunErrorDetails: return RunErrorDetails( input="hi",