fix(agent): recover Codex streams with null output

This commit is contained in:
Carlton 2026-05-26 17:15:01 -07:00 committed by Teknium
parent bb4703c761
commit 43a3f119fc
4 changed files with 250 additions and 62 deletions

View file

@ -2554,6 +2554,45 @@ class TestCodexAuxiliaryAdapterTimeout:
assert time.monotonic() - started < 0.14
class TestCodexAuxiliaryAdapterNullOutputRecovery:
def test_recovers_output_item_when_sdk_raises_during_iteration(self):
"""Regression for #11179 in auxiliary calls such as compression/title generation."""
output_item = SimpleNamespace(
type="message",
content=[SimpleNamespace(type="output_text", text="aux survived")],
)
class NullOutputParseStream:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield SimpleNamespace(type="response.output_item.done", item=output_item)
raise TypeError("'NoneType' object is not iterable")
def get_final_response(self): # pragma: no cover - iterator fails first
raise AssertionError("get_final_response should not be reached")
class FakeResponses:
def __init__(self):
self.create = MagicMock()
def stream(self, **kwargs):
return NullOutputParseStream()
fake_client = SimpleNamespace(responses=FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
response = adapter.create(messages=[{"role": "user", "content": "summarize"}])
assert response.choices[0].message.content == "aux survived"
fake_client.responses.create.assert_not_called()
# ---------------------------------------------------------------------------
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
# ---------------------------------------------------------------------------

View file

@ -186,6 +186,27 @@ class _FakeCreateStream:
self.closed = True
class _IteratorTypeErrorStream:
"""Mimic the SDK raising while parsing response.completed.output=None."""
def __init__(self, events_before_error):
self._events_before_error = list(events_before_error)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
for event in self._events_before_error:
yield event
raise TypeError("'NoneType' object is not iterable")
def get_final_response(self): # pragma: no cover - iterator fails first
raise AssertionError("get_final_response should not be reached")
def _codex_request_kwargs():
return {
"model": "gpt-5-codex",
@ -484,6 +505,40 @@ def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch):
assert response.output[0].content[0].text == "streamed create ok"
def test_run_codex_stream_falls_back_when_stream_iteration_parses_null_output(monkeypatch):
"""Regression for #11179: the SDK can raise while iterating response.completed.
The failure happens before get_final_response(), so post-loop backfill alone is
not enough. Preserve already streamed output_item.done events.
"""
agent = _build_agent(monkeypatch)
output_item = SimpleNamespace(
type="message",
status="completed",
content=[SimpleNamespace(type="output_text", text="stream item survived")],
)
calls = {"stream": 0}
def _fake_stream(**kwargs):
calls["stream"] += 1
return _IteratorTypeErrorStream([
SimpleNamespace(type="response.output_item.done", item=output_item),
])
def _unexpected_create(**kwargs): # pragma: no cover - recovery should avoid fallback call
raise AssertionError("create fallback should not be needed when output items were collected")
agent.client = SimpleNamespace(
responses=SimpleNamespace(stream=_fake_stream, create=_unexpected_create),
)
response = agent._run_codex_stream(_codex_request_kwargs())
assert calls["stream"] == 1
assert response.output == [output_item]
assert response.status == "completed"
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"))