Skip to content

Codex continuation drops previous_response_id after tool calls: retained function_call arguments are compared byte-for-byte against re-serialized arguments #118

Description

@ItsAlbertZhang

(all file:line references are against main = dc61029, v0.1.35)

Symptom

With CCP_CODEX_PREVIOUS_RESPONSE_ID=1, plain text turns reuse previous_response_id and send only the input delta, but the turn that follows a tool call frequently falls back to a full-context request (hasPreviousResponseId: false, inputDeltaCount: null in codex_upstream_request_started, reason not_append_only internally). In an agentic session almost every turn is a tool turn, so the feature mostly degrades to "full history re-upload on most turns". Because should_reset_websocket_pool (src/providers/codex/client.rs:3002-3011) treats every non-disabled reason as a reset, each of these misses also tears down the pooled WebSocket (separate issue, will cross-reference).

Reproduction

  1. CCP_CODEX_PREVIOUS_RESPONSE_ID=1 CCP_TRAFFIC_LOG=1 claude-code-proxy and point Claude Code at it with a Codex model.
  2. Ask for something that produces one Edit (any tool whose arguments have more than one key works; Bash with only command does not trigger it).
  3. Compare the two captures:
    • response of turn N, response.output_item.done for the function_call — arguments as the model streamed it, e.g.
      {"file_path":"/tmp/x","old_string":"a","new_string":"b"}
    • request of turn N+1, the same function_call item in input — re-serialized by the proxy:
      {"file_path":"/tmp/x","new_string":"b","old_string":"a"}
  4. Turn N+1 has no previous_response_id and carries the full input; turn N+2 (after a text-only answer) reuses it again.

Event sequence for turn N (sanitized): response.createdresponse.output_item.added(function_call)response.function_call_arguments.delta… → response.output_item.done(function_call)response.completed.

Root cause

Three sites, each correct on its own:

  1. src/providers/codex/translate/reducer.rs:312-318capture_output_item stores the function_call into the continuation transcript with arguments: args_accum.clone(), i.e. the raw text the model streamed (sanitize_read_args only rewrites it for the abnormal Read offset case).
  2. src/providers/codex/translate/request.rs:893-899 — on the next turn, Claude Code's tool_use.input (a serde_json::Value, translate_shared.rs:13-17) becomes arguments: serde_json::to_string(input). serde_json is used without preserve_order (Cargo.toml:13, and serde_json has no indexmap dependency in Cargo.lock), so Value::Object is a BTreeMap and keys come out sorted and compact.
  3. src/providers/codex/continuation.rs:532-547input_suffix_after_prefix compares each item via serde_json::to_value(..) equality. ResponsesInputItem::FunctionCall { arguments: String } (request.rs:151-157) therefore compares the argument string byte-for-byte: raw model text vs. canonical re-serialization.

The transcript is request_body.input + output_items (continuation.rs:348-349), so the prefix always ends with the previous turn's output items. Any function_call whose raw text differs from its canonical form (key order, whitespace, escape forms) makes the prefix check fail at that item → not_append_only → no previous_response_id. call_id and name are the same on both sides; Reasoning items are built identically on both sides (reducer.rs:162-168 vs request.rs thinking branch), so arguments is the only asymmetric field.

Data point: on my fork, whose comparison at the time was identical to this code, roughly 10–14% of all continuation comparisons over two days of agentic use failed with the first mismatch located within the last few items of the retained transcript (i.e. at the previous turn's function_call items); after switching to argument-level semantic comparison it dropped to ~0.1–1%. Pre-fix hit rate ≈ 66%, post-fix ≈ 95%.

Tests: tests/codex_agent_continuation.rs and smoke_codex_websocket_previous_response_id_sends_delta_on_second_turn only exercise text turns; there is no test that records a function_call and then asks for a continuation.

Minimal fix

Compare FunctionCall items by call_id, name, and parsed arguments, keeping the byte comparison as fallback — about 15 lines in continuation.rs, no change to what is recorded or sent:

fn input_items_equivalent(a: &ResponsesInputItem, b: &ResponsesInputItem) -> bool {
    if let (
        ResponsesInputItem::FunctionCall { call_id: ca, name: na, arguments: aa },
        ResponsesInputItem::FunctionCall { call_id: cb, name: nb, arguments: ab },
    ) = (a, b)
    {
        if ca != cb || na != nb {
            return false;
        }
        if aa == ab {
            return true;
        }
        return matches!(
            (
                serde_json::from_str::<serde_json::Value>(aa),
                serde_json::from_str::<serde_json::Value>(ab),
            ),
            (Ok(x), Ok(y)) if x == y
        );
    }
    serde_json::to_value(a).unwrap_or_default() == serde_json::to_value(b).unwrap_or_default()
}

and use it in the loop of input_suffix_after_prefix. Alternative with the same effect: canonicalize arguments (parse → to_string, raw on parse failure) when the reducer captures the function_call at reducer.rs:314-317, since that transcript is only read by the continuation comparison and portable_summary_text.

Test: record a transcript whose last item is FunctionCall { arguments: "{\"b\":1,\"a\":2}" }, request the next turn with "{\"a\":2,\"b\":1}" followed by a function_call_output, and assert previous_response_id is Some with a one-item delta.

Proposed follow-up: I am preparing a PR for this (the comparison-site version, with the test above).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions