Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,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 []):
Expand Down
57 changes: 57 additions & 0 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2591,6 +2591,63 @@ def create(self, **kwargs):

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
Expand Down
Loading