fix(codex): also guard the auxiliary Copilot Responses adapter

_CodexCompletionsAdapter (agent/auxiliary_client.py) is a second,
independent producer of Codex Responses input — used by auxiliary
calls (context compression, flush_memories, MoA aggregation,
session_search) that route through CodexAuxiliaryClient instead of
the main agent's ResponsesApiTransport.build_kwargs. It calls
_chat_messages_to_responses_input() directly without is_github_responses,
so the previous commit's fix didn't cover it: an auxiliary call made
against a Copilot-backed session could still replay a connection-scoped
codex_message_items id and hit the same HTTP 401.

Detect the Copilot host from the adapter's own client.base_url (same
check the adapter already does further down for prompt_cache_key
opt-out) and pass is_github_responses through, closing the gap.

Still #32716.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
joaomarcos 2026-07-10 15:43:49 -03:00 committed by kshitij
parent b9146a47bc
commit 83b2a685cd
2 changed files with 102 additions and 1 deletions

View file

@ -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,

View file

@ -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