openai / openai/codex

WebSocket response chain leaks across turns and causes Invalid previous_response_id

Open
#42,787 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug CLI connectivity session
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

What is the bug?

When a Codex thread starts a new turn while using the Responses WebSocket transport, the new turn-scoped ModelClientSession can inherit the previous turn's last_response_rx from the cached WebsocketSession.

That makes the first request of the new turn use the previous turn's provider-private previous_response_id and only the computed input delta. If that response handle is no longer valid in the provider's current lifecycle domain, the provider rejects an otherwise valid Codex thread continuation before producing any tokens:

400 invalid_request_error: Invalid previous_response_id

The durable Codex thread and its canonical history are still valid. The stale object is only the provider response-chain handle.

Reproduction

This is deterministic with the existing WebSocket test server:

  1. Create a WebSocket-capable OpenAI ModelClient.
  2. Create a ModelClientSession, send turn 1, receive resp-1, and drop that turn-scoped session.
  3. Create a second ModelClientSession from the same client and provide the complete canonical input for turn 2.
  4. Observe that the socket connection is correctly reused, but the request also carries previous_response_id: resp-1 and only the inferred delta.

The same behavior occurs when the second native turn is introduced through an App-tools send_message_to_thread result represented as a function_call_output in canonical history.

Root cause

ModelClientSession is explicitly turn-scoped, but ModelClient::new_session() takes the entire cached WebsocketSession. That cache contains both:

  • connection state, which is safe and useful to reuse across turns; and
  • last_request, last_response_rx, and the untraced-warmup marker, which represent one provider response chain and should remain turn-scoped.

When the prior session is dropped, all of that state is returned to the client cache. The next turn therefore treats a cross-turn request as a same-turn incremental request.

Expected behavior
  • A WebSocket connection may be reused across Codex turns.
  • The first request from every new ModelClientSession must contain full canonical turn history and no previous_response_id.
  • Incremental previous_response_id requests must remain available inside one turn, including tool-call loops.
  • Startup prewarm reuse inside the first turn must continue to work.
Proposed fix and validated patch

Reset only response-chain state when converting the cached WebSocket transport into a new turn:

fn into_new_turn(mut self) -> Self {
    self.last_request = None;
    self.last_response_rx = None;
    self.last_response_from_untraced_warmup = false;
    self
}

Then use it in ModelClient::new_session() while preserving the existing socket connection.

An atomic patch against current main is available here:

Per the repository contribution policy, I am opening the bug report first rather than submitting an unsolicited PR. I would be happy to open a PR if a maintainer confirms the approach and invites it.

Validation

The patch adds/updates deterministic coverage for:

  • socket reuse across session drop without cross-turn response-chain reuse;
  • an externally injected callback turn containing function_call_output;
  • three consecutive turns using complete canonical history;
  • same-turn incremental WebSocket requests still using previous_response_id;
  • startup prewarm response reuse remaining unchanged.

All five focused tests pass on the current main base. cargo fmt --all -- --check also passes.

Environment
ChatGPT Desktop bundled codex-cli: 0.153.0
macOS: 26.6.2 (25G83)
source base: b3f5e45cc1de8bcb09d320f3211378db285aa201
Additional context

This should be prevented at the turn boundary rather than handled as a blind retry after a 400. Retrying the same request with an uncertain provider/effect state can duplicate tool effects. Sending the complete canonical history on a new turn avoids dependence on a provider-private handle while preserving all durable Codex thread state.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at ModelClient::new_session and trace how the cached WebsocketSession is reused when a ModelClientSession ends. Review the proposed into_new_turn response-chain reset and the five focused tests covering socket reuse, callback turns, consecutive turns, same-turn increments, and startup prewarm. Done means new turns send canonical history without a previous_response_id while same-turn incremental requests and prewarm reuse still pass, along with cargo fmt --all -- --check.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api, backend, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.