fix(codex): avoid replaying static config on websocket continuations

This commit is contained in:
ZheFox
2026-08-20 00:40:40 +08:00
parent 342f8b6a5f
commit d21d8ce9f5
2 changed files with 167 additions and 1 deletions
@@ -388,6 +388,54 @@ mod tests {
assert_eq!(normalized["previous_response_id"], "resp_123"); assert_eq!(normalized["previous_response_id"], "resp_123");
} }
#[test]
fn lite_continuations_forward_only_each_turns_incremental_input() {
let normalization = ResponsesWebSocketBodyNormalization::for_tests("gpt-5.6-sol")
.with_provider_type_for_tests("codex");
for turn in 1..=4 {
let event = json!({
"type": "response.create",
"model": "gpt-5.6-sol",
"previous_response_id": format!("resp_{turn}"),
"instructions": "The same large developer instructions.",
"tools": [{
"type": "function",
"name": "shell",
"parameters": {"type": "object"}
}],
"input": [
{
"type": "reasoning",
"id": format!("rs_{turn}"),
"content": [{
"type": "reasoning_text",
"text": format!("reasoning state {turn}")
}],
"encrypted_content": format!("opaque-{turn}")
},
{
"type": "function_call_output",
"call_id": format!("call_{turn}"),
"output": format!("result {turn}")
}
]
});
let normalized = normalized_continuation(&event, &normalization);
let input = normalized["input"].as_array().expect("incremental input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["type"], "reasoning");
assert_eq!(input[0]["content"][0]["type"], "reasoning_text");
assert_eq!(input[1]["type"], "function_call_output");
assert!(normalized.get("instructions").is_none());
assert!(normalized.get("tools").is_none());
assert!(!input.iter().any(|item| item["type"] == "additional_tools"));
assert!(!input.iter().any(|item| item["role"] == "developer"));
assert_eq!(normalized["previous_response_id"], format!("resp_{turn}"));
}
}
#[test] #[test]
fn continuation_keeps_a_warmup_generate_flag() { fn continuation_keeps_a_warmup_generate_flag() {
let event = json!({ let event = json!({
@@ -1345,6 +1345,7 @@ fn codex_responses_lite_supports_request_body(provider_request_body: Option<&Val
fn apply_codex_responses_lite_body_contract( fn apply_codex_responses_lite_body_contract(
body_object: &mut serde_json::Map<String, Value>, body_object: &mut serde_json::Map<String, Value>,
capabilities: &CodexResponsesModelCapabilities, capabilities: &CodexResponsesModelCapabilities,
websocket_continuation: bool,
) { ) {
if !capabilities.use_responses_lite { if !capabilities.use_responses_lite {
return; return;
@@ -1381,6 +1382,23 @@ fn apply_codex_responses_lite_body_contract(
.and_then(Value::as_array_mut) .and_then(Value::as_array_mut)
.expect("Responses Lite input was validated as an array"); .expect("Responses Lite input was validated as an array");
// `previous_response_id` already references the prior response's stored
// input. The Codex client sends only the new input items on these turns;
// replaying the current top-level tools/instructions as synthetic history
// would permanently append another copy on every tool round-trip. Keep
// the Lite wire requirements below, but do not synthesize static config
// for a continuation.
if websocket_continuation {
for item in input
.iter_mut()
.filter(|item| !is_codex_responses_lite_additional_tools_item(item))
{
strip_codex_responses_lite_image_details(item);
}
body_object.insert("parallel_tool_calls".to_string(), json!(false));
return;
}
let existing_additional_tools = input let existing_additional_tools = input
.iter() .iter()
.position(is_codex_responses_lite_additional_tools_item) .position(is_codex_responses_lite_additional_tools_item)
@@ -1710,8 +1728,21 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap
return; return;
}; };
// HTTP Responses bodies do not carry an event `type`. Preserve this
// marker only for an actual WebSocket response.create continuation so it
// survives both normalization/finalization passes. The framing layer owns
// the field and will forward it verbatim to the already-bound upstream.
let websocket_continuation = body_object.get("type").and_then(Value::as_str)
== Some("response.create")
&& body_object
.get("previous_response_id")
.is_some_and(|value| !value.is_null());
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format); wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS { for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if *field == "previous_response_id" && websocket_continuation {
continue;
}
if !body_rules_handle_path(body_rules, field) { if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field); body_object.remove(*field);
} }
@@ -1794,7 +1825,7 @@ pub fn apply_codex_openai_responses_special_body_edits_with_source_model_and_cap
capabilities, capabilities,
body_rules, body_rules,
); );
apply_codex_responses_lite_body_contract(body_object, capabilities); apply_codex_responses_lite_body_contract(body_object, capabilities, websocket_continuation);
strip_codex_cache_control_fields(provider_request_body); strip_codex_cache_control_fields(provider_request_body);
apply_codex_openai_responses_compact_body_edits( apply_codex_openai_responses_compact_body_edits(
provider_request_body, provider_request_body,
@@ -3195,6 +3226,93 @@ mod tests {
); );
} }
#[test]
fn codex_responses_lite_websocket_continuations_do_not_reappend_static_config() {
let incremental_input = json!([
{
"type": "reasoning",
"id": "rs_provider_123",
"content": [{
"type": "reasoning_text",
"text": "Pass the provider reasoning state through unchanged."
}],
"encrypted_content": "opaque-provider-state"
},
{
"type": "function_call_output",
"call_id": "call_123",
"output": "small result"
}
]);
let mut provider_request_body = json!({
"type": "response.create",
"model": "gpt-5.6-sol",
"previous_response_id": "resp_123",
"instructions": "Static developer instructions.",
"tools": [{
"type": "function",
"name": "lookup",
"parameters": {"type": "object"}
}],
"input": incremental_input.clone()
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert_eq!(provider_request_body["previous_response_id"], "resp_123");
assert!(provider_request_body.get("instructions").is_none());
assert!(provider_request_body.get("tools").is_none());
assert_eq!(provider_request_body["input"], incremental_input);
assert_eq!(provider_request_body["parallel_tool_calls"], false);
let once = provider_request_body.clone();
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert_eq!(provider_request_body, once);
}
#[test]
fn codex_responses_lite_null_previous_id_is_an_independent_turn() {
let mut provider_request_body = json!({
"type": "response.create",
"model": "gpt-5.6-sol",
"previous_response_id": null,
"instructions": "Fresh instructions.",
"tools": [{"type": "function", "name": "lookup", "parameters": {}}],
"input": [{"type": "message", "role": "user", "content": []}]
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert!(provider_request_body.get("previous_response_id").is_none());
assert_eq!(
provider_request_body["input"][0]["type"],
"additional_tools"
);
assert_eq!(provider_request_body["input"][1]["role"], "developer");
assert_eq!(
provider_request_body["input"][1]["content"][0]["text"],
"Fresh instructions."
);
}
#[test] #[test]
fn codex_responses_body_edits_strip_name_from_hosted_web_search_tool() { fn codex_responses_body_edits_strip_name_from_hosted_web_search_tool() {
let mut provider_request_body = json!({ let mut provider_request_body = json!({