diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 4d138ce6e63..e25293d7320 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = { _RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"} +# The Responses API rejects input[].id longer than this with a non-retryable +# HTTP 400 ("string too long"). Codex-issued assistant message ids are +# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted +# ids (msg_...) stay well under this cap and are worth keeping for +# prefix-cache hits. Drop only the oversized ones on replay. +_MAX_RESPONSES_ITEM_ID_LENGTH = 64 + def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str: """Normalize a Responses assistant message status for replay. @@ -464,7 +471,9 @@ def _chat_messages_to_responses_input( } item_id = raw_item.get("id") if isinstance(item_id, str) and item_id.strip(): - replay_item["id"] = item_id.strip() + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + replay_item["id"] = stripped_id phase = raw_item.get("phase") if isinstance(phase, str) and phase.strip(): replay_item["phase"] = phase.strip() @@ -718,7 +727,9 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]: } item_id = item.get("id") if isinstance(item_id, str) and item_id.strip(): - normalized_item["id"] = item_id.strip() + stripped_id = item_id.strip() + if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: + normalized_item["id"] = stripped_id phase = item.get("phase") if isinstance(phase, str) and phase.strip(): normalized_item["phase"] = phase.strip() diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index b8586dbeace..e3c699d3ede 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -3,9 +3,11 @@ from types import SimpleNamespace import pytest from agent.codex_responses_adapter import ( + _chat_messages_to_responses_input, _format_responses_error, _normalize_codex_response, _preflight_codex_api_kwargs, + _preflight_codex_input_items, ) @@ -139,6 +141,128 @@ def test_normalize_codex_response_in_progress_message_still_incomplete(): assert finish_reason == "incomplete" +# --------------------------------------------------------------------------- +# Replayed assistant message items with an oversized server-assigned ``id`` +# (Codex issues 400+ char base64 blobs) must never reach the API — the +# Responses endpoint caps input[].id at 64 chars and rejects the whole +# request with a non-retryable HTTP 400, permanently bricking the session +# (every subsequent turn replays the same bad id). Short ids (msg_...) are +# still worth keeping for prefix-cache hits, so this is a length guard, not +# a blanket strip. +# --------------------------------------------------------------------------- + +_OVERSIZED_ITEM_ID = "x" * 408 +_VALID_ITEM_ID = "msg_abc123" + + +def test_chat_messages_to_responses_input_drops_oversized_message_id(): + messages = [ + { + "role": "assistant", + "content": "pong", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": _OVERSIZED_ITEM_ID, + "phase": "final_answer", + } + ], + } + ] + + items = _chat_messages_to_responses_input(messages) + + message_item = next(item for item in items if item.get("type") == "message") + assert "id" not in message_item + assert message_item["phase"] == "final_answer" + assert message_item["content"] == [{"type": "output_text", "text": "pong"}] + + +def test_chat_messages_to_responses_input_keeps_short_message_id(): + messages = [ + { + "role": "assistant", + "content": "pong", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": _VALID_ITEM_ID, + } + ], + } + ] + + items = _chat_messages_to_responses_input(messages) + + message_item = next(item for item in items if item.get("type") == "message") + assert message_item["id"] == _VALID_ITEM_ID + + +def test_preflight_codex_input_items_drops_oversized_message_id(): + items = _preflight_codex_input_items( + [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": _OVERSIZED_ITEM_ID, + "phase": "final_answer", + } + ] + ) + + assert "id" not in items[0] + assert items[0]["phase"] == "final_answer" + + +def test_preflight_codex_input_items_keeps_short_message_id(): + items = _preflight_codex_input_items( + [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": _VALID_ITEM_ID, + } + ] + ) + + assert items[0]["id"] == _VALID_ITEM_ID + + +def test_preflight_codex_api_kwargs_drops_oversized_message_id_end_to_end(): + kwargs = _preflight_codex_api_kwargs( + { + "model": "gpt-5.5", + "instructions": "You are Hermes.", + "input": [ + {"role": "user", "content": "ping"}, + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": _OVERSIZED_ITEM_ID, + "phase": "final_answer", + }, + ], + "tools": [], + "store": False, + } + ) + + message_item = next(item for item in kwargs["input"] if item.get("type") == "message") + assert "id" not in message_item + + # --------------------------------------------------------------------------- # _preflight_codex_api_kwargs — built-in (provider-executed) tools must pass # through validation. Regression guard for the xAI native web_search