Skip to content

fix(agent): strip connection-bound message ids on Copilot Responses replay - #32725

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/copilot-strip-message-id-32716
Closed

fix(agent): strip connection-bound message ids on Copilot Responses replay#32725
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/copilot-strip-message-id-32716

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

When using the GitHub Copilot provider with a model that routes to the Responses API (e.g. gpt-5.5), Hermes can permanently break a multi-turn session by replaying assistant codex_message_items ids that were minted under a different "connection." The Copilot backend rejects the request with HTTP 401 "input item ID does not belong to this connection". Every subsequent turn re-sends the same poisoned ids and gets the same 401 back, making the session unrecoverable.

The bug is in agent/codex_responses_adapter.py::_chat_messages_to_responses_input — when a prior assistant turn contained server-assigned message-item ids, we replay the full item including its id field. Copilot binds that id to a backend connection that does not survive credential-pool rotation, gateway restart, or routine GitHub-side load-balancer churn between turns.

This PR adds an is_github_responses flag to _chat_messages_to_responses_input and wires it through ResponsesApiTransport.convert_messages and ResponsesApiTransport.build_kwargs (the only two call sites). 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 (is_github_responses=False, default) and xAI Responses keep the existing id-replay behaviour — those backends rely on the id for cache lookups and do not have the connection-binding constraint.

Audited siblings: codex_reasoning_items already strip their id unconditionally (with a store=False comment in the source) so no widening needed there. The only other Responses adapter path that consumes ids is _preflight_codex_input_items, which receives already-normalized items so the strip is correctly performed upstream. No additional widening needed.

Related Issue

Fixes #32716

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/codex_responses_adapter.py — add is_github_responses keyword argument to _chat_messages_to_responses_input; skip the id field on replayed codex_message_items when the flag is set; document the connection-binding rationale.
  • agent/transports/codex.py — forward is_github_responses to the adapter at both call sites (convert_messages and build_kwargs). The transport already accepts the flag from upstream callers; this propagates it the last hop into the converter.
  • tests/run_agent/test_run_agent_codex_responses.py — add test_chat_messages_to_responses_input_strips_message_id_for_github_responses exercising both the new flag (id stripped, phase/content preserved) and the default behaviour (id kept) so the regression guard catches future drift.

How to Test

  1. Reproduce per the issue: use the copilot provider with gpt-5.5 (anything matched by _should_use_copilot_responses_api), take a few multi-turn steps so the gateway captures assistant codex_message_items with server ids, then trigger a connection rotation (credential-pool rotation, gateway restart with a different gh account, or organic load-balancer churn in a long session). The next /responses call previously returned 401; with this fix the request is sent without the connection-bound id and succeeds.
  2. Run focused tests:
    uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest tests/run_agent/test_run_agent_codex_responses.py -v
    
    375 passed locally (8 of which are _chat_messages_to_responses_input and codex_message_items regression tests including the new one).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run focused tests for the touched code and all pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15.x

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstring on _chat_messages_to_responses_input updated to explain the new flag and its rationale
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A (no new config keys)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — N/A (pure data-shape change)
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

The issue body has a complete trace and reproduction.

Related / Positioning

Distinct from prior Responses-id fixes:

No competing PR on this code path; closed-PR scan returned no prior briandevans PR on the Copilot connection-binding error.

…eplay

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 NousResearch#32716.
Copilot AI review requested due to automatic review settings May 26, 2026 17:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds GitHub Copilot /responses compatibility by stripping connection-bound assistant message item IDs during replay to avoid 401 session poisoning.

Changes:

  • Add is_github_responses flag plumbed through the Codex transport into _chat_messages_to_responses_input.
  • Strip replayed assistant message item id fields when is_github_responses=True, preserving phase and content.
  • Add a unit test verifying ID stripping for GitHub Responses while keeping IDs for other transports.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/run_agent/test_run_agent_codex_responses.py Adds regression test ensuring GitHub Responses replays omit connection-bound message item IDs.
agent/transports/codex.py Plumbs is_github_responses through transport conversion/build kwargs into the adapter.
agent/codex_responses_adapter.py Implements GitHub-specific stripping of id on replayed assistant message items and documents rationale.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread agent/codex_responses_adapter.py Outdated
Comment on lines +274 to +275
are preserved so multi-turn coherence and prefix-cache opportunities
survive the strip.
Comment thread agent/transports/codex.py Outdated
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")),
Comment on lines +1855 to +1858
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"
)
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/copilot GitHub Copilot (ACP + Chat) P2 Medium — degraded but workaround exists labels May 26, 2026
- 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``.
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All three findings addressed in commit 6b7ff1d:

  • agent/codex_responses_adapter.py:275 — rewrote the docstring tail to make explicit that the Copilot branch intentionally gives up the id-keyed prefix-cache shortcut (since that shortcut is the failure mechanism), while replayed content / phase / status still carry multi-turn coherence.
  • agent/transports/codex.py:30 — tightened both is_xai_responses and is_github_responses to a strict kwargs.get(...) is True check so an env-piped truthy string can't silently flip Responses replay semantics.
  • tests/run_agent/test_run_agent_codex_responses.py:1858 — replaced both bare next(...) calls with explicit assert message_items + indexed reads so a regression that drops the message item entirely fails with a clear assertion message instead of StopIteration.

@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the queue focused — 33 days idle with no maintainer pickup. Happy to reopen if the Copilot Responses replay id-stripping fix is still wanted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/copilot GitHub Copilot (ACP + Chat) type/bug Something isn't working

Projects

None yet

3 participants