diff --git a/engine/agents/openai_event_mapper.py b/engine/agents/openai_event_mapper.py index e69008f..b0e0ba1 100644 --- a/engine/agents/openai_event_mapper.py +++ b/engine/agents/openai_event_mapper.py @@ -93,7 +93,13 @@ def _map_assistant_message( raw_item = item.raw_item item_id = raw_item.id parts = raw_item.content - text = "".join(part.text for part in parts if isinstance(part, ResponseOutputText)) + # ``part.text`` can be ``None`` on leniently-parsed provider payloads: + # Gemini (via the LiteLLM Responses translation) emits ``output_text`` + # parts with no ``text`` field when a turn is tool-calls-only, and the + # SDK materializes those without validation. Joining ``None`` raises + # ``TypeError: sequence item 0: expected str instance, NoneType found`` + # and kills the run (INF: analysis-smoke gemini-3.6-flash failures). + text = "".join(part.text or "" for part in parts if isinstance(part, ResponseOutputText)) refusal_text = _extract_refusal_text(parts=parts, text=text) if refusal_text is not None: return MappedEvent(refusal_text=refusal_text) diff --git a/tests/unit/agents/test_openai_event_mapper.py b/tests/unit/agents/test_openai_event_mapper.py index 5e71082..59f0094 100644 --- a/tests/unit/agents/test_openai_event_mapper.py +++ b/tests/unit/agents/test_openai_event_mapper.py @@ -196,3 +196,38 @@ def test_tool_call_and_output_have_distinct_item_ids() -> None: assert output_mapped.context_item is not None assert call_mapped.context_item.item_id == "tool-call-call_xyz" assert output_mapped.context_item.item_id == "tool-result-call_xyz" + + +def test_assistant_text_part_with_none_text_maps_as_empty() -> None: + """Gemini via the LiteLLM Responses translation emits ``output_text`` parts + with no ``text`` field on tool-calls-only turns; the SDK materializes them + leniently with ``text=None``. The mapper must treat those as empty instead + of raising ``TypeError: sequence item 0: expected str instance, NoneType + found`` (which failed whole HALO runs on gemini-3.6-flash). + """ + from agents.items import MessageOutputItem + from agents.stream_events import RunItemStreamEvent + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + from tests._sdk_events import SHARED_AGENT + + raw = ResponseOutputMessage.model_construct( + id="msg_none_text", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText.model_construct(type="output_text", text=None, annotations=[])], + ) + event = RunItemStreamEvent( + name="message_output_created", + item=MessageOutputItem(agent=SHARED_AGENT, raw_item=raw), + ) + + mapper = OpenAiEventMapper() + mapped = mapper.to_mapped_event(event, execution=_exec(), is_root=True) + assert mapped.refusal_text is None + assert mapped.context_item is not None + assert mapped.context_item.content is None + assert mapped.output_item is not None + assert mapped.output_item.final is False + assert mapped.output_item.item.content is None