From ffca7eace72f3f497bd763a1a6706195d57ff147 Mon Sep 17 00:00:00 2001 From: Rishab Motgi Date: Fri, 15 May 2026 14:06:05 -0700 Subject: [PATCH] fix: guard None refusal in ItemHelpers.extract_last_content PR #3394 added ``or ""`` for ``ResponseOutputText.text`` in the ``extract_last_content`` method but left the ``ResponseOutputRefusal`` branch unguarded. ``ResponseOutputRefusal.refusal`` is typed as ``str`` per the Responses API schema, but the same provider gateways (e.g. LiteLLM) and ``model_construct`` paths during streaming that surface ``None`` for ``.text`` can also surface ``None`` for ``.refusal``. Without the guard, callers that rely on the ``-> str`` return type receive ``None`` and may crash downstream. Add the same ``or ""`` coercion to the refusal branch and add regression tests mirroring the ones added for the text branch. Co-Authored-By: Claude Sonnet 4.6 --- src/agents/items.py | 6 ++++- tests/utils/test_pretty_print_and_items.py | 28 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/agents/items.py b/src/agents/items.py index c761cc221f..587e30cda6 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -691,7 +691,11 @@ 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 (e.g. LiteLLM) have been observed surfacing ``None`` + # for typed-str fields (same rationale as the ``ResponseOutputText.text`` + # guard 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..334a0c9ee1 100644 --- a/tests/utils/test_pretty_print_and_items.py +++ b/tests/utils/test_pretty_print_and_items.py @@ -1,6 +1,6 @@ from __future__ import annotations -from openai.types.responses import ResponseOutputMessage, ResponseOutputText +from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal, ResponseOutputText from agents import Agent from agents.exceptions import RunErrorDetails @@ -62,6 +62,32 @@ def test_extract_last_content_returns_text_normally(): assert ItemHelpers.extract_last_content(msg) == "hello" +def _make_refusal_message(refusal: str | None) -> ResponseOutputMessage: + return ResponseOutputMessage.model_construct( + id="msg_refusal", + 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. The same provider + gateways (e.g. LiteLLM) that surface ``None`` for ResponseOutputText.text + can also surface ``None`` for ResponseOutputRefusal.refusal; the ``or ""`` + guard must be applied consistently to both branches.""" + 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",