diff --git a/run_agent.py b/run_agent.py index 19f7c23f0c0..a0c266aa595 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7391,20 +7391,30 @@ class AIAgent: response_invalid = True error_details.append("response.output is not a list") elif not output_items: - # If we reach here, _run_codex_stream's backfill - # from output_item.done events and text-delta - # synthesis both failed to populate output. - _resp_status = getattr(response, "status", None) - _resp_incomplete = getattr(response, "incomplete_details", None) - logging.warning( - "Codex response.output is empty after stream backfill " - "(status=%s, incomplete_details=%s, model=%s). %s", - _resp_status, _resp_incomplete, - getattr(response, "model", None), - f"api_mode={self.api_mode} provider={self.provider}", - ) - response_invalid = True - error_details.append("response.output is empty") + # Stream backfill may have failed, but + # _normalize_codex_response can still recover + # from response.output_text. Only mark invalid + # when that fallback is also absent. + _out_text = getattr(response, "output_text", None) + _out_text_stripped = _out_text.strip() if isinstance(_out_text, str) else "" + if _out_text_stripped: + logger.debug( + "Codex response.output is empty but output_text is present " + "(%d chars); deferring to normalization.", + len(_out_text_stripped), + ) + else: + _resp_status = getattr(response, "status", None) + _resp_incomplete = getattr(response, "incomplete_details", None) + logger.warning( + "Codex response.output is empty after stream backfill " + "(status=%s, incomplete_details=%s, model=%s). %s", + _resp_status, _resp_incomplete, + getattr(response, "model", None), + f"api_mode={self.api_mode} provider={self.provider}", + ) + response_invalid = True + error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": content_blocks = getattr(response, "content", None) if response is not None else None if response is None: diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 4b24fbb1286..ea703ffbb1d 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -386,6 +386,56 @@ def test_run_conversation_codex_plain_text(monkeypatch): assert result["messages"][-1]["content"] == "OK" +def test_run_conversation_codex_empty_output_with_output_text(monkeypatch): + """Regression: empty response.output + valid output_text should succeed, + not trigger retry/fallback. The validation stage must defer to + _normalize_codex_response which synthesizes output from output_text.""" + agent = _build_agent(monkeypatch) + + def _empty_output_response(api_kwargs): + return SimpleNamespace( + output=[], + output_text="Hello from Codex", + usage=SimpleNamespace(input_tokens=5, output_tokens=3, total_tokens=8), + status="completed", + model="gpt-5-codex", + ) + + monkeypatch.setattr(agent, "_interruptible_api_call", _empty_output_response) + + result = agent.run_conversation("Say hello") + + assert result["completed"] is True + assert result["final_response"] == "Hello from Codex" + + +def test_run_conversation_codex_empty_output_no_output_text_retries(monkeypatch): + """When both output and output_text are empty, validation should + correctly mark the response as invalid and trigger retry.""" + agent = _build_agent(monkeypatch) + calls = {"api": 0} + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + return SimpleNamespace( + output=[], + output_text=None, + usage=SimpleNamespace(input_tokens=5, output_tokens=3, total_tokens=8), + status="completed", + model="gpt-5-codex", + ) + return _codex_message_response("Recovered") + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + + result = agent.run_conversation("Say hello") + + assert calls["api"] >= 2 + assert result["completed"] is True + assert result["final_response"] == "Recovered" + + def test_run_conversation_codex_refreshes_after_401_and_retries(monkeypatch): agent = _build_agent(monkeypatch) calls = {"api": 0, "refresh": 0}