diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index c5d6dfcea48b..6cbbd5618566 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -852,7 +852,6 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: saw_final_answer_phase = True message_text = _extract_responses_message_text(item) if message_text: - content_parts.append(message_text) raw_message_item: Dict[str, Any] = { "type": "message", "role": "assistant", @@ -865,6 +864,14 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: if normalized_phase: raw_message_item["phase"] = normalized_phase message_items_raw.append(raw_message_item) + + # Codex backends can emit user-invisible planning/status text as + # `message` items with phase=commentary/analysis while also + # emitting structured function_call items. Preserve the raw item + # above for replay/debug metadata, but never promote it into + # assistant.content/final_response/gateway-visible text. + if normalized_phase not in {"commentary", "analysis"}: + content_parts.append(message_text) elif item_type == "reasoning": reasoning_text = _extract_responses_reasoning_text(item) if reasoning_text: diff --git a/run_agent.py b/run_agent.py index 919a5875b65a..596f11406550 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6682,7 +6682,7 @@ def _emit_interim_assistant_message(self, assistant_msg: Dict[str, Any]) -> None if cb is None or not isinstance(assistant_msg, dict): return content = assistant_msg.get("content") - visible = self._strip_think_blocks(content or "").strip() + visible = sanitize_context(self._strip_think_blocks(content or "")).strip() 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 47c491c441c9..fff9115d2906 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -704,7 +704,7 @@ def test_run_conversation_codex_tool_round_trip(monkeypatch): responses = [_codex_tool_call_response(), _codex_message_response("done")] monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( { @@ -872,7 +872,7 @@ def _fake_api_call(api_kwargs): monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( { @@ -907,7 +907,7 @@ def test_run_conversation_codex_continues_after_incomplete_interim_message(monke ] monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( { @@ -940,7 +940,8 @@ def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(mo ) assert finish_reason == "incomplete" - assert "inspect the repository" in (assistant_message.content or "") + assert (assistant_message.content or "") == "" + assert "inspect the repository" in assistant_message.codex_message_items[0]["content"][0]["text"] def test_normalize_codex_response_preserves_message_status_for_replay(monkeypatch): @@ -1010,6 +1011,49 @@ def test_normalize_codex_response_detects_leaked_tool_call_text(monkeypatch): assert assistant_message.tool_calls == [] +def test_normalize_codex_response_scrubs_commentary_text_when_tool_call_present(monkeypatch): + """Codex commentary is debug/replay metadata, not user-visible content. + + This is the Discord leak failure mode: a Responses output contains a + phase=commentary message like "Need inspect..." plus a real function_call. + Tool calls must still run, but commentary text must not become the assistant + content that gateways can stream or deliver. + """ + agent = _build_agent(monkeypatch) + from agent.codex_responses_adapter import _normalize_codex_response + + leaked_commentary = "Need inspect core context files. Use search for SOUL/AGENTS/MEMORY." + response = SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + id="msg_commentary", + phase="commentary", + status="completed", + content=[SimpleNamespace(type="output_text", text=leaked_commentary)], + ), + SimpleNamespace( + type="function_call", + id="fc_1", + call_id="call_1", + name="search_files", + arguments='{"pattern":"SOUL.md"}', + ), + ], + usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6), + status="completed", + model="gpt-5.5", + ) + + assistant_message, finish_reason = _normalize_codex_response(response) + + assert finish_reason == "tool_calls" + assert assistant_message.tool_calls + assert (assistant_message.content or "") == "" + assert assistant_message.codex_message_items[0]["phase"] == "commentary" + assert leaked_commentary in assistant_message.codex_message_items[0]["content"][0]["text"] + + def test_normalize_codex_response_ignores_tool_call_text_when_real_tool_call_present(monkeypatch): """If the model emitted BOTH a structured function_call AND some text that happens to contain `to=functions.*` (unlikely but possible), trust the @@ -1115,10 +1159,9 @@ def failing_callback(_text): } -def test_interim_commentary_preserves_assistant_content(monkeypatch): - """Interim commentary must not silently mutate assistant text containing - literal markers — that's legitimate model output (docs, - code). Streaming-path leak prevention happens delta-by-delta upstream.""" +def test_interim_commentary_strips_leaked_memory_context(monkeypatch): + """Interim commentary is user-visible in gateways, so leaked injected + context fences must be stripped before callback delivery.""" agent = _build_agent(monkeypatch) observed = {} agent.interim_assistant_callback = lambda text, *, already_streamed=False: observed.update( @@ -1136,7 +1179,9 @@ def test_interim_commentary_preserves_assistant_content(monkeypatch): agent._emit_interim_assistant_message({"role": "assistant", "content": content}) - assert "" in observed["text"] + assert "" not in observed["text"] + assert "Honcho Context" not in observed["text"] + assert "stale memory" not in observed["text"] assert "I'll inspect the repo structure first." in observed["text"] @@ -1259,7 +1304,7 @@ def test_run_conversation_codex_continues_after_commentary_phase_message(monkeyp ] monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( { @@ -1278,7 +1323,8 @@ def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): assert any( msg.get("role") == "assistant" and msg.get("finish_reason") == "incomplete" - and "inspect the repo structure" in (msg.get("content") or "") + and not (msg.get("content") or "") + and "inspect the repo structure" in str(msg.get("codex_message_items") or "") for msg in result["messages"] ) assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"]) @@ -1295,7 +1341,7 @@ def test_run_conversation_codex_continues_after_ack_stop_message(monkeypatch): ] monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( { @@ -1336,7 +1382,7 @@ def test_run_conversation_codex_continues_after_ack_for_directory_listing_prompt ] monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) - def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id, *args, **kwargs): for call in assistant_message.tool_calls: messages.append( {