From 136ade2ed53efc1258d172dea3de08d26b1358d2 Mon Sep 17 00:00:00 2001 From: David Robertson Date: Tue, 7 Jul 2026 19:37:49 +0000 Subject: [PATCH] fix(agent): surface Codex commentary items as interim messages --- run_agent.py | 41 +++++++++++++++++ .../test_run_agent_codex_responses.py | 45 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/run_agent.py b/run_agent.py index 4d0436247e56..f76ca439cf48 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4754,6 +4754,45 @@ class AIAgent: ) return bool(streamed) and streamed == visible_content + def _extract_codex_interim_visible_text(self, assistant_msg: Dict[str, Any]) -> str: + """Extract visible Codex Responses commentary text. + + Codex Responses can keep user-facing mid-turn narration as structured + ``phase=commentary`` message items while final answer text remains in + assistant ``content``. Non-streaming gateway surfaces need that + commentary through the interim assistant callback before tool calls run. + ``phase=analysis`` remains hidden because it is provider scratchpad. + """ + items = assistant_msg.get("codex_message_items") + if not isinstance(items, list): + return "" + + parts: List[str] = [] + for item in items: + if not isinstance(item, dict): + continue + if item.get("type") != "message": + continue + phase = item.get("phase") + if not isinstance(phase, str) or phase.strip().lower() != "commentary": + continue + content_parts = item.get("content") + if not isinstance(content_parts, list): + continue + for part in content_parts: + if not isinstance(part, dict): + continue + if part.get("type") != "output_text": + continue + text = part.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + + visible = "\n\n".join(parts).strip() + if visible: + visible = self._strip_think_blocks(visible).strip() + return visible + def _emit_interim_assistant_message(self, assistant_msg: Dict[str, Any]) -> None: """Surface a real mid-turn assistant commentary message to the UI layer.""" cb = getattr(self, "interim_assistant_callback", None) @@ -4761,6 +4800,8 @@ class AIAgent: return content = assistant_msg.get("content") visible = self._strip_think_blocks(content or "").strip() + if not visible or visible == "(empty)": + visible = self._extract_codex_interim_visible_text(assistant_msg) if not visible or visible == "(empty)": return already_streamed = self._interim_content_was_streamed(visible) diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 3a4a9c1c75ac..c13d57c5b563 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -2100,6 +2100,51 @@ def test_interim_commentary_preserves_assistant_content(monkeypatch): assert "I'll inspect the repo structure first." in observed["text"] +def test_interim_commentary_uses_codex_commentary_items_when_content_is_empty(monkeypatch): + """Codex Responses stores commentary phase text outside content. + + The gateway still needs that visible mid-turn narration to flow through the + interim assistant callback; otherwise Discord with streaming disabled gets + no natural progress commentary before tools run. Analysis/final-answer items + stay hidden from interim delivery. + """ + agent = _build_agent(monkeypatch) + observed = {} + agent.interim_assistant_callback = lambda text, *, already_streamed=False: observed.update( + {"text": text, "already_streamed": already_streamed} + ) + + agent._emit_interim_assistant_message({ + "role": "assistant", + "content": "", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "phase": "analysis", + "content": [{"type": "output_text", "text": "Need inspect files."}], + }, + { + "type": "message", + "role": "assistant", + "phase": "commentary", + "content": [{"type": "output_text", "text": "I'll inspect the repo first."}], + }, + { + "type": "message", + "role": "assistant", + "phase": "final_answer", + "content": [{"type": "output_text", "text": "Done."}], + }, + ], + }) + + assert observed == { + "text": "I'll inspect the repo first.", + "already_streamed": False, + } + + def test_stream_delta_strips_leaked_memory_context(monkeypatch): agent = _build_agent(monkeypatch) observed = []