From a433631d19631234eaf3de53618e24b268797290 Mon Sep 17 00:00:00 2001 From: Xenu Date: Sun, 26 Jul 2026 15:04:57 +0100 Subject: [PATCH] fix(codex-responses): cap over-long call_id instead of failing the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex app-server names MCP tool calls ``codex_mcp_____exec-``. A 36-character uuid plus ``_exec-`` already leaves only 22 characters for the server and tool names, so real ids run well past the Responses API's 64-character ``call_id`` limit. Measured on a live install: 94 codex_mcp__codex_apps__google_calendar.search_events_exec- 93 codex_mcp__codex_apps__google_calendar.create_event_exec- 92 codex_mcp__openaiDeveloperDocs__search_openai_docs_exec- 82 codex_mcp__hermes-tools__kanban_complete_exec- Replaying such a transcript over the Responses wire — a background-review fork, or any codex_app_server -> codex_responses downgrade — then dies on: Invalid 'input[187].call_id': string too long. Expected a string with maximum length 64, but got a string with length 71 instead. That is a non-retryable client error, so the entire call is lost. Observed repeatedly on a live gateway: every background-review pass failed this way, meaning the runtime's self-review stage silently never ran. Cap instead of fail. ``_cap_call_id`` keeps a readable prefix and appends a sha256 digest of the FULL original id, so distinct calls cannot collide and the result is exactly 64 characters. It is a pure function of the input, so replays and prefix caches stay stable (AGENTS.md Pitfall #16 — deterministic IDs in tool call history). Ids already within the limit are returned untouched, leaving the ordinary ``call_...`` shape unaffected. Applied inside ``_preflight_codex_input_items``, the single choke point every request passes through, at all three write sites — function_call plus both function_call_output branches (string and multimodal array). Uniform application in one pass is what keeps a call and its output referencing the same id; the tests assert that pairing explicitly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018tF9BacnKsd4w7KXmFeKP6 --- agent/codex_responses_adapter.py | 40 +++++++++- tests/agent/test_codex_responses_adapter.py | 88 +++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index ee75f4190e6d..39ace879b785 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -600,6 +600,40 @@ def _chat_messages_to_responses_input( # Input preflight / validation # --------------------------------------------------------------------------- +#: The Responses API rejects a ``call_id`` longer than this with a +#: non-retryable HTTP 400 ``string_above_max_length``. +CALL_ID_MAX_LEN = 64 + + +def _cap_call_id(call_id: str) -> str: + """Fit ``call_id`` inside the Responses API's 64-character limit. + + The codex app-server names MCP tool calls + ``codex_mcp_____exec-``, which runs 79-94 characters in + practice — a 36-char uuid plus ``_exec-`` already leaves only 22 for the + server and tool names. Replaying such a transcript over the Responses wire + (a background-review fork, or any codex_app_server -> codex_responses + downgrade) therefore fails with: + + Invalid 'input[N].call_id': string too long. Expected a string with + maximum length 64, but got a string with length 78 instead. + + That error is non-retryable, so the whole call dies. Shorten instead: + keep a readable prefix and append a digest of the FULL original id so + distinct calls can never collide. Purely a function of the input, so + replays and prefix caches stay stable (AGENTS.md Pitfall #16 — + deterministic IDs in tool call history). + + Ids already within the limit are returned untouched, so the normal + ``call_...`` shape is unaffected. + """ + if len(call_id) <= CALL_ID_MAX_LEN: + return call_id + digest = hashlib.sha256(call_id.encode("utf-8")).hexdigest()[:16] + keep = CALL_ID_MAX_LEN - len(digest) - 1 + return f"{call_id[:keep]}_{digest}" + + def _preflight_codex_input_items( raw_items: Any, *, @@ -633,7 +667,7 @@ def _preflight_codex_input_items( normalized.append( { "type": "function_call", - "call_id": call_id.strip(), + "call_id": _cap_call_id(call_id.strip()), "name": name.strip(), "arguments": arguments, } @@ -674,7 +708,7 @@ def _preflight_codex_input_items( normalized.append( { "type": "function_call_output", - "call_id": call_id.strip(), + "call_id": _cap_call_id(call_id.strip()), "output": cleaned if cleaned else "", } ) @@ -685,7 +719,7 @@ def _preflight_codex_input_items( normalized.append( { "type": "function_call_output", - "call_id": call_id.strip(), + "call_id": _cap_call_id(call_id.strip()), "output": output, } ) diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index eb690a9e3b82..cb20d367997e 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -3,6 +3,8 @@ import pytest from agent.codex_responses_adapter import ( + CALL_ID_MAX_LEN, + _cap_call_id, _chat_messages_to_responses_input, _format_responses_error, _normalize_codex_response, @@ -537,3 +539,89 @@ def test_normalize_codex_response_xai_reasoning_without_marker_stays_incomplete( assert finish_reason == "incomplete" assert assistant_message.content == "" + + +# --------------------------------------------------------------------------- +# call_id length cap (Responses API rejects > 64 chars, non-retryably) +# --------------------------------------------------------------------------- + +# Real ids observed from the codex app-server, which names MCP tool calls +# codex_mcp_____exec-. 82 and 94 characters. +_LONG_CALL_ID = ( + "codex_mcp__hermes-tools__kanban_complete_exec-" + "34a055fe-2a0b-4d32-a499-dcd21100d76a" +) +_LONGER_CALL_ID = ( + "codex_mcp__codex_apps__google_calendar.search_events_exec-" + "6450d423-18e8-4e0c-9b87-36124bb53370" +) + + +def test_preflight_caps_overlong_call_id_and_keeps_the_pair_matched(): + """A function_call and its output must still reference the SAME id. + + Without the cap the Responses API returns a non-retryable + HTTP 400 string_above_max_length and the entire call dies. + """ + items = _preflight_codex_input_items([ + { + "type": "function_call", + "call_id": _LONG_CALL_ID, + "name": "kanban_complete", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": _LONG_CALL_ID, + "output": "ok", + }, + ]) + + call, output = items[0], items[1] + assert len(call["call_id"]) <= CALL_ID_MAX_LEN + assert call["call_id"] == output["call_id"], "pairing must survive the cap" + assert call["call_id"] != _LONG_CALL_ID + + +def test_preflight_caps_multimodal_tool_output_call_id_too(): + """The array-output branch is a separate write site — cap it as well.""" + items = _preflight_codex_input_items([ + { + "type": "function_call", + "call_id": _LONGER_CALL_ID, + "name": "search_events", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": _LONGER_CALL_ID, + "output": [{"type": "input_text", "text": "found"}], + }, + ]) + + assert len(items[1]["call_id"]) <= CALL_ID_MAX_LEN + assert items[0]["call_id"] == items[1]["call_id"] + + +def test_preflight_leaves_normal_call_ids_untouched(): + items = _preflight_codex_input_items([ + { + "type": "function_call", + "call_id": "call_abc123", + "name": "read_file", + "arguments": "{}", + }, + ]) + + assert items[0]["call_id"] == "call_abc123" + + +def test_cap_call_id_is_deterministic_and_collision_resistant(): + a = _LONG_CALL_ID + # Differs only in the final uuid character — the truncated prefix is + # identical, so only the digest can keep these apart. + b = _LONG_CALL_ID[:-1] + ("0" if _LONG_CALL_ID[-1] != "0" else "1") + + assert _cap_call_id(a) == _cap_call_id(a) + assert _cap_call_id(a) != _cap_call_id(b) + assert len(_cap_call_id(a)) == CALL_ID_MAX_LEN