diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index d8ff9204dae..585db9940cd 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -872,6 +872,7 @@ class _CodexCompletionsAdapter: # `function_call_output` items with a valid call_id, so every # Responses path normalizes tool history identically and cannot drift. from agent.codex_responses_adapter import _chat_messages_to_responses_input + from utils import base_url_host_matches instructions = "You are a helpful assistant." replay_messages: List[Dict[str, Any]] = [] @@ -883,7 +884,18 @@ class _CodexCompletionsAdapter: else: replay_messages.append(msg) - input_items = _chat_messages_to_responses_input(replay_messages) + # Copilot (githubcopilot.com) binds replayed codex_message_items ids + # to a backend "connection" that doesn't survive credential + # rotation/gateway restarts — replaying one gets HTTP 401 "input + # item ID does not belong to this connection" (#32716). Auxiliary + # calls (context compression, flush_memories, MoA aggregation) go + # through this adapter instead of agent/transports/codex.py's + # build_kwargs, so they need the same guard applied independently. + _host_for_input = str(getattr(self._client, "base_url", "") or "") + _is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com") + input_items = _chat_messages_to_responses_input( + replay_messages, is_github_responses=_is_github_for_input, + ) resp_kwargs: Dict[str, Any] = { "model": model, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 5e18fa73a1f..d4bab973a9e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -4050,6 +4050,95 @@ class TestCodexAdapterPromptCacheKey: assert "prompt_cache_key" not in captured +class TestCodexAdapterGithubResponsesMessageIdDrop: + """_CodexCompletionsAdapter must drop codex_message_items ``id`` when + talking to Copilot (githubcopilot.com), independent of the main + transport's build_kwargs path. Auxiliary calls (context compression, + flush_memories, MoA aggregation) route through this adapter instead of + agent/transports/codex.py, so they need the same #32716 guard applied + separately — Copilot binds replayed ids to a backend "connection" that + doesn't survive credential rotation/gateway restarts, and rejects a + stale id with HTTP 401 regardless of its length. + """ + + @staticmethod + def _build_adapter(base_url): + from agent.auxiliary_client import _CodexCompletionsAdapter + from types import SimpleNamespace + + message_item = SimpleNamespace( + type="message", role="assistant", status="completed", + content=[SimpleNamespace(type="output_text", text="hi")], + ) + events = [ + SimpleNamespace(type="response.created"), + SimpleNamespace(type="response.output_item.done", item=message_item), + SimpleNamespace( + type="response.completed", + response=SimpleNamespace( + status="completed", id="resp_test", + usage=SimpleNamespace(input_tokens=1, output_tokens=1, total_tokens=2), + ), + ), + ] + + class _FakeCreateStream: + def __iter__(self): return iter(events) + def close(self): pass + + captured_kwargs = {} + + def _create(**kwargs): + captured_kwargs.update(kwargs) + return _FakeCreateStream() + + real_client = MagicMock() + real_client.base_url = base_url + real_client.responses.create = _create + adapter = _CodexCompletionsAdapter(real_client, "gpt-5.5") + return adapter, captured_kwargs + + @staticmethod + def _replay_messages(): + return [ + {"role": "system", "content": "You are helpful."}, + { + "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", + } + ], + }, + {"role": "user", "content": "continue"}, + ] + + def test_drops_message_id_for_github_copilot_host(self): + adapter, captured = self._build_adapter(base_url="https://api.githubcopilot.com") + adapter.create(messages=self._replay_messages()) + message_item = next( + item for item in captured["input"] if item.get("type") == "message" + ) + assert "id" not in message_item + assert message_item["phase"] == "final_answer" + + def test_keeps_message_id_for_codex_backend_host(self): + adapter, captured = self._build_adapter( + base_url="https://chatgpt.com/backend-api/codex" + ) + adapter.create(messages=self._replay_messages()) + message_item = next( + item for item in captured["input"] if item.get("type") == "message" + ) + assert message_item["id"] == "msg_short_but_connection_scoped" + + class TestVisionAutoSkipsKimiCoding: """_resolve_auto vision branch skips providers that have no vision on their main endpoint (e.g. Kimi Coding Plan /coding) and falls through