From c3eb62b86e62557778e9966dcc8048a8987c1a8a Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 26 May 2026 10:16:15 -0700 Subject: [PATCH 1/2] fix(agent): strip connection-bound message ids on Copilot Responses replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes was permanently breaking multi-turn Copilot sessions whenever the backend "connection" rotated between turns. ``_chat_messages_to_responses_input`` replays prior assistant ``codex_message_items`` with their server-assigned ``id`` field intact. GitHub Copilot's ``/responses`` endpoint binds those ids to a backend connection that does not survive credential-pool rotation, gateway restart, or routine load-balancer churn — replaying a stale id returns ``HTTP 401 "input item ID does not belong to this connection"`` and poisons the session because every subsequent turn re-sends the same bad ids. Add an ``is_github_responses`` flag to ``_chat_messages_to_responses_input`` and wire it through ``ResponsesApiTransport``'s ``convert_messages`` and ``build_kwargs``. When the flag is set, the replay omits the connection- bound ``id`` while keeping ``content``, ``phase`` and ``status`` so prefix-cache hits and multi-turn coherence still work. Native Codex and xAI Responses keep the existing id-replay behaviour. Fixes #32716. --- agent/codex_responses_adapter.py | 18 +++++++- agent/transports/codex.py | 2 + .../test_run_agent_codex_responses.py | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 07ae5cc95068..b9262db222fb 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -248,6 +248,7 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, + is_github_responses: bool = False, ) -> List[Dict[str, Any]]: """Convert internal chat-style messages to Responses input items. @@ -261,6 +262,17 @@ def _chat_messages_to_responses_input( integration). We now replay encrypted reasoning on every Responses transport (xAI, native Codex, custom relays) and let xAI tell us explicitly if a specific surface ever rejects a payload. + + ``is_github_responses`` strips server-assigned ``id`` fields from + replayed assistant message items. GitHub Copilot's ``/responses`` + endpoint binds those ids to a backend "connection" that does not + survive credential-pool rotation, gateway restart, or even routine + load-balancer churn between turns. Replaying the id after the + connection rotates yields ``HTTP 401 "input item ID does not belong + to this connection"`` and permanently poisons the session because + every subsequent turn re-sends the same bad ids. Content and phase + are preserved so multi-turn coherence and prefix-cache opportunities + survive the strip. """ items: List[Dict[str, Any]] = [] seen_item_ids: set = set() @@ -348,7 +360,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 ( + isinstance(item_id, str) + and item_id.strip() + and not is_github_responses + ): replay_item["id"] = item_id.strip() phase = raw_item.get("phase") if isinstance(phase, str) and phase.strip(): diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 970692c03947..db206940c265 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -27,6 +27,7 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: 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")), ) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: @@ -100,6 +101,7 @@ def build_kwargs( "input": _chat_messages_to_responses_input( payload_messages, is_xai_responses=is_xai_responses, + is_github_responses=is_github_responses, ), "tools": response_tools, "store": False, diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index bc575cc676f9..3eb0c254bc15 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1821,6 +1821,52 @@ def test_codex_message_item_status_survives_conversion_and_preflight(monkeypatch assert normalized[0]["status"] == "in_progress" +def test_chat_messages_to_responses_input_strips_message_id_for_github_responses(): + """Copilot ``/responses`` binds assistant message ids to a backend "connection" + that does not survive credential-pool rotation, gateway restart, or routine + load-balancer churn between turns. Replaying a connection-bound id yields + ``HTTP 401 "input item ID does not belong to this connection"`` and + permanently poisons the session. When ``is_github_responses=True`` we must + drop the ``id`` field while preserving the content + phase replay (issue + #32716). + """ + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + payload = [ + { + "role": "assistant", + "content": "answer", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "status": "completed", + "id": "msg_connection_bound_abc123", + "phase": "final", + "content": [{"type": "output_text", "text": "answer"}], + } + ], + } + ] + + items_copilot = _chat_messages_to_responses_input( + payload, is_github_responses=True + ) + replay_copilot = next(item for item in items_copilot if item.get("type") == "message") + assert "id" not in replay_copilot, ( + "Copilot ``/responses`` replay must omit the connection-bound id" + ) + assert replay_copilot["phase"] == "final" + assert replay_copilot["content"] == [{"type": "output_text", "text": "answer"}] + + # Other Responses transports (native Codex, xAI) keep the id so the + # OpenAI/xAI backend can land prefix-cache hits. + items_default = _chat_messages_to_responses_input(payload) + replay_default = next(item for item in items_default if item.get("type") == "message") + assert replay_default["id"] == "msg_connection_bound_abc123" + assert replay_default["phase"] == "final" + + def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch): """Two consecutive reasoning-only responses with different encrypted content must NOT be treated as duplicates.""" From 6b7ff1d6a0993a75ca1d38badcfabd1fd7e25398 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 26 May 2026 13:11:18 -0700 Subject: [PATCH 2/2] fix(agent): address Copilot review on Copilot Responses id-strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codex_responses_adapter: clarify docstring — the Copilot replay intentionally trades the id-keyed prefix-cache shortcut for session survivability, since that shortcut is the failure mechanism here. Multi-turn coherence still survives via replayed content/phase/status. - transports/codex: tighten ``is_xai_responses`` / ``is_github_responses`` kwarg handling in ``ResponsesApiTransport.convert_messages`` to a strict ``is True`` check. ``bool(kwargs.get(...))`` would treat any truthy string (e.g. an env-piped ``"false"``) as enabling the backend-specific replay branch and silently change semantics. - tests/run_agent/test_run_agent_codex_responses: replace the bare ``next(...)`` over the replayed message items with an explicit ``assert message_items`` + indexed read, so a regression that drops the message item entirely fails with a clear assertion message instead of an opaque ``StopIteration``. --- agent/codex_responses_adapter.py | 8 +++++--- agent/transports/codex.py | 9 +++++++-- tests/run_agent/test_run_agent_codex_responses.py | 12 ++++++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index b9262db222fb..3fd253a8b8b8 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -270,9 +270,11 @@ def _chat_messages_to_responses_input( load-balancer churn between turns. Replaying the id after the connection rotates yields ``HTTP 401 "input item ID does not belong to this connection"`` and permanently poisons the session because - every subsequent turn re-sends the same bad ids. Content and phase - are preserved so multi-turn coherence and prefix-cache opportunities - survive the strip. + every subsequent turn re-sends the same bad ids. The replayed + ``content``, ``phase`` and ``status`` are enough to preserve + multi-turn coherence; the id-keyed prefix-cache shortcut that + other Responses backends use is intentionally given up on the + Copilot path since it is the very mechanism that breaks here. """ items: List[Dict[str, Any]] = [] seen_item_ids: set = set() diff --git a/agent/transports/codex.py b/agent/transports/codex.py index db206940c265..d8e7f99788a1 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -24,10 +24,15 @@ def api_mode(self) -> str: def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: """Convert OpenAI chat messages to Responses API input items.""" from agent.codex_responses_adapter import _chat_messages_to_responses_input + # Strict identity checks: only enable backend-specific replay + # behaviour when the caller passes the actual ``True`` value. + # ``bool(...)`` would treat truthy strings like "false"/"0" + # coming from environment plumbing as ``True`` and silently + # change Responses replay semantics. 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")), + is_xai_responses=kwargs.get("is_xai_responses") is True, + is_github_responses=kwargs.get("is_github_responses") is True, ) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 3eb0c254bc15..e0e1c008bb45 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1852,7 +1852,11 @@ def test_chat_messages_to_responses_input_strips_message_id_for_github_responses items_copilot = _chat_messages_to_responses_input( payload, is_github_responses=True ) - replay_copilot = next(item for item in items_copilot if item.get("type") == "message") + message_items_copilot = [item for item in items_copilot if item.get("type") == "message"] + assert message_items_copilot, ( + "Copilot ``/responses`` adapter must still emit a replayed assistant message item" + ) + replay_copilot = message_items_copilot[0] assert "id" not in replay_copilot, ( "Copilot ``/responses`` replay must omit the connection-bound id" ) @@ -1862,7 +1866,11 @@ def test_chat_messages_to_responses_input_strips_message_id_for_github_responses # Other Responses transports (native Codex, xAI) keep the id so the # OpenAI/xAI backend can land prefix-cache hits. items_default = _chat_messages_to_responses_input(payload) - replay_default = next(item for item in items_default if item.get("type") == "message") + message_items_default = [item for item in items_default if item.get("type") == "message"] + assert message_items_default, ( + "Default Responses adapter must still emit a replayed assistant message item" + ) + replay_default = message_items_default[0] assert replay_default["id"] == "msg_connection_bound_abc123" assert replay_default["phase"] == "final"