fix(auxiliary): coerce None final.output to empty list in Codex aux adapter

Closes #33368.

`_CodexCompletionsAdapter.create()` iterates `final.output` from the
Codex Responses stream. The event-driven consumer (introduced in #33042)
always sets `final.output` to a list, so this shape can't come from our
own code path. But:

- Mocked clients in tests can return a typed Response with `output=None`
- Third-party shims / compatibility layers that bypass the consumer can
  do the same
- A future code path that wraps a different consumer could regress

The old code `getattr(final, "output", [])` returns `None` (not the
default `[]`) when the attribute EXISTS but is `None`. Iterating
`None` then raises `TypeError: 'NoneType' object is not iterable` —
the exact error logged by title-generation when this fires.

Fix: `getattr(final, "output", None) or []` — single-line defensive
coerce. Cheap; zero risk.

Regression test asserts the auxiliary path handles a final whose
`.output` is `None` (via monkey-patched consumer) without raising and
returns the expected chat.completions-shaped response.

Reporter: @pavegrid-1 (issue #33368).
This commit is contained in:
teknium1 2026-05-27 11:01:47 -07:00 committed by Teknium
parent 9919caff46
commit 486d632cc2
2 changed files with 58 additions and 1 deletions

View file

@ -2591,6 +2591,63 @@ class TestCodexAuxiliaryAdapterNullOutputRecovery:
assert response.choices[0].message.content == "aux survived"
def test_handles_final_output_is_none_after_consumer(self):
"""Regression for #33368 — defense against ``final.output`` being ``None``.
The event-driven consumer always sets ``final.output`` to a list, so this
shape can't come from our own path. But a mocked client / compatibility
shim that returns a typed Response with ``output=None`` directly (or a
future code path that wraps a different consumer) would crash on
``for item in getattr(final, "output", [])`` because ``getattr`` returns
``None`` (not the default) when the attribute exists but is ``None``.
Coerce with ``or []`` to handle this defensively.
"""
# Stream that returns no items but a terminal with output=None.
# The consumer assembles an empty list. We then mock the consumer's
# return to simulate a third-party path that returns final.output=None.
empty_events = [
SimpleNamespace(type="response.completed", response=SimpleNamespace(
status="completed", id="r", output=None, usage=None,
)),
]
class _Stream:
def __iter__(self): return iter(empty_events)
def close(self): pass
# Monkey-patch the consumer to return a final whose .output is None
# (mimics third-party shim behavior the defensive guard protects against).
from agent import codex_runtime
original_consume = codex_runtime._consume_codex_event_stream
def _consume_returning_none_output(*args, **kwargs):
return SimpleNamespace(
output=None, # the defensive guard target
output_text="",
usage=None,
status="completed",
id="r",
model=kwargs.get("model"),
incomplete_details=None,
error=None,
)
codex_runtime._consume_codex_event_stream = _consume_returning_none_output
try:
class FakeResponses:
def create(self, **kwargs):
return _Stream()
fake_client = SimpleNamespace(responses=FakeResponses())
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
# Should not raise TypeError: 'NoneType' object is not iterable
response = adapter.create(messages=[{"role": "user", "content": "x"}])
assert response.choices[0].message.content is None
assert response.choices[0].finish_reason == "stop"
finally:
codex_runtime._consume_codex_event_stream = original_consume
# ---------------------------------------------------------------------------
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail