From e77773085cf770c89b9f0800220786c15f38d113 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 26 Aug 2026 19:30:06 +0000 Subject: [PATCH] Give Responses Lite prefix items stable IDs (#40962) ## Why Responses Lite rebuilds its tools and base-instruction prefix for each request. Stable item identities let follow-up WebSocket requests recognize that unchanged prefix and send only incremental input. ## What changed - Derive deterministic IDs for additional-tools and base-instruction items from the thread ID and each item's serialized payload. - Preserve IDs when rebuilding an unchanged request or resuming a session, while changing them when the thread or payload changes. ## Testing - Cover ID stability and payload/thread sensitivity in request construction. - Verify resumed Responses Lite sessions reuse prefix IDs and WebSocket follow-ups use `previous_response_id` with only new input items. GitOrigin-RevId: 3c8548b96979d7578865b0ccc51c147926823478 --- codex-rs/core/src/client.rs | 20 +++++- codex-rs/core/src/client_tests.rs | 65 +++++++++++++++++++ .../core/tests/suite/client_websockets.rs | 59 +++++++++++++++++ codex-rs/core/tests/suite/responses_lite.rs | 42 +++++++++--- 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 90b095aa39..19f3c4ec21 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -74,6 +74,7 @@ use codex_login::default_client::add_originator_header; use codex_login::default_client::create_client_for_route; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::ResponseItemId; use codex_protocol::auth::AuthMode; use codex_protocol::ThreadId; @@ -108,6 +109,7 @@ use tokio_util::sync::CancellationToken; use tracing::instrument; use tracing::trace; use tracing::warn; +use uuid::Uuid; use crate::attestation::AttestationContext; use crate::attestation::AttestationProvider; @@ -898,20 +900,34 @@ impl ModelClient { let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); let is_openai = self.state.provider.info().is_openai(); let (instructions, tools) = if model_info.use_responses_lite { + // These prompt-only items are rebuilt on every request. Hash their visible payloads + // within the thread so retries and resumed sessions preserve their identity. + let prefix_namespace = Uuid::new_v5( + &Uuid::NAMESPACE_OID, + self.state.thread_id.to_string().as_bytes(), + ); let tools = if self.state.provider.capabilities().namespace_tools { create_tools_json_for_responses_lite(&prompt.tools)? } else { create_tools_json_for_responses_api(&prompt.tools)? }; let mut prefix = vec![ResponseItem::AdditionalTools { - id: None, + id: Some(ResponseItemId::with_suffix( + "at", + Uuid::new_v5(&prefix_namespace, &serde_json::to_vec(&tools)?), + )), role: "developer".to_string(), tools, }]; if !prompt.base_instructions.text.is_empty() { - prefix.push(ContextualUserFragment::into(BaseInstructionsFragment( + let mut instructions = ContextualUserFragment::into(BaseInstructionsFragment( prompt.base_instructions.text.clone(), + )); + instructions.set_id(Some(ResponseItemId::with_suffix( + "msg", + Uuid::new_v5(&prefix_namespace, prompt.base_instructions.text.as_bytes()), ))); + prefix.push(instructions); } input.splice(0..0, prefix); (String::new(), None) diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 4389d83cee..f686257772 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -284,6 +284,71 @@ fn test_model_info() -> ModelInfo { .expect("deserialize test model info") } +#[test] +fn responses_lite_prefix_ids_track_thread_and_payload() -> anyhow::Result<()> { + let thread_id = ThreadId::new(); + let client = test_model_client_with_thread_id(thread_id, SessionSource::Cli); + let mut model = test_model_info(); + model.use_responses_lite = true; + let mut prompt = Prompt { + base_instructions: BaseInstructions { + text: "base instructions".to_string(), + provenance: None, + }, + ..Default::default() + }; + let build = |client: &ModelClient, prompt: &Prompt| { + client.build_responses_request( + prompt, + &model, + /*effort*/ None, + codex_protocol::config_types::ReasoningSummary::None, + /*service_tier*/ None, + &test_responses_metadata_for_client( + client, + /*turn_id*/ None, + format!("{}:0", client.state.thread_id), + /*parent_thread_id*/ None, + TestCodexResponsesRequestKind::Turn, + ), + ) + }; + + let original = build(&client, &prompt)?; + assert_eq!(build(&client, &prompt)?, original); + + prompt.base_instructions.text.push_str(" with an update"); + let changed_instructions = build(&client, &prompt)?; + assert_eq!(changed_instructions.input[0], original.input[0]); + assert_ne!(changed_instructions.input[1].id(), original.input[1].id()); + + prompt.tools = vec![codex_tools::ToolSpec::Freeform(codex_tools::FreeformTool { + name: "exec".to_string(), + description: "Execute JavaScript.".to_string(), + defer_loading: None, + format: codex_tools::FreeformToolFormat { + r#type: "grammar".to_string(), + syntax: "lark".to_string(), + definition: "start: /.+/".to_string(), + }, + })] + .into(); + let changed_tools = build(&client, &prompt)?; + assert_ne!( + changed_tools.input[0].id(), + changed_instructions.input[0].id() + ); + assert_eq!(changed_tools.input[1], changed_instructions.input[1]); + + let independent = build( + &test_model_client_with_thread_id(ThreadId::new(), SessionSource::Cli), + &prompt, + )?; + assert_ne!(independent.input[0].id(), changed_tools.input[0].id()); + assert_ne!(independent.input[1].id(), changed_tools.input[1].id()); + Ok(()) +} + fn test_session_telemetry() -> SessionTelemetry { SessionTelemetry::new( ThreadId::new(), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9d1c184e16..2d1253a04c 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1834,6 +1834,65 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_websocket_uses_incremental_create_on_prefix() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg_1", "assistant output"), + ev_completed("resp-1"), + ], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let mut harness = websocket_harness(&server).await; + harness.model_info.use_responses_lite = true; + let mut client_session = harness.client.new_session(); + let mut initial_item = message_item("hello"); + initial_item.set_id(Some(ResponseItemId::with_suffix("msg", "supplied"))); + let prompt_one = prompt_with_input_and_instructions(vec![initial_item.clone()], "base"); + let prompt_two = prompt_with_input_and_instructions( + vec![ + initial_item, + assistant_message_item("1", "assistant output"), + message_item("second"), + ], + "base", + ); + + stream_until_complete(&mut client_session, &harness, &prompt_one).await; + stream_until_complete(&mut client_session, &harness, &prompt_two).await; + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + let first = connection.first().expect("missing request").body_json(); + let second = connection.get(1).expect("missing request").body_json(); + let first_input = first["input"].as_array().expect("request input"); + + assert_eq!(first_input.len(), 3); + assert!( + first_input[0]["id"] + .as_str() + .is_some_and(|id| id.starts_with("at_")) + ); + assert!( + first_input[1]["id"] + .as_str() + .is_some_and(|id| id.starts_with("msg_")) + ); + assert_eq!(first_input[2]["id"], "msg_supplied"); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); + assert_eq!( + second["input"], + serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental items") + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 2636c24b1a..734104e230 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -102,16 +102,18 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< ) .await; - let mut builder = test_codex() - .with_model_info_override("gpt-5.4", |model_info| { - model_info.use_responses_lite = true; - model_info.tool_mode = Some(ToolMode::CodeMode); - }) - .with_config(|config| { - config.base_instructions = Some("test instructions".to_string()); - config.code_mode.disable_in_process_fallback = true; - }); - let test = builder.build(&server).await?; + let builder = || { + test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.tool_mode = Some(ToolMode::CodeMode); + }) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + config.code_mode.disable_in_process_fallback = true; + }) + }; + let test = builder().build(&server).await?; test.submit_turn("hello").await?; @@ -124,9 +126,20 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< .context("Responses request input should be an array")?; assert_eq!(input[0]["type"], "additional_tools"); assert_eq!(input[0]["role"], "developer"); + assert!( + input[0]["id"] + .as_str() + .is_some_and(|id| id.starts_with("at_")) + ); + assert!( + input[1]["id"] + .as_str() + .is_some_and(|id| id.starts_with("msg_")) + ); assert_eq!( input[1], serde_json::json!({ + "id": input[1]["id"], "type": "message", "role": "developer", "content": [{ @@ -164,6 +177,15 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result< assert!(turn_metadata.get("tool_namespaces_info").is_none()); + let followup = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("resp-2")]), + ) + .await; + let resumed = builder().restart(&server, &test).await?; + resumed.submit_turn("continue").await?; + assert_eq!(&followup.single_request().input()[..2], &input[..2]); + Ok(()) }