{"repo":"NousResearch/hermes-agent","public_id":"32956","display_label":"Codex Responses null output field causing TypeError","summary":"The three members appear to concern the same problem: Codex Responses streaming with a null `output` field, reported as a `TypeError: 'NoneType' object is not iterable`. Two members are titled as fixes (one in OpenAI SDK `parse_response` handling, one in agent stream recovery), and one is a bug report; their titles alone do not show whether the fixes work.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":32963,"merged_at":"2026-05-27T02:37:37+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-27T02:37:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":11179,"pr_number":32963,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-27T02:37:37+00:00"},{"issue_number":32883,"pr_number":32963,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-27T02:37:37+00:00"},{"issue_number":32894,"pr_number":32963,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-27T02:37:37+00:00"},{"issue_number":32908,"pr_number":32963,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-27T02:37:37+00:00"},{"issue_number":32956,"pr_number":32963,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-27T02:37:37+00:00"},{"issue_number":53405,"pr_number":32963,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-27T02:37:37+00:00"}],"members":[{"kind":"issue","number":32956,"title":"[Bug]: Codex Responses streaming crashes with TypeError: 'NoneType' object is not iterable (openai-codex / chatgpt.com backend)","url":null,"state":"closed"},{"kind":"pr","number":32959,"title":"fix(codex): handle null output field in OpenAI SDK parse_response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32963","number":32963},{"kind":"pr","value":"#11179","number":11179}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":32963,"title":"fix(agent): recover Codex Responses streams with null output","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/codex_runtime.py","scripts/release.py","tests/agent/test_auxiliary_client.py","tests/run_agent/test_run_agent_codex_responses.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -107,6 +107,32 @@ def __repr__(self):\n logger = logging.getLogger(__name__)\n \n \n+def _responses_null_output_iterable_error(exc: BaseException) -> bool:\n+    \"\"\"True when the OpenAI SDK trips over terminal response.output=None.\"\"\"\n+    text = str(exc)\n+    return isinstance(exc, TypeError) and \"NoneType\" in text and \"not iterable\" in text\n+\n+\n+def _responses_backfilled_response(output_items: List[Any], text_parts: List[str], *, has_function_calls: bool, model: str = None) -> Optional[Any]:\n+    \"\"\"Build a minimal Responses-like object from already streamed events.\"\"\"\n+    if output_items:\n+        return SimpleNamespace(output=list(output_items), usage=None, status=\"completed\", model=model)\n+    if text_parts and not has_function_calls:\n+        assembled = \"\".join(text_parts)\n+        return SimpleNamespace(\n+            output=[SimpleNamespace(\n+                type=\"message\",\n+                role=\"assistant\",\n+                status=\"completed\",\n+                content=[SimpleNamespace(type=\"output_text\", text=assembled)],\n+            )],\n+            usage=None,\n+            status=\"completed\",\n+            model=model,\n+        )\n+    return None\n+\n+\n def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:\n     \"\"\"Return False instead of raising when a patched symbol is not a type.\"\"\"\n     try:","@@ -796,44 +822,61 @@ def _check_cancelled() -> None:\n                 timeout_timer.daemon = True\n                 timeout_timer.start()\n             _check_cancelled()\n+            final = None\n             with self._client.responses.stream(**resp_kwargs) as stream:\n-                for _event in stream:\n+                try:\n+                    for _event in stream:\n+                        _check_cancelled()\n+                        _etype = getattr(_event, \"type\", \"\")\n+                        if _etype == \"response.output_item.done\":\n+                            _done = getattr(_event, \"item\", None)\n+                            if _done is not None:\n+                                collected_output_items.append(_done)\n+                        elif \"output_text.delta\" in _etype:\n+                            _delta = getattr(_event, \"delta\", \"\")\n+                            if _delta:\n+                                collected_text_deltas.append(_delta)\n+                        elif \"function_call\" in _etype:\n+                            has_function_calls = True\n                     _check_cancelled()\n-                    _etype = getattr(_event, \"type\", \"\")\n-                    if _etype == \"response.output_item.done\":\n-                        _done = getattr(_event, \"item\", None)\n-                        if _done is not None:\n-                            collected_output_items.append(_done)\n-                    elif \"output_text.delta\" in _etype:\n-                        _delta = getattr(_event, \"delta\", \"\")\n-                        if _delta:\n-                            collected_text_deltas.append(_delta)\n-                    elif \"function_call\" in _etype:\n-                        has_function_calls = True\n-                _check_cancelled()\n-                final = stream.get_final_response()\n+                    final = stream.get_final_response()\n+                except TypeError as exc:\n+                    if not _responses_null_output_iterable_error(exc):\n+                        raise\n+                    final = _responses_backfilled_response(\n+                        collected_output_items,\n+                        collected_text_deltas,\n+                        has_function_calls=has_function_calls,\n+                        model=resp_kwargs.get(\"model\"),\n+                    )\n+                    if final is None:\n+                        raise\n+                    logger.debug(\n+                        \"Codex auxiliary Responses stream parser hit response.output=None; \"\n+                        \"recovered from streamed events (items=%d, text_parts=%d)\",\n+                        len(collected_output_items),\n+                        len(collected_text_deltas),\n+                    )\n+\n+            if final is None:\n+                raise RuntimeError(\"Codex auxiliary Responses stream did not return a final response\")\n \n             # Backfill empty output from collected stream events\n             _output = getattr(final, \"output\", None)\n-            if isinstance(_output, list) and not _output:\n-                if collected_output_items:\n-                    final.output = list(collected_output_items)\n+            if _output is None or (isinstance(_output, list) and not _output):\n+                recovered = _responses_backfilled_response(\n+                    collected_output_items,\n+                    collected_text_deltas,\n+                    has_function_calls=has_function_calls,\n+                    model=resp_kwargs.get(\"model\"),\n+                )\n+                if recovered is not None:\n+                    final.output = recovered.output\n                     logger.debug(\n-                        \"Codex auxiliary: backfilled %d output items from stream events\",\n+                        \"Codex auxiliary: backfilled missing output from stream events \"\n+                        \"(items=%d, text_parts=%d)\",\n                         len(collected_output_items),\n-                    )\n-                elif collected_text_deltas and not has_function_calls:\n-                    # Only synthesize text when no tool calls were streamed —\n-                    # a function_call response with incidental text should not\n-                    # be collapsed into a plain-text message.\n-                    assembled = \"\".join(collected_text_deltas)\n-                    final.output = [SimpleNamespace(\n-                        type=\"message\", role=\"assistant\", status=\"completed\",\n-                        content=[SimpleNamespace(type=\"output_text\", text=assembled)],\n-                    )]\n-                    logger.debug(\n-                        \"Codex auxiliary: synthesized from %d deltas (%d chars)\",\n-                        len(collected_text_deltas), len(assembled),\n+                        len(collected_text_deltas),\n                     )\n \n             # Extract text and tool calls from the Responses output."]},{"file":"agent/codex_runtime.py","hunks":["@@ -176,6 +176,37 @@ def run_codex_app_server_turn(\n \n \n \n+def _responses_null_output_iterable_error(exc: BaseException) -> bool:\n+    \"\"\"True when the OpenAI SDK trips over terminal response.output=None.\"\"\"\n+    text = str(exc)\n+    return isinstance(exc, TypeError) and \"NoneType\" in text and \"not iterable\" in text\n+\n+\n+def _codex_backfilled_response(output_items: list, text_parts: list, *, has_tool_calls: bool, model: str = None):\n+    \"\"\"Build a minimal Responses-like object from events already streamed.\"\"\"\n+    if output_items:\n+        return SimpleNamespace(\n+            output=list(output_items),\n+            usage=None,\n+            status=\"completed\",\n+            model=model,\n+        )\n+    if text_parts and not has_tool_calls:\n+        assembled = \"\".join(text_parts)\n+        return SimpleNamespace(\n+            output=[SimpleNamespace(\n+                type=\"message\",\n+                role=\"assistant\",\n+                status=\"completed\",\n+                content=[SimpleNamespace(type=\"output_text\", text=assembled)],\n+            )],\n+            usage=None,\n+            status=\"completed\",\n+            model=model,\n+        )\n+    return None\n+\n+\n def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):\n     \"\"\"Execute one streaming Responses API request and return the final response.\"\"\"\n     import httpx as _httpx","@@ -251,24 +282,20 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta\n                 # but get_final_response() can return an empty output list.\n                 # Backfill from collected items or synthesize from deltas.\n                 _out = getattr(final_response, \"output\", None)\n-                if isinstance(_out, list) and not _out:\n-                    if collected_output_items:\n-                        final_response.output = list(collected_output_items)\n+                if _out is None or (isinstance(_out, list) and not _out):\n+                    recovered = _codex_backfilled_response(\n+                        collected_output_items,\n+                        agent._codex_streamed_text_parts,\n+                        has_tool_calls=has_tool_calls,\n+                        model=api_kwargs.get(\"model\"),\n+                    )\n+                    if recovered is not None:\n+                        final_response.output = recovered.output\n                         logger.debug(\n-                            \"Codex stream: backfilled %d output items from stream events\",\n+                            \"Codex stream: backfilled missing output from stream events \"\n+                            \"(items=%d, text_parts=%d)\",\n                             len(collected_output_items),\n-                        )\n-                    elif agent._codex_streamed_text_parts and not has_tool_calls:\n-                        assembled = \"\".join(agent._codex_streamed_text_parts)\n-                        final_response.output = [SimpleNamespace(\n-                            type=\"message\",\n-                            role=\"assistant\",\n-                            status=\"completed\",\n-                            content=[SimpleNamespace(type=\"output_text\", text=assembled)],\n-                        )]\n-                        logger.debug(\n-                            \"Codex stream: synthesized output from %d text deltas (%d chars)\",\n-                            len(agent._codex_streamed_text_parts), len(assembled),\n+                            len(agent._codex_streamed_text_parts),\n                         )\n                 return final_response\n         except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:","@@ -287,6 +314,30 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta\n                 exc,\n             )\n             return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)\n+        except TypeError as exc:\n+            if _responses_null_output_iterable_error(exc):\n+                recovered = _codex_backfilled_response(\n+                    collected_output_items,\n+                    agent._codex_streamed_text_parts,\n+                    has_tool_calls=has_tool_calls,\n+                    model=api_kwargs.get(\"model\"),\n+                )\n+                if recovered is not None:\n+                    logger.debug(\n+                        \"Codex Responses stream parser hit response.output=None; \"\n+                        \"recovered from streamed events (items=%d, text_parts=%d). %s\",\n+                        len(collected_output_items),\n+                        len(agent._codex_streamed_text_parts),\n+                        agent._client_log_context(),\n+                    )\n+                    return recovered\n+                logger.debug(\n+                    \"Codex Responses stream parser hit response.output=None without \"\n+                    \"recoverable events; falling back to create(stream=True). %s\",\n+                    agent._client_log_context(),\n+                )\n+                return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)\n+            raise\n         except RuntimeError as exc:\n             err_text = str(exc)\n             missing_completed = \"response.completed\" in err_text","@@ -355,6 +406,7 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None\n     terminal_response = None\n     collected_output_items: list = []\n     collected_text_deltas: list = []\n+    has_tool_calls = False\n     try:\n         for event in stream_or_response:\n             agent._touch_activity(\"receiving stream response\")","@@ -404,6 +456,8 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None\n                     delta = event.get(\"delta\", \"\")\n                 if delta:\n                     collected_text_deltas.append(delta)\n+            elif event_type and \"function_call\" in event_type:\n+                has_tool_calls = True\n \n             if event_type not in {\"response.completed\", \"response.incomplete\", \"response.failed\"}:\n                 continue","@@ -414,23 +468,20 @@ def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None\n             if terminal_response is not None:\n                 # Backfill empty output from collected stream events\n                 _out = getattr(terminal_response, \"output\", None)\n-                if isinstance(_out, list) and not _out:\n-                    if collected_output_items:\n-                        terminal_response.output = list(collected_output_items)\n+                if _out is None or (isinstance(_out, list) and not _out):\n+                    recovered = _codex_backfilled_response(\n+                        collected_output_items,\n+                        collected_text_deltas,\n+                        has_tool_calls=has_tool_calls,\n+                        model=fallback_kwargs.get(\"model\"),\n+                    )\n+                    if recovered is not None:\n+                        terminal_response.output = recovered.output\n                         logger.debug(\n-                            \"Codex fallback stream: backfilled %d output items\",\n+                            \"Codex fallback stream: backfilled missing output \"\n+                            \"(items=%d, text_parts=%d)\",\n                             len(collected_output_items),\n-                        )\n-                    elif collected_text_deltas:\n-                        assembled = \"\".join(collected_text_deltas)\n-                        terminal_response.output = [SimpleNamespace(\n-                            type=\"message\", role=\"assistant\",\n-                            status=\"completed\",\n-                            content=[SimpleNamespace(type=\"output_text\", text=assembled)],\n-                        )]\n-                        logger.debug(\n-                            \"Codex fallback stream: synthesized from %d deltas (%d chars)\",\n-                            len(collected_text_deltas), len(assembled),\n+                            len(collected_text_deltas),\n                         )\n                 return terminal_response\n     finally:"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"9592417+adam91holt@users.noreply.github.com\": \"adam91holt\",\n     \"45688690+fujinice@users.noreply.github.com\": \"fujinice\",\n+    \"276689385+carltonawong@users.noreply.github.com\": \"carltonawong\",\n     # teknium (multiple emails)\n     \"[redacted:email]\": \"teknium1\",\n     \"[redacted:email]\": \"kenyonxu\","]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -2554,6 +2554,45 @@ def stream(self, **kwargs):\n         assert time.monotonic() - started < 0.14\n \n \n+class TestCodexAuxiliaryAdapterNullOutputRecovery:\n+    def test_recovers_output_item_when_sdk_raises_during_iteration(self):\n+        \"\"\"Regression for #11179 in auxiliary calls such as compression/title generation.\"\"\"\n+\n+        output_item = SimpleNamespace(\n+            type=\"message\",\n+            content=[SimpleNamespace(type=\"output_text\", text=\"aux survived\")],\n+        )\n+\n+        class NullOutputParseStream:\n+            def __enter__(self):\n+                return self\n+\n+            def __exit__(self, exc_type, exc, tb):\n+                return False\n+\n+            def __iter__(self):\n+                yield SimpleNamespace(type=\"response.output_item.done\", item=output_item)\n+                raise TypeError(\"'NoneType' object is not iterable\")\n+\n+            def get_final_response(self):  # pragma: no cover - iterator fails first\n+                raise AssertionError(\"get_final_response should not be reached\")\n+\n+        class FakeResponses:\n+            def __init__(self):\n+                self.create = MagicMock()\n+\n+            def stream(self, **kwargs):\n+                return NullOutputParseStream()\n+\n+        fake_client = SimpleNamespace(responses=FakeResponses())\n+        adapter = _CodexCompletionsAdapter(fake_client, \"gpt-5.5\")\n+\n+        response = adapter.create(messages=[{\"role\": \"user\", \"content\": \"summarize\"}])\n+\n+        assert response.choices[0].message.content == \"aux survived\"\n+        fake_client.responses.create.assert_not_called()\n+\n+\n # ---------------------------------------------------------------------------\n # Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail\n # ---------------------------------------------------------------------------"]},{"file":"tests/run_agent/test_run_agent_codex_responses.py","hunks":["@@ -186,6 +186,27 @@ def close(self):\n         self.closed = True\n \n \n+class _IteratorTypeErrorStream:\n+    \"\"\"Mimic the SDK raising while parsing response.completed.output=None.\"\"\"\n+\n+    def __init__(self, events_before_error):\n+        self._events_before_error = list(events_before_error)\n+\n+    def __enter__(self):\n+        return self\n+\n+    def __exit__(self, exc_type, exc, tb):\n+        return False\n+\n+    def __iter__(self):\n+        for event in self._events_before_error:\n+            yield event\n+        raise TypeError(\"'NoneType' object is not iterable\")\n+\n+    def get_final_response(self):  # pragma: no cover - iterator fails first\n+        raise AssertionError(\"get_final_response should not be reached\")\n+\n+\n def _codex_request_kwargs():\n     return {\n         \"model\": \"gpt-5-codex\",","@@ -484,6 +505,40 @@ def _fake_create(**kwargs):\n     assert response.output[0].content[0].text == \"streamed create ok\"\n \n \n+def test_run_codex_stream_falls_back_when_stream_iteration_parses_null_output(monkeypatch):\n+    \"\"\"Regression for #11179: the SDK can raise while iterating response.completed.\n+\n+    The failure happens before get_final_response(), so post-loop backfill alone is\n+    not enough. Preserve already streamed output_item.done events.\n+    \"\"\"\n+    agent = _build_agent(monkeypatch)\n+    output_item = SimpleNamespace(\n+        type=\"message\",\n+        status=\"completed\",\n+        content=[SimpleNamespace(type=\"output_text\", text=\"stream item survived\")],\n+    )\n+    calls = {\"stream\": 0}\n+\n+    def _fake_stream(**kwargs):\n+        calls[\"stream\"] += 1\n+        return _IteratorTypeErrorStream([\n+            SimpleNamespace(type=\"response.output_item.done\", item=output_item),\n+        ])\n+\n+    def _unexpected_create(**kwargs):  # pragma: no cover - recovery should avoid fallback call\n+        raise AssertionError(\"create fallback should not be needed when output items were collected\")\n+\n+    agent.client = SimpleNamespace(\n+        responses=SimpleNamespace(stream=_fake_stream, create=_unexpected_create),\n+    )\n+\n+    response = agent._run_codex_stream(_codex_request_kwargs())\n+\n+    assert calls[\"stream\"] == 1\n+    assert response.output == [output_item]\n+    assert response.status == \"completed\"\n+\n+\n def test_run_conversation_codex_plain_text(monkeypatch):\n     agent = _build_agent(monkeypatch)\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: _codex_message_response(\"OK\"))\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}