diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index e25293d7320..edb63849cb7 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -314,6 +314,7 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, + is_github_responses: bool = False, replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None, ) -> List[Dict[str, Any]]: @@ -338,6 +339,16 @@ def _chat_messages_to_responses_input( items from the conversation history and threads ``replay_enabled=False`` through this converter so subsequent turns send no reasoning items. + ``is_github_responses`` drops the ``id`` field from replayed + ``codex_message_items`` regardless of length. The Copilot backend + (api.githubcopilot.com/responses) binds these ids to a specific + backend "connection" — credential-pool rotation, a gateway restart, + or routine load-balancer churn between turns all invalidate it — and + rejects a stale id with HTTP 401 "input item ID does not belong to + this connection" even for short ids (see #32716). ``phase``/ + ``status``/``content`` are still replayed; only ``id`` is unsafe to + reuse across a Copilot connection. + ``current_issuer_kind`` enables a per-item cross-issuer guard. The Responses API's ``encrypted_content`` blob is decryptable only by the endpoint that minted it — replaying a Codex-issued blob against xAI @@ -470,7 +481,11 @@ def _chat_messages_to_responses_input( "content": normalized_content_parts, } item_id = raw_item.get("id") - if isinstance(item_id, str) and item_id.strip(): + if ( + not is_github_responses + and isinstance(item_id, str) + and item_id.strip() + ): stripped_id = item_id.strip() if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH: replay_item["id"] = stripped_id diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 56374b87533..ea78c97b9f6 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -81,6 +81,7 @@ class ResponsesApiTransport(ProviderTransport): return _chat_messages_to_responses_input( messages, is_xai_responses=bool(kwargs.get("is_xai_responses")), + is_github_responses=bool(kwargs.get("is_github_responses")), replay_encrypted_reasoning=bool( kwargs.get("replay_encrypted_reasoning", True) ), @@ -239,6 +240,7 @@ class ResponsesApiTransport(ProviderTransport): "input": _chat_messages_to_responses_input( payload_messages, is_xai_responses=is_xai_responses, + is_github_responses=is_github_responses, replay_encrypted_reasoning=replay_encrypted_reasoning, current_issuer_kind=issuer_kind, ), diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 6389890519a..6116d3978fb 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -121,6 +121,63 @@ class TestCodexBuildKwargs: ) assert "prompt_cache_key" not in kw + def test_github_responses_drops_message_item_id_end_to_end(self, transport): + # #32716: Copilot binds codex_message_items ids to a backend + # "connection" that doesn't survive credential rotation, a gateway + # restart, or load-balancer churn — replaying a stale id gets HTTP + # 401 "input item ID does not belong to this connection", even for + # ids well under the #27038 64-char length cap. build_kwargs must + # thread is_github_responses through to the input converter so the + # id never reaches the request. + messages = [ + {"role": "system", "content": "You are Hermes."}, + { + "role": "assistant", + "content": "pong", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": "msg_short_but_connection_scoped", + "phase": "final_answer", + } + ], + }, + ] + kw = transport.build_kwargs( + model="gpt-5.5", messages=messages, tools=[], + is_github_responses=True, + ) + message_item = next(item for item in kw["input"] if item.get("type") == "message") + assert "id" not in message_item + assert message_item["phase"] == "final_answer" + + def test_non_github_responses_keeps_message_item_id_end_to_end(self, transport): + messages = [ + {"role": "system", "content": "You are Hermes."}, + { + "role": "assistant", + "content": "pong", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong"}], + "id": "msg_short_id", + } + ], + }, + ] + kw = transport.build_kwargs( + model="gpt-5.5", messages=messages, tools=[], + is_codex_backend=True, + ) + message_item = next(item for item in kw["input"] if item.get("type") == "message") + assert message_item["id"] == "msg_short_id" + def test_xai_responses_sends_cache_key_via_extra_body(self, transport): """xAI's Responses API documents ``prompt_cache_key`` as the body-level cache-routing key (the ``x-grok-conv-id`` header is