{"repo":"NousResearch/hermes-agent","public_id":"24933","display_label":"Codex commentary-phase text leaking as visible output","summary":"A bug where Codex's pre-tool-call commentary or preamble text is incorrectly routed as visible assistant content to chat gateways instead of being kept in an internal reasoning channel. Multiple fixes and reports address this same routing issue.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58868},"resolution":{"state":"partially_resolved","by":"pr-58868"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T13:29:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.43600000000000005},"fix_edges":[{"issue_number":24933,"pr_number":58868,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":41293,"pr_number":58868,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":24933,"title":"[Bug]: Codex Responses commentary-phase tool planning leaks as visible Telegram text","url":null,"state":"closed"},{"kind":"issue","number":41293,"title":"GPT-5.5 (openai-codex): pre-tool-call preamble text leaks as visible assistant content to chat gateways since v0.16.0","url":null,"state":"closed"},{"kind":"pr","number":25268,"title":"fix(agent): hide codex commentary messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24933","number":24933},{"kind":"pr","value":"#24933","number":24933}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41343,"title":"fix(codex): route commentary-phase preamble text to reasoning channel (fixes #41293)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58868","number":58868},{"kind":"pr","value":"#58696","number":58696},{"kind":"pr","value":"#41293","number":41293},{"kind":"pr","value":"#24933","number":24933}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58868,"title":"fix(codex): route commentary-phase text to the reasoning channel (salvage #58696 + #41343)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/codex_responses_adapter.py","agent/codex_runtime.py","scripts/release.py","tests/run_agent/test_run_agent_codex_responses.py"],"fix_hunks":[{"file":"agent/codex_responses_adapter.py","hunks":["@@ -1166,15 +1166,28 @@ def _normalize_codex_response(\n         if item_type == \"message\":\n             item_phase = getattr(item, \"phase\", None)\n             normalized_phase = None\n+            is_commentary_phase = False\n             if isinstance(item_phase, str):\n                 normalized_phase = item_phase.strip().lower()\n                 if normalized_phase in {\"commentary\", \"analysis\"}:\n                     saw_commentary_phase = True\n+                    is_commentary_phase = True\n                 elif normalized_phase in {\"final_answer\", \"final\"}:\n                     saw_final_answer_phase = True\n             message_text = _extract_responses_message_text(item)\n             if message_text:\n-                content_parts.append(message_text)\n+                # Responses ``commentary``/``analysis`` phase text is mid-turn\n+                # preamble/progress narration, never the turn's final answer\n+                # (Codex CLI excludes it from last-message extraction; issues\n+                # #24933 / #41293).  Keep it out of assistant content so it\n+                # can't be concatenated into — or leak as — the final response,\n+                # but surface it through the reasoning channel so the CLI/\n+                # gateway display it like thinking text.  The exact message\n+                # item is still preserved below for replay/cache continuity.\n+                if is_commentary_phase:\n+                    reasoning_parts.append(message_text)\n+                else:\n+                    content_parts.append(message_text)\n                 raw_message_item: Dict[str, Any] = {\n                     \"type\": \"message\",\n                     \"role\": \"assistant\",","@@ -1269,7 +1282,11 @@ def _normalize_codex_response(\n             ))\n \n     final_text = \"\\n\".join([p for p in content_parts if p]).strip()\n-    if not final_text and hasattr(response, \"output_text\"):\n+    if (\n+        not final_text\n+        and hasattr(response, \"output_text\")\n+        and not (saw_commentary_phase and not saw_final_answer_phase)\n+    ):\n         out_text = getattr(response, \"output_text\", \"\")\n         if isinstance(out_text, str):\n             final_text = out_text.strip()"]},{"file":"agent/codex_runtime.py","hunks":["@@ -500,6 +500,14 @@ def _event_field(event: Any, name: str, default: Any = None) -> Any:\n     return value if value is not None else default\n \n \n+def _item_field(item: Any, name: str, default: Any = None) -> Any:\n+    \"\"\"Field access for nested Response items (attr-style SDK object or dict).\"\"\"\n+    value = getattr(item, name, None)\n+    if value is None and isinstance(item, dict):\n+        value = item.get(name, default)\n+    return value if value is not None else default\n+\n+\n def _raise_stream_error(event: Any) -> None:\n     \"\"\"Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.\n ","@@ -562,6 +570,7 @@ def _consume_codex_event_stream(\n     collected_text_deltas: List[str] = []\n     has_tool_calls = False\n     first_delta_fired = False\n+    active_message_phase: str | None = None\n     terminal_status: str = \"completed\"\n     terminal_usage: Any = None\n     terminal_response_id: str = None","@@ -595,9 +604,35 @@ def _consume_codex_event_stream(\n         if event_type == \"error\":\n             _raise_stream_error(event)\n \n+        # Track the phase of the active streamed message item.  Codex/Harmony\n+        # ``commentary``/``analysis`` text is mid-turn preamble/progress\n+        # narration, never the final answer.  We still collect completed output\n+        # items for replay, but route those deltas to the reasoning callback so\n+        # they display like thinking text instead of assistant content.\n+        if event_type == \"response.output_item.added\":\n+            item = _event_field(event, \"item\")\n+            item_type = _item_field(item, \"type\", \"\")\n+            if item_type == \"message\":\n+                phase = _item_field(item, \"phase\", None)\n+                active_message_phase = phase.strip().lower() if isinstance(phase, str) else None\n+            else:\n+                active_message_phase = None\n+            if \"function_call\" in str(item_type):\n+                has_tool_calls = True\n+            continue\n+\n         if \"output_text.delta\" in event_type or event_type == \"response.output_text.delta\":\n             delta_text = _event_field(event, \"delta\", \"\")\n-            if delta_text:\n+            is_commentary_delta = active_message_phase in {\"commentary\", \"analysis\"}\n+            if delta_text and is_commentary_delta:\n+                # Commentary streams through the reasoning channel, not the\n+                # visible answer stream (and stays out of output_text).\n+                if on_reasoning_delta is not None:\n+                    try:\n+                        on_reasoning_delta(delta_text)\n+                    except Exception:\n+                        logger.debug(\"Codex stream on_reasoning_delta raised\", exc_info=True)\n+            elif delta_text:\n                 collected_text_deltas.append(delta_text)\n                 if not has_tool_calls:\n                     if not first_delta_fired:"]},{"file":"scripts/release.py","hunks":["@@ -486,6 +486,7 @@\n     \"[redacted:email]\": \"Rivuza\",\n     \"[redacted:email]\": \"annguyenNous\",\n     \"[redacted:email]\": \"annguyenNous\",\n+    \"[redacted:email]\": \"devatnull\",\n     \"[redacted:email]\": \"kylekahraman\",\n     \"[redacted:email]\": \"kylekahraman\",\n     \"[redacted:email]\": \"seppegadeyne\","]},{"file":"tests/run_agent/test_run_agent_codex_responses.py","hunks":["@@ -629,6 +629,74 @@ def _fake_create(**kwargs):\n     assert response.output == [output_item]\n \n \n+def test_consume_codex_stream_routes_commentary_phase_deltas_to_reasoning(monkeypatch):\n+    from agent.codex_runtime import _consume_codex_event_stream\n+\n+    commentary_item = SimpleNamespace(\n+        type=\"message\",\n+        phase=\"commentary\",\n+        status=\"completed\",\n+        content=[SimpleNamespace(type=\"output_text\", text=\"I’ll call the tool now.\")],\n+    )\n+    function_item = SimpleNamespace(\n+        type=\"function_call\",\n+        id=\"fc_1\",\n+        call_id=\"call_1\",\n+        name=\"terminal\",\n+        arguments=\"{}\",\n+    )\n+    streamed = []\n+    reasoning_streamed = []\n+\n+    response = _consume_codex_event_stream(\n+        _FakeCreateStream([\n+            SimpleNamespace(type=\"response.created\"),\n+            SimpleNamespace(\n+                type=\"response.output_item.added\",\n+                item=SimpleNamespace(type=\"message\", phase=\"commentary\"),\n+            ),\n+            SimpleNamespace(type=\"response.output_text.delta\", delta=\"I’ll call the tool now.\"),\n+            SimpleNamespace(type=\"response.output_item.done\", item=commentary_item),\n+            SimpleNamespace(\n+                type=\"response.output_item.added\",\n+                item=SimpleNamespace(type=\"function_call\"),\n+            ),\n+            SimpleNamespace(type=\"response.output_item.done\", item=function_item),\n+            SimpleNamespace(type=\"response.completed\", response=SimpleNamespace(status=\"completed\")),\n+        ]),\n+        model=\"gpt-5-codex\",\n+        on_text_delta=streamed.append,\n+        on_reasoning_delta=reasoning_streamed.append,\n+    )\n+\n+    assert streamed == []\n+    assert reasoning_streamed == [\"I’ll call the tool now.\"]\n+    assert response.output == [commentary_item, function_item]\n+    assert response.output_text == \"\"\n+\n+\n+def test_consume_codex_stream_keeps_final_answer_phase_deltas(monkeypatch):\n+    from agent.codex_runtime import _consume_codex_event_stream\n+\n+    streamed = []\n+    response = _consume_codex_event_stream(\n+        _FakeCreateStream([\n+            SimpleNamespace(type=\"response.created\"),\n+            SimpleNamespace(\n+                type=\"response.output_item.added\",\n+                item=SimpleNamespace(type=\"message\", phase=\"final_answer\"),\n+            ),\n+            SimpleNamespace(type=\"response.output_text.delta\", delta=\"visible answer\"),\n+            SimpleNamespace(type=\"response.completed\", response=SimpleNamespace(status=\"completed\")),\n+        ]),\n+        model=\"gpt-5-codex\",\n+        on_text_delta=streamed.append,\n+    )\n+\n+    assert streamed == [\"visible answer\"]\n+    assert response.output_text == \"visible answer\"\n+\n+\n def test_run_codex_stream_surfaces_failed_status_in_final_response(monkeypatch):\n     \"\"\"A ``response.failed`` terminal event is reflected on the returned object.\"\"\"\n     agent = _build_agent(monkeypatch)","@@ -1156,7 +1224,7 @@ def test_run_conversation_codex_tool_round_trip(monkeypatch):\n     responses = [_codex_tool_call_response(), _codex_message_response(\"done\")]\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: responses.pop(0))\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {","@@ -1347,7 +1415,7 @@ def _fake_api_call(api_kwargs):\n \n     monkeypatch.setattr(agent, \"_interruptible_api_call\", _fake_api_call)\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {","@@ -1382,7 +1450,7 @@ def test_run_conversation_codex_continues_after_incomplete_interim_message(monke\n     ]\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: responses.pop(0))\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {","@@ -1569,8 +1637,26 @@ def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(mo\n     )\n \n     assert finish_reason == \"incomplete\"\n-    assert \"inspect the repository\" in (assistant_message.content or \"\")\n+    assert (assistant_message.content or \"\") == \"\"\n+    assert \"inspect the repository\" in (assistant_message.reasoning or \"\")\n+    assert assistant_message.codex_message_items\n+    assert assistant_message.codex_message_items[0][\"phase\"] == \"commentary\"\n+    assert \"inspect the repository\" in assistant_message.codex_message_items[0][\"content\"][0][\"text\"]\n+\n+\n+def test_normalize_codex_response_does_not_fallback_to_output_text_for_commentary_only(monkeypatch):\n+    agent = _build_agent(monkeypatch)\n+    from agent.codex_responses_adapter import _normalize_codex_response\n+\n+    response = _codex_commentary_message_response(\"I’ll call the tool now.\")\n+    response.output_text = \"I’ll call the tool now.\"\n \n+    assistant_message, finish_reason = _normalize_codex_response(response)\n+\n+    assert finish_reason == \"incomplete\"\n+    assert (assistant_message.content or \"\") == \"\"\n+    assert \"call the tool\" in (assistant_message.reasoning or \"\")\n+    assert assistant_message.codex_message_items[0][\"phase\"] == \"commentary\"\n \n def test_normalize_codex_response_final_answer_overrides_top_level_incomplete(monkeypatch):\n     from agent.codex_responses_adapter import _normalize_codex_response","@@ -1974,7 +2060,7 @@ def test_run_conversation_codex_continues_after_commentary_phase_message(monkeyp\n     ]\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: responses.pop(0))\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {","@@ -1990,11 +2076,17 @@ def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n \n     assert result[\"completed\"] is True\n     assert result[\"final_response\"] == \"Architecture summary complete.\"\n+    commentary_messages = [\n+        msg for msg in result[\"messages\"]\n+        if msg.get(\"role\") == \"assistant\" and msg.get(\"finish_reason\") == \"incomplete\"\n+    ]\n+    assert commentary_messages\n+    assert all((msg.get(\"content\") or \"\") == \"\" for msg in commentary_messages)\n     assert any(\n-        msg.get(\"role\") == \"assistant\"\n-        and msg.get(\"finish_reason\") == \"incomplete\"\n-        and \"inspect the repo structure\" in (msg.get(\"content\") or \"\")\n-        for msg in result[\"messages\"]\n+        \"inspect the repo structure\" in item[\"content\"][0][\"text\"]\n+        for msg in commentary_messages\n+        for item in (msg.get(\"codex_message_items\") or [])\n+        if item.get(\"phase\") == \"commentary\"\n     )\n     assert any(msg.get(\"role\") == \"tool\" and msg.get(\"tool_call_id\") == \"call_1\" for msg in result[\"messages\"])\n ","@@ -2010,7 +2102,7 @@ def test_run_conversation_codex_continues_after_ack_stop_message(monkeypatch):\n     ]\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: responses.pop(0))\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {","@@ -2051,7 +2143,7 @@ def test_run_conversation_codex_continues_after_ack_for_directory_listing_prompt\n     ]\n     monkeypatch.setattr(agent, \"_interruptible_api_call\", lambda api_kwargs: responses.pop(0))\n \n-    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id):\n+    def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *_args):\n         for call in assistant_message.tool_calls:\n             messages.append(\n                 {\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}