(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
CCP_CODEX_PREVIOUS_RESPONSE_ID=1 CCP_TRAFFIC_LOG=1 claude-code-proxy and point Claude Code at it with a Codex model.
- 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).
- 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"}
- 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.created → response.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:
src/providers/codex/translate/reducer.rs:312-318 — capture_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).
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.
src/providers/codex/continuation.rs:532-547 — input_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).
(all file:line references are against
main= dc61029, v0.1.35)Symptom
With
CCP_CODEX_PREVIOUS_RESPONSE_ID=1, plain text turns reuseprevious_response_idand send only the input delta, but the turn that follows a tool call frequently falls back to a full-context request (hasPreviousResponseId: false,inputDeltaCount: nullincodex_upstream_request_started, reasonnot_append_onlyinternally). In an agentic session almost every turn is a tool turn, so the feature mostly degrades to "full history re-upload on most turns". Becauseshould_reset_websocket_pool(src/providers/codex/client.rs:3002-3011) treats every non-disabledreason as a reset, each of these misses also tears down the pooled WebSocket (separate issue, will cross-reference).Reproduction
CCP_CODEX_PREVIOUS_RESPONSE_ID=1 CCP_TRAFFIC_LOG=1 claude-code-proxyand point Claude Code at it with a Codex model.Edit(any tool whose arguments have more than one key works;Bashwith onlycommanddoes not trigger it).response.output_item.donefor the function_call —argumentsas the model streamed it, e.g.{"file_path":"/tmp/x","old_string":"a","new_string":"b"}function_callitem ininput— re-serialized by the proxy:{"file_path":"/tmp/x","new_string":"b","old_string":"a"}previous_response_idand carries the fullinput; turn N+2 (after a text-only answer) reuses it again.Event sequence for turn N (sanitized):
response.created→response.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:
src/providers/codex/translate/reducer.rs:312-318—capture_output_itemstores the function_call into the continuation transcript witharguments: args_accum.clone(), i.e. the raw text the model streamed (sanitize_read_argsonly rewrites it for the abnormalReadoffset case).src/providers/codex/translate/request.rs:893-899— on the next turn, Claude Code'stool_use.input(aserde_json::Value,translate_shared.rs:13-17) becomesarguments: serde_json::to_string(input).serde_jsonis used withoutpreserve_order(Cargo.toml:13, andserde_jsonhas noindexmapdependency inCargo.lock), soValue::Objectis aBTreeMapand keys come out sorted and compact.src/providers/codex/continuation.rs:532-547—input_suffix_after_prefixcompares each item viaserde_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→ noprevious_response_id.call_idandnameare the same on both sides;Reasoningitems are built identically on both sides (reducer.rs:162-168vsrequest.rsthinking branch), soargumentsis 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.rsandsmoke_codex_websocket_previous_response_id_sends_delta_on_second_turnonly exercise text turns; there is no test that records a function_call and then asks for a continuation.Minimal fix
Compare
FunctionCallitems bycall_id,name, and parsed arguments, keeping the byte comparison as fallback — about 15 lines incontinuation.rs, no change to what is recorded or sent:and use it in the loop of
input_suffix_after_prefix. Alternative with the same effect: canonicalizearguments(parse →to_string, raw on parse failure) when the reducer captures the function_call atreducer.rs:314-317, since that transcript is only read by the continuation comparison andportable_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 afunction_call_output, and assertprevious_response_idisSomewith a one-item delta.Proposed follow-up: I am preparing a PR for this (the comparison-site version, with the test above).