diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e6d42dd21654..5a87455f2fe6 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -796,26 +796,51 @@ def _check_cancelled() -> None: timeout_timer.daemon = True timeout_timer.start() _check_cancelled() - with self._client.responses.stream(**resp_kwargs) as stream: - for _event in stream: + try: + with self._client.responses.stream(**resp_kwargs) as stream: + for _event in stream: + _check_cancelled() + _etype = getattr(_event, "type", "") + if _etype == "response.output_item.done": + _done = getattr(_event, "item", None) + if _done is not None: + collected_output_items.append(_done) + elif "output_text.delta" in _etype: + _delta = getattr(_event, "delta", "") + if _delta: + collected_text_deltas.append(_delta) + elif "function_call" in _etype: + has_function_calls = True _check_cancelled() - _etype = getattr(_event, "type", "") - if _etype == "response.output_item.done": - _done = getattr(_event, "item", None) - if _done is not None: - collected_output_items.append(_done) - elif "output_text.delta" in _etype: - _delta = getattr(_event, "delta", "") - if _delta: - collected_text_deltas.append(_delta) - elif "function_call" in _etype: - has_function_calls = True - _check_cancelled() - final = stream.get_final_response() + final = stream.get_final_response() + except TypeError as exc: + if "'NoneType' object is not iterable" not in str(exc): + raise + if collected_output_items: + final = SimpleNamespace(output=list(collected_output_items), usage=None) + logger.debug( + "Codex auxiliary: recovered %d output items after stream parser None output", + len(collected_output_items), + ) + elif collected_text_deltas and not has_function_calls: + assembled = "".join(collected_text_deltas) + final = SimpleNamespace( + output=[SimpleNamespace( + type="message", role="assistant", status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )], + usage=None, + ) + logger.debug( + "Codex auxiliary: recovered from stream parser None output using %d deltas (%d chars)", + len(collected_text_deltas), len(assembled), + ) + else: + raise # Backfill empty output from collected stream events _output = getattr(final, "output", None) - if isinstance(_output, list) and not _output: + if _output is None or (isinstance(_output, list) and not _output): if collected_output_items: final.output = list(collected_output_items) logger.debug( @@ -845,7 +870,7 @@ def _item_get(obj: Any, key: str, default: Any = None) -> Any: val = obj.get(key, default) return val if val is not None else default - for item in getattr(final, "output", []): + for item in (getattr(final, "output", None) or []): item_type = _item_get(item, "type") if item_type == "message": for part in (_item_get(item, "content") or []): diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 8c5dff39bffe..0855a735fe5b 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -251,7 +251,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta # but get_final_response() can return an empty output list. # Backfill from collected items or synthesize from deltas. _out = getattr(final_response, "output", None) - if isinstance(_out, list) and not _out: + if _out is None or (isinstance(_out, list) and not _out): if collected_output_items: final_response.output = list(collected_output_items) logger.debug( @@ -335,6 +335,15 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta ) return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client) raise + except TypeError as exc: + if "'NoneType' object is not iterable" not in str(exc): + raise + logger.debug( + "Responses stream parser hit None output; falling back to create(stream=True). %s err=%s", + agent._client_log_context(), + exc, + ) + return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client) @@ -414,7 +423,7 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None if terminal_response is not None: # Backfill empty output from collected stream events _out = getattr(terminal_response, "output", None) - if isinstance(_out, list) and not _out: + if _out is None or (isinstance(_out, list) and not _out): if collected_output_items: terminal_response.output = list(collected_output_items) logger.debug( diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 970692c03947..41130346dfa2 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -101,10 +101,10 @@ def build_kwargs( payload_messages, is_xai_responses=is_xai_responses, ), - "tools": response_tools, "store": False, } if response_tools: + kwargs["tools"] = response_tools kwargs["tool_choice"] = "auto" kwargs["parallel_tool_calls"] = True diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 20c30c7ea9ed..6005447bec87 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2476,6 +2476,63 @@ def test_skip_set_covers_exactly_known_entries(self): class TestCodexAuxiliaryAdapterTimeout: + def test_recovers_stream_parser_none_output_error_after_text_delta(self): + class FakeStream: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def __iter__(self): + yield SimpleNamespace(type="response.output_text.delta", delta="Short title") + raise TypeError("'NoneType' object is not iterable") + + def get_final_response(self): + raise AssertionError("stream parser error should bypass final response") + + class FakeResponses: + def stream(self, **kwargs): + return FakeStream() + + fake_client = SimpleNamespace(responses=FakeResponses()) + adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5") + + response = adapter.create( + messages=[{"role": "user", "content": "title this"}], + ) + + assert response.choices[0].message.content == "Short title" + + def test_backfills_none_output_from_streamed_text_delta(self): + class FakeStream: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def __iter__(self): + return iter(( + SimpleNamespace(type="response.output_text.delta", delta="summary"), + )) + + def get_final_response(self): + return SimpleNamespace(output=None, usage=None) + + class FakeResponses: + def stream(self, **kwargs): + return FakeStream() + + fake_client = SimpleNamespace(responses=FakeResponses()) + adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5") + + response = adapter.create( + messages=[{"role": "user", "content": "summarize this"}], + ) + + assert response.choices[0].message.content == "summary" + def test_forwards_timeout_to_responses_stream(self): class FakeStream: def __enter__(self): diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 96a808272044..fdaacd113efb 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -54,6 +54,18 @@ def test_basic_kwargs(self, transport): assert "input" in kw assert kw["store"] is False + def test_no_tools_omits_tools_key(self, transport): + """OpenAI SDK 2.24 treats tools=None as iterable and raises TypeError.""" + kw = transport.build_kwargs( + model="gpt-5.5", + messages=[{"role": "user", "content": "Hello"}], + tools=[], + ) + + assert "tools" not in kw + assert "tool_choice" not in kw + assert "parallel_tool_calls" not in kw + def test_system_extracted_from_messages(self, transport): messages = [ {"role": "system", "content": "Custom system prompt"}, diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index bc575cc676f9..14d15e5cab6b 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -448,6 +448,33 @@ def _fake_create(**kwargs): assert response.output[0].content[0].text == "create fallback ok" +def test_run_codex_stream_falls_back_when_sdk_parser_hits_none_output(monkeypatch): + agent = _build_agent(monkeypatch) + calls = {"stream": 0, "create": 0} + + def _fake_stream(**kwargs): + calls["stream"] += 1 + return _FakeResponsesStream( + final_error=TypeError("'NoneType' object is not iterable") + ) + + def _fake_create(**kwargs): + calls["create"] += 1 + return _codex_message_response("none-output fallback ok") + + agent.client = SimpleNamespace( + responses=SimpleNamespace( + stream=_fake_stream, + create=_fake_create, + ) + ) + + response = agent._run_codex_stream(_codex_request_kwargs()) + assert calls["stream"] == 1 + assert calls["create"] == 1 + assert response.output[0].content[0].text == "none-output fallback ok" + + def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch): agent = _build_agent(monkeypatch) calls = {"stream": 0, "create": 0} @@ -484,6 +511,41 @@ def _fake_create(**kwargs): assert response.output[0].content[0].text == "streamed create ok" +def test_run_codex_create_stream_fallback_backfills_terminal_none_output(monkeypatch): + agent = _build_agent(monkeypatch) + create_stream = _FakeCreateStream( + [ + SimpleNamespace(type="response.created"), + SimpleNamespace( + type="response.output_item.done", + item=SimpleNamespace( + type="message", + status="completed", + content=[SimpleNamespace(type="output_text", text="backfilled item")], + ), + ), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace( + output=None, + usage=SimpleNamespace(input_tokens=5, output_tokens=3, total_tokens=8), + status="completed", + model="gpt-5-codex", + ), + ), + ] + ) + agent.client = SimpleNamespace( + responses=SimpleNamespace( + create=lambda **kwargs: create_stream, + ) + ) + + response = agent._run_codex_create_stream_fallback(_codex_request_kwargs()) + assert create_stream.closed is True + assert response.output[0].content[0].text == "backfilled item" + + def test_run_conversation_codex_plain_text(monkeypatch): agent = _build_agent(monkeypatch) monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: _codex_message_response("OK"))