From 5eb2e6d64a41d6365635e0afd09c043bd2fb2009 Mon Sep 17 00:00:00 2001 From: Johnson Date: Wed, 27 May 2026 09:07:27 +0800 Subject: [PATCH] fix: recover codex stream when terminal output is null --- agent/codex_runtime.py | 30 ++++++++++++++ .../test_run_agent_codex_responses.py | 41 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 8c5dff39bffee..e183ee112d2f0 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -271,6 +271,36 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta len(agent._codex_streamed_text_parts), len(assembled), ) return final_response + except TypeError as exc: + # Some Responses-compatible backends (notably chatgpt.com/backend-api/codex + # through OpenAI SDK >= 2.24) can emit a terminal response.completed + # frame whose response.output is null while the useful message has + # already arrived via response.output_item.done / output_text deltas. + # The SDK raises while parsing that terminal frame, before + # get_final_response() can run. Recover from the collected stream + # evidence instead of routing a successful turn to fallback. + if "NoneType" in str(exc) and "iterable" in str(exc): + if collected_output_items: + return SimpleNamespace( + output=list(collected_output_items), + output_text="".join(agent._codex_streamed_text_parts), + status="completed", + model=api_kwargs.get("model"), + ) + if agent._codex_streamed_text_parts and not has_tool_calls: + assembled = "".join(agent._codex_streamed_text_parts) + return SimpleNamespace( + output=[SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )], + output_text=assembled, + status="completed", + model=api_kwargs.get("model"), + ) + raise except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: logger.debug( diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index bc575cc676f9b..57042dda258fa 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -155,9 +155,10 @@ def _codex_ack_message_response(text: str): class _FakeResponsesStream: - def __init__(self, *, final_response=None, final_error=None): + def __init__(self, *, final_response=None, final_error=None, events=None): self._final_response = final_response self._final_error = final_error + self._events = list(events or []) def __enter__(self): return self @@ -166,7 +167,7 @@ def __exit__(self, exc_type, exc, tb): return False def __iter__(self): - return iter(()) + return iter(self._events) def get_final_response(self): if self._final_error is not None: @@ -484,6 +485,42 @@ def _fake_create(**kwargs): assert response.output[0].content[0].text == "streamed create ok" +def test_run_codex_stream_recovers_when_sdk_parse_response_sees_null_output(monkeypatch): + """Regression: chatgpt.com Codex may stream a valid output_item.done, + then OpenAI SDK parsing raises TypeError when terminal response.output is null. + Hermes should return the collected item instead of treating the turn as failed. + """ + agent = _build_agent(monkeypatch) + item = SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text="sdk recovered")], + ) + events = [ + SimpleNamespace(type="response.output_text.delta", delta="sdk recovered"), + SimpleNamespace(type="response.output_item.done", item=item), + ] + + def _fake_stream(**kwargs): + return _FakeResponsesStream( + events=events, + final_error=TypeError("'NoneType' object is not iterable"), + ) + + agent.client = SimpleNamespace( + responses=SimpleNamespace( + stream=_fake_stream, + create=lambda **kwargs: _codex_message_response("fallback should not run"), + ) + ) + + response = agent._run_codex_stream(_codex_request_kwargs()) + assert response is not None + assert response.output[0].content[0].text == "sdk recovered" + assert response.output_text == "sdk recovered" + + 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"))