fix(codex): drop oversized message ids on Responses input replay
Codex assigns assistant message items server-side ids that can run 400+ chars (base64 encrypted blobs), but the Responses API caps input[].id at 64 chars and rejects the whole request with a non-retryable HTTP 400. Once a session captures one of these long ids, every subsequent turn replays it and 400s forever, since the history persists it in codex_message_items. Add a 64-char length guard at both replay sites — the history-to- input converter and the final preflight gate — so oversized ids are dropped while short ids (msg_...) are kept for prefix-cache hits. Mirrors the existing pattern for reasoning items, which already strip their id before replay because store=False means the API can't resolve ids server-side anyway. Fixes #27038 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = {
|
|||||||
|
|
||||||
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
|
_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:
|
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
|
||||||
"""Normalize a Responses assistant message status for replay.
|
"""Normalize a Responses assistant message status for replay.
|
||||||
@@ -464,7 +471,9 @@ def _chat_messages_to_responses_input(
|
|||||||
}
|
}
|
||||||
item_id = raw_item.get("id")
|
item_id = raw_item.get("id")
|
||||||
if isinstance(item_id, str) and item_id.strip():
|
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")
|
phase = raw_item.get("phase")
|
||||||
if isinstance(phase, str) and phase.strip():
|
if isinstance(phase, str) and phase.strip():
|
||||||
replay_item["phase"] = 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")
|
item_id = item.get("id")
|
||||||
if isinstance(item_id, str) and item_id.strip():
|
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")
|
phase = item.get("phase")
|
||||||
if isinstance(phase, str) and phase.strip():
|
if isinstance(phase, str) and phase.strip():
|
||||||
normalized_item["phase"] = phase.strip()
|
normalized_item["phase"] = phase.strip()
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent.codex_responses_adapter import (
|
from agent.codex_responses_adapter import (
|
||||||
|
_chat_messages_to_responses_input,
|
||||||
_format_responses_error,
|
_format_responses_error,
|
||||||
_normalize_codex_response,
|
_normalize_codex_response,
|
||||||
_preflight_codex_api_kwargs,
|
_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"
|
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
|
# _preflight_codex_api_kwargs — built-in (provider-executed) tools must pass
|
||||||
# through validation. Regression guard for the xAI native web_search
|
# through validation. Regression guard for the xAI native web_search
|
||||||
|
|||||||
Reference in New Issue
Block a user