When every pre-stream connect attempt to an endpoint fails, the user only saw "Connection error." repeated per outer retry; the host, the attempt count and the serialized request size lived in agent.log alone. #97548's reporter had an ~829 KB Codex Responses request fail twice before the stream opened while short chats went through, which is the request-size-limit signature, and nothing on screen said so. One buffered diagnostic line now flows through the existing retry-status path (flushed on terminal failure, dropped on recovery) on both the Codex Responses runtime and the Chat Completions stream worker: "Could not open a stream to <host> after N attempts (request X KB); ...". The host comes from the failed request's URL (the endpoint actually contacted, proxies included) and the size from the buffered httpx request body. Re-entering the stream call from the outer retry/fallback loop does not add another copy. Part of #97548
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
"""Diagnostics for Codex Responses transport failures."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import APIConnectionError
|
|
|
|
from agent.codex_runtime import _codex_request_failure_details, run_codex_stream
|
|
|
|
|
|
def test_transport_failure_without_attached_request_reports_unknown_size():
|
|
error = httpx.RemoteProtocolError("connection closed")
|
|
|
|
request_body_bytes, exception_chain = _codex_request_failure_details(error)
|
|
|
|
assert request_body_bytes is None
|
|
assert exception_chain == "RemoteProtocolError"
|
|
|
|
|
|
def test_transport_failure_logs_exact_request_bytes_and_class_chain(caplog):
|
|
request_content = b'{"input":"payload"}'
|
|
request = httpx.Request(
|
|
"POST",
|
|
"https://example.invalid/responses",
|
|
content=request_content,
|
|
)
|
|
transport_error = httpx.RemoteProtocolError(
|
|
"server disconnected without sending a response",
|
|
request=request,
|
|
)
|
|
connection_error = APIConnectionError(request=request)
|
|
connection_error.__cause__ = transport_error
|
|
|
|
class FailingResponses:
|
|
def create(self, **_kwargs):
|
|
raise connection_error
|
|
|
|
client = SimpleNamespace(responses=FailingResponses())
|
|
agent = SimpleNamespace(
|
|
_interrupt_requested=False,
|
|
_current_api_request_id="request-id",
|
|
_fallback_index=0,
|
|
is_subagent=False,
|
|
model="gpt-5.6-sol",
|
|
provider="openai-codex",
|
|
session_id="",
|
|
_client_log_context=lambda: "",
|
|
_buffer_diagnostic_status=lambda message: None,
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="agent.codex_runtime"):
|
|
with pytest.raises(APIConnectionError):
|
|
run_codex_stream(agent, {"model": "gpt-5.6-sol"}, client=client)
|
|
|
|
message = caplog.messages[-1]
|
|
assert f"serialized_request_body_bytes={len(request_content)}" in message
|
|
assert "stream_opened=false" in message
|
|
assert "exception_chain=APIConnectionError <- RemoteProtocolError" in message
|
|
assert "attempt=2/2" in message
|
|
assert "payload" not in message
|
|
assert request_content.decode() not in message
|
|
assert "example.invalid" not in message
|
|
|
|
|
|
def _zero_event_then_completed_client(seen_inputs: list):
|
|
"""``responses.create`` that dies before any stream event on the first call and completes on
|
|
the second; records the ``input`` list each physical attempt was given."""
|
|
from tests.agent.test_run_agent_codex_responses import _FakeCreateStream
|
|
|
|
events = [
|
|
SimpleNamespace(type="response.output_item.done", item=SimpleNamespace(
|
|
type="message", status="completed", content=[SimpleNamespace(type="output_text", text="ok")])),
|
|
SimpleNamespace(type="response.completed", response=SimpleNamespace(
|
|
status="completed", usage=SimpleNamespace(input_tokens=1, output_tokens=1, total_tokens=2), id="r1")),
|
|
]
|
|
|
|
def _create(**kwargs):
|
|
seen_inputs.append(kwargs.get("input") or (kwargs.get("extra_body") or {}).get("input"))
|
|
if len(seen_inputs) == 1:
|
|
raise httpx.ConnectError("no first byte")
|
|
return _FakeCreateStream(events)
|
|
|
|
return SimpleNamespace(responses=SimpleNamespace(create=_create))
|
|
|
|
|
|
def _oversized_codex_kwargs(size: int) -> dict:
|
|
return {"model": "gpt-5-codex", "instructions": "You are Hermes.", "store": False, "tools": None,
|
|
"input": [{"role": "user", "content": "look"},
|
|
{"type": "function_call", "call_id": "call_browser", "name": "browser_exec", "arguments": "{}"},
|
|
{"type": "function_call_output", "call_id": "call_browser", "output": "x" * size},
|
|
{"role": "user", "content": "continue"}]}
|
|
|
|
|
|
def test_zero_event_retry_prunes_oversized_tool_output_and_logs_size_delta(monkeypatch, tmp_path, caplog):
|
|
"""#95429 criterion 3: a reconnect after a zero-event attempt must not resend the same oversized
|
|
payload unchanged -- the inline tool output is spilled and the size delta is logged."""
|
|
from tests.agent.test_run_agent_codex_responses import _build_agent
|
|
from tools.tool_result_storage import PERSISTED_OUTPUT_TAG
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path, raising=False)
|
|
agent = _build_agent(monkeypatch)
|
|
seen: list = []
|
|
agent.client = _zero_event_then_completed_client(seen)
|
|
|
|
with caplog.at_level(logging.INFO, logger="agent.codex_runtime"):
|
|
response = agent._run_codex_stream(_oversized_codex_kwargs(760_396))
|
|
|
|
assert response.status == "completed" and len(seen) == 2
|
|
first, second = (len(json.dumps(i)) for i in seen)
|
|
assert second < first // 10
|
|
retried_output = seen[1][2]["output"]
|
|
assert PERSISTED_OUTPUT_TAG in retried_output and len(retried_output) < 10_000
|
|
assert Path(tmp_path, "cache", "spillover", "call_browser.txt").read_text() == "x" * 760_396
|
|
assert seen[0][2]["output"] == "x" * 760_396 # the caller's kwargs are not mutated
|
|
prune_logs = [r.message for r in caplog.records if "zero-event" in r.message]
|
|
assert len(prune_logs) == 1 and "attempt 1/2" in prune_logs[0]
|
|
assert re.search(r"serialized_input_bytes=\d+ -> \d+", prune_logs[0])
|
|
|
|
|
|
def test_zero_event_retry_without_prunable_output_logs_unchanged_resend(monkeypatch, caplog):
|
|
from tests.agent.test_run_agent_codex_responses import _build_agent
|
|
|
|
agent = _build_agent(monkeypatch)
|
|
seen: list = []
|
|
agent.client = _zero_event_then_completed_client(seen)
|
|
kwargs = _oversized_codex_kwargs(300_000)
|
|
kwargs["input"][2]["output"] = ["not-a-string"] # nothing prunable
|
|
kwargs["input"][0]["content"] = "u" * 300_000 # still a large payload
|
|
|
|
with caplog.at_level(logging.INFO, logger="agent.codex_runtime"):
|
|
response = agent._run_codex_stream(kwargs)
|
|
|
|
assert response.status == "completed" and seen[0] == seen[1]
|
|
(log,) = [r.message for r in caplog.records if "zero-event" in r.message]
|
|
assert "unchanged" in log and "attempt 1/2" in log and re.search(r"serialized_input_bytes=\d+", log)
|