From 009bd870cd794c4c867576d306717b6a5ca70857 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 17 Jun 2026 16:43:53 -0700 Subject: [PATCH 01/14] assign response item IDs when recording history --- .../schema/json/ClientRequest.json | 80 ++++++++++++- .../codex_app_server_protocol.schemas.json | 80 ++++++++++++- .../codex_app_server_protocol.v2.schemas.json | 80 ++++++++++++- .../RawResponseItemCompletedNotification.json | 80 ++++++++++++- .../schema/json/v2/ThreadResumeParams.json | 80 ++++++++++++- .../schema/typescript/ResponseItem.ts | 10 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/compact_remote_v2.rs | 5 +- codex-rs/core/src/session/mod.rs | 39 ++++++- codex-rs/core/src/session/tests.rs | 18 ++- codex-rs/core/tests/suite/client.rs | 78 +++++++++++++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/protocol/src/models.rs | 108 ++++++------------ 13 files changed, 575 insertions(+), 97 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 749899498067..7c2c5f55649d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2289,6 +2289,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2339,6 +2345,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2387,6 +2399,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2430,6 +2448,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2467,6 +2492,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2515,6 +2546,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2552,6 +2589,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2586,6 +2629,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -2630,6 +2679,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2676,6 +2731,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2722,6 +2783,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2755,7 +2822,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -2804,6 +2870,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -2863,6 +2935,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5b0c59c42555..b0e5972e7679 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14791,6 +14791,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14841,6 +14847,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14889,6 +14901,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14932,6 +14950,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -14969,6 +14994,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15017,6 +15048,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15054,6 +15091,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15088,6 +15131,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -15132,6 +15181,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15178,6 +15233,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15224,6 +15285,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15257,7 +15324,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -15306,6 +15372,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -15365,6 +15437,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 45cfbd95c21f..c2f0d7e5d4ae 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11236,6 +11236,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11286,6 +11292,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11334,6 +11346,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11377,6 +11395,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11414,6 +11439,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11462,6 +11493,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11499,6 +11536,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11533,6 +11576,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -11577,6 +11626,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11623,6 +11678,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11669,6 +11730,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11702,7 +11769,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -11751,6 +11817,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -11810,6 +11882,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f77a68dc1d71..22385f816f87 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -377,6 +377,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -427,6 +433,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -475,6 +487,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -518,6 +536,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -555,6 +580,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -603,6 +634,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -640,6 +677,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -674,6 +717,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -718,6 +767,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -764,6 +819,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -810,6 +871,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -843,7 +910,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -892,6 +958,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -951,6 +1023,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index e09dd2c6fc75..528076c6ae28 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -448,6 +448,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -498,6 +504,12 @@ }, "type": "array" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -546,6 +558,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -589,6 +607,13 @@ "null" ] }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -626,6 +651,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -674,6 +705,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -711,6 +748,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -745,6 +788,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "input": { "type": "string" }, @@ -789,6 +838,12 @@ "call_id": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -835,6 +890,12 @@ "execution": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -881,6 +942,12 @@ } ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -914,7 +981,6 @@ { "properties": { "id": { - "description": "Existing provider ID retained on serialized history for compatibility.", "type": [ "string", "null" @@ -963,6 +1029,12 @@ "encrypted_content": { "type": "string" }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { @@ -1022,6 +1094,12 @@ "null" ] }, + "id": { + "type": [ + "string", + "null" + ] + }, "metadata": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index f0cd1c8ee661..fc399b925f59 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -12,12 +12,12 @@ import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSumm import type { ResponseItemMetadata } from "./ResponseItemMetadata"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", /** - * Set when using the Responses API. + * Legacy id field retained for compatibility with older payloads. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", +id?: string, /** - * Existing provider ID retained on serialized history for compatibility. + * Set when using the Responses API. */ -id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", id?: string, encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", id?: string, encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9944c2994cd2..19c6f1a56539 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -523,6 +523,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, @@ -4684,6 +4687,9 @@ "in_app_browser": { "type": "boolean" }, + "item_ids": { + "type": "boolean" + }, "js_repl": { "type": "boolean" }, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6c3a939024a6..3b79789c02ba 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -231,10 +231,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }); + input.push(ResponseItem::CompactionTrigger { metadata: None }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cf191235919d..e9fa300c1f8e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2634,17 +2634,46 @@ impl Session { turn_context: &TurnContext, items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { - if !turn_context + let mut items = Cow::Borrowed(items); + if turn_context .config .features .enabled(Feature::ResizeAllImages) { - return Cow::Borrowed(items); + prepare_response_items(items.to_mut()); + } + if turn_context.config.features.enabled(Feature::ItemIds) { + Self::assign_missing_response_item_ids(&mut items); } + items + } - let mut prepared_items = items.to_vec(); - prepare_response_items(&mut prepared_items); - Cow::Owned(prepared_items) + fn assign_missing_response_item_ids(items: &mut Cow<'_, [ResponseItem]>) { + if items.iter().all(|item| item.id().is_some()) { + return; + } + for item in items.to_mut() { + if item.id().is_some() { + continue; + } + let prefix = match item { + ResponseItem::Message { .. } => "msg", + ResponseItem::AgentMessage { .. } => "amsg", + ResponseItem::Reasoning { .. } => "rs", + ResponseItem::LocalShellCall { .. } => "lsh", + ResponseItem::FunctionCall { .. } => "fc", + ResponseItem::ToolSearchCall { .. } => "tsc", + ResponseItem::FunctionCallOutput { .. } => "fco", + ResponseItem::CustomToolCall { .. } => "ctc", + ResponseItem::CustomToolCallOutput { .. } => "ctco", + ResponseItem::ToolSearchOutput { .. } => "tso", + ResponseItem::WebSearchCall { .. } => "ws", + ResponseItem::ImageGenerationCall { .. } => "ig", + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp", + ResponseItem::CompactionTrigger { .. } | ResponseItem::Other => continue, + }; + item.set_id(format!("{prefix}_{}", Uuid::now_v7())); + } } pub(crate) fn response_item_from_user_input( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 724c35f2e815..d0b7149d87d7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1660,6 +1660,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { Vec::new(), |config| { let _ = config.features.enable(Feature::ResizeAllImages); + let _ = config.features.enable(Feature::ItemIds); }, ) .await; @@ -1689,8 +1690,18 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { .record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&item)) .await; + let history = session.state.lock().await.clone_history(); + let id = history.raw_items()[0] + .id() + .expect("history item should have an ID") + .to_string(); + let uuid = id + .strip_prefix("fco_") + .expect("function call output ID should have the Responses API prefix"); + let parsed_id = Uuid::parse_str(uuid).expect("history item should have a UUID ID"); + assert_eq!(parsed_id.get_version(), Some(uuid::Version::SortRand)); let expected = vec![ResponseItem::FunctionCallOutput { - id: None, + id: Some(id), call_id: "call-1".to_string(), output: FunctionCallOutputPayload { body: FunctionCallOutputBody::ContentItems(vec![ @@ -1709,10 +1720,7 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { }, metadata: None, }]; - assert_eq!( - session.state.lock().await.clone_history().raw_items(), - expected.as_slice() - ); + assert_eq!(history.raw_items(), expected.as_slice()); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 54e24d49cbe8..357e6c429d82 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -134,6 +134,22 @@ fn message_input_text_contains(request: &ResponsesRequest, role: &str, needle: & .any(|text| text.contains(needle)) } +fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) -> String { + request + .inputs_of_type("message") + .into_iter() + .find(|item| { + item.get("role").and_then(serde_json::Value::as_str) == Some(role) + && message_input_texts(item).contains(&text) + }) + .and_then(|item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}")) +} + fn assert_codex_client_metadata( request_body: &serde_json::Value, installation_id: &str, @@ -216,9 +232,71 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { item.get("metadata").is_none(), "input item should omit metadata: {item}" ); + assert!( + item.get("id").is_none(), + "input item should omit generated IDs: {item}" + ); } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg_server", "first reply"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let initial = builder.build(&server).await?; + let home = Arc::clone(&initial.home); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial.submit_turn("before resume").await?; + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed.submit_turn("after resume").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let user_id = response_message_item_id(&requests[0], "user", "before resume"); + let user_uuid = user_id + .strip_prefix("msg_") + .expect("message ID should have the Responses API prefix"); + assert_eq!( + Uuid::parse_str(user_uuid)?.get_version(), + Some(uuid::Version::SortRand) + ); + assert_eq!( + response_message_item_id(&requests[1], "user", "before resume"), + user_id + ); + assert_eq!( + response_message_item_id(&requests[1], "assistant", "first reply"), + "msg_server" + ); + + Ok(()) +} + /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b887237a18cb..1267239cecd9 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -189,6 +189,8 @@ pub enum Feature { ImageGenExt, /// Resize all inline data-URL images before recording them in history. ResizeAllImages, + /// Generate Responses API item IDs for client-created history items. + ItemIds, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -1113,6 +1115,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ItemIds, + key: "item_ids", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 3c0753cb43ab..ead2d5330b14 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -918,9 +918,8 @@ pub struct ResponseItemMetadata { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, role: String, content: Vec, @@ -935,9 +934,8 @@ pub enum ResponseItem { metadata: Option, }, AgentMessage { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, author: String, recipient: String, @@ -947,9 +945,8 @@ pub enum ResponseItem { metadata: Option, }, Reasoning { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, summary: Vec, #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")] @@ -962,9 +959,8 @@ pub enum ResponseItem { }, LocalShellCall { /// Legacy id field retained for compatibility with older payloads. - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, /// Set when using the Responses API. call_id: Option, @@ -975,9 +971,8 @@ pub enum ResponseItem { metadata: Option, }, FunctionCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -993,9 +988,8 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1014,9 +1008,8 @@ pub enum ResponseItem { // - an array of structured content items (`content_items`) // We keep this behavior centralized in `FunctionCallOutputPayload`. FunctionCallOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: String, #[ts(as = "FunctionCallOutputBody")] @@ -1027,9 +1020,8 @@ pub enum ResponseItem { metadata: Option, }, CustomToolCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1046,9 +1038,8 @@ pub enum ResponseItem { // `function_call_output.output` so freeform tools can return either plain // text or structured content items. CustomToolCallOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1062,9 +1053,8 @@ pub enum ResponseItem { metadata: Option, }, ToolSearchOutput { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, call_id: Option, status: String, @@ -1084,9 +1074,8 @@ pub enum ResponseItem { // "action": {"type":"search","query":"weather: San Francisco, CA"} // } WebSearchCall { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1108,7 +1097,6 @@ pub enum ResponseItem { // "result":"..." // } ImageGenerationCall { - /// Existing provider ID retained on serialized history for compatibility. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] id: Option, @@ -1123,28 +1111,24 @@ pub enum ResponseItem { }, #[serde(alias = "compaction_summary")] Compaction { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, + // Compaction triggers are request controls, and the Responses API does not + // accept an `id` field for them. CompactionTrigger { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] - id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] metadata: Option, }, ContextCompaction { - #[serde(default, skip_serializing)] - #[ts(skip)] - #[schemars(skip)] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1179,9 +1163,8 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::CompactionTrigger { id, .. } | Self::ContextCompaction { id, .. } => id.as_deref().filter(|id| !id.is_empty()), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } @@ -1201,9 +1184,8 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::CompactionTrigger { id, .. } | Self::ContextCompaction { id, .. } => *id = Some(new_id), - Self::Other => {} + Self::CompactionTrigger { .. } | Self::Other => {} } } @@ -3022,10 +3004,7 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }; + let item = ResponseItem::CompactionTrigger { metadata: None }; assert_eq!( serde_json::to_value(item)?, @@ -3038,10 +3017,7 @@ mod tests { #[test] fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { - id: None, - metadata: None, - }; + let mut item = ResponseItem::CompactionTrigger { metadata: None }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( @@ -3062,13 +3038,7 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!( - item, - ResponseItem::CompactionTrigger { - id: None, - metadata: None, - } - ); + assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); Ok(()) } @@ -3109,7 +3079,6 @@ mod tests { queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]), }), Some("completed".into()), - true, ), ( r#"{ @@ -3125,7 +3094,6 @@ mod tests { url: Some("https://example.com".into()), }), Some("open".into()), - true, ), ( r#"{ @@ -3143,7 +3111,6 @@ mod tests { pattern: Some("installation".into()), }), Some("in_progress".into()), - true, ), ( r#"{ @@ -3154,12 +3121,10 @@ mod tests { Some("ws_partial".into()), None, Some("in_progress".into()), - false, ), ]; - for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases - { + for (json_literal, expected_id, expected_action, expected_status) in cases { let parsed: ResponseItem = serde_json::from_str(json_literal)?; let expected = ResponseItem::WebSearchCall { id: expected_id.clone(), @@ -3170,10 +3135,7 @@ mod tests { assert_eq!(parsed, expected); let serialized = serde_json::to_value(&parsed)?; - let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; - if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() { - obj.remove("id"); - } + let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; assert_eq!(serialized, expected_serialized); } From c2b77d7d580cda225c35b73f0b73689cad7c8d6f Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 11:01:38 -0700 Subject: [PATCH 02/14] codex: address PR review feedback (#28814) --- codex-rs/codex-api/src/endpoint/responses.rs | 19 ++- codex-rs/codex-api/tests/clients.rs | 22 +++- codex-rs/core/src/client.rs | 11 +- codex-rs/core/src/client_common.rs | 4 + codex-rs/core/src/compact.rs | 2 + codex-rs/core/src/compact_remote.rs | 2 + codex-rs/core/src/compact_remote_v2.rs | 2 + codex-rs/core/src/session/mod.rs | 3 +- codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/tests/suite/client.rs | 121 ++++++++++++++++++ .../core/tests/suite/client_websockets.rs | 29 +++++ codex-rs/protocol/src/models.rs | 21 +++ codex-rs/protocol/src/protocol.rs | 8 +- codex-rs/rollout-trace/src/inference.rs | 1 + 14 files changed, 237 insertions(+), 9 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index ad79adf33dff..8d120618f232 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -37,6 +37,7 @@ pub struct ResponsesOptions { pub extra_headers: HeaderMap, pub compression: Compression, pub turn_state: Option>>, + pub include_item_ids: bool, } impl ResponsesClient { @@ -80,13 +81,27 @@ impl ResponsesClient { extra_headers, compression, turn_state, + include_item_ids, } = options; - let body = if request.store && self.session.provider().is_azure_responses_endpoint() { + let attach_azure_item_ids = + request.store && self.session.provider().is_azure_responses_endpoint(); + let body = if !include_item_ids || attach_azure_item_ids { let mut body = serde_json::to_value(&request).map_err(|e| { ApiError::Stream(format!("failed to encode responses request: {e}")) })?; - attach_item_ids(&mut body, &request.input); + if !include_item_ids + && let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) + { + for item in items { + if let Some(item) = item.as_object_mut() { + item.remove("id"); + } + } + } + if attach_azure_item_ids { + attach_item_ids(&mut body, &request.input); + } EncodedJsonBody::encode(&body) } else { EncodedJsonBody::encode(&request) diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index d8489ed7487f..824f60d04bd3 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> { +async fn responses_client_stream_request_gates_item_ids() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -330,12 +330,25 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( let expected = serde_json::to_vec(&request)?; let _stream = client - .stream_request(request, ResponsesOptions::default()) + .stream_request(request.clone(), ResponsesOptions::default()) + .await?; + let _stream = client + .stream_request( + request, + ResponsesOptions { + include_item_ids: true, + ..Default::default() + }, + ) .await?; let requests = state.take_stream_requests(); - assert_eq!(requests.len(), 1); - let prepared = requests[0] + assert_eq!(requests.len(), 2); + let body_without_ids: serde_json::Value = + serde_json::from_slice(request_body_bytes(&requests[0]))?; + assert_eq!(body_without_ids["input"][0].get("id"), None); + + let prepared = requests[1] .prepare_body_for_send() .expect("body should prepare"); assert_eq!(prepared.body.as_deref(), Some(expected.as_slice())); @@ -542,6 +555,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { extra_headers, compression: Compression::None, turn_state: None, + include_item_ids: false, }, ) .await?; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4ea8c0ff1fb8..04e45475bca3 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -508,7 +508,7 @@ impl ModelClient { RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), self.state.auth_env_telemetry.clone(), ); - let request = self.build_responses_request( + let mut request = self.build_responses_request( &client_setup.api_provider, prompt, model_info, @@ -517,6 +517,9 @@ impl ModelClient { settings.service_tier, responses_metadata, )?; + if !prompt.include_item_ids { + request.input.iter_mut().for_each(ResponseItem::clear_id); + } let ResponsesApiRequest { model, instructions, @@ -1033,6 +1036,7 @@ impl ModelClientSession { }, compression, turn_state: Some(Arc::clone(&self.turn_state)), + include_item_ids: false, } } @@ -1298,6 +1302,7 @@ impl ModelClientSession { model_info.use_responses_lite, ) .await; + options.include_item_ids = prompt.include_item_ids; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1467,6 +1472,10 @@ impl ModelClientSession { let (mut ws_request, previous_response_id_from_untraced_warmup) = self.prepare_websocket_request(ws_payload, &request); + if !prompt.include_item_ids { + let ResponsesWsRequest::ResponseCreate(payload) = &mut ws_request; + payload.input.iter_mut().for_each(ResponseItem::clear_id); + } let inference_trace_attempt = if warmup { // Prewarm sends `generate=false`; it is connection setup, not a // model inference attempt that should appear in rollout traces. diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e0c2ff03c302..41edf4add8f4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -19,6 +19,9 @@ pub struct Prompt { /// Conversation context input items. pub input: Vec, + /// Whether Responses API item IDs should be included on the wire. + pub include_item_ids: bool, + /// Tools available to the model, including additional tools sourced from /// external MCP servers. pub(crate) tools: Vec, @@ -39,6 +42,7 @@ impl Default for Prompt { fn default() -> Self { Self { input: Vec::new(), + include_item_ids: false, tools: Vec::new(), parallel_tool_calls: false, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d79138e87ce..943b4a50f9a3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -25,6 +25,7 @@ use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_analytics::now_unix_seconds; +use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -238,6 +239,7 @@ async fn run_compact_task_inner_impl( let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, + include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), base_instructions: sess.get_base_instructions().await, ..Default::default() }; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c88d8062d602..115106b936b3 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -23,6 +23,7 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; use codex_app_server_protocol::AuthMode; +use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -225,6 +226,7 @@ async fn run_remote_compact_task_inner_impl( .await?; let prompt = Prompt { input: prompt_input, + include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 3b79789c02ba..864a814ef626 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -27,6 +27,7 @@ use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; +use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -234,6 +235,7 @@ async fn run_remote_compact_task_inner_impl( input.push(ResponseItem::CompactionTrigger { metadata: None }); let prompt = Prompt { input, + include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e9fa300c1f8e..f91ccbd09402 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2717,7 +2717,7 @@ impl Session { pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, - communication: InterAgentCommunication, + mut communication: InterAgentCommunication, ) { let response_item = communication.to_model_input_item(); let items = self.prepare_conversation_items_for_history( @@ -2725,6 +2725,7 @@ impl Session { std::slice::from_ref(&response_item), ); let items = items.as_ref(); + communication.id = items.first().and_then(ResponseItem::id).map(str::to_owned); { let mut state = self.state.lock().await; state.record_items( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4beef792bb30..31b5671231fc 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1026,6 +1026,7 @@ pub(crate) fn build_prompt( ) -> Prompt { Prompt { input, + include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 357e6c429d82..95a285015197 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -19,6 +19,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -42,6 +43,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -150,6 +152,19 @@ fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) .unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}")) } +fn response_agent_message_item_id(request: &ResponsesRequest, text: &str) -> String { + request + .inputs_of_type("agent_message") + .into_iter() + .find(|item| message_input_texts(item).contains(&text)) + .and_then(|item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| panic!("missing item ID for agent message {text:?}")) +} + fn assert_codex_client_metadata( request_body: &serde_json::Value, installation_id: &str, @@ -239,6 +254,38 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_item_ids_are_omitted_when_feature_is_disabled() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg_server", "first reply"), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex().build(&server).await?; + + test.submit_turn("first turn").await?; + test.submit_turn("second turn").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + for item in requests[1].input() { + assert!( + item.get("id").is_none(), + "input item should omit IDs while the feature is disabled: {item}" + ); + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> { let server = MockServer::start().await; @@ -272,6 +319,9 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an }) .await; + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); let resumed = builder.resume(&server, home, rollout_path).await?; resumed.submit_turn("after resume").await?; @@ -297,6 +347,77 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inter_agent_response_item_ids_persist_across_resume() -> anyhow::Result<()> { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-mail"), + ev_completed("resp-mail"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let initial = builder.build(&server).await?; + let home = Arc::clone(&initial.home); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + initial + .codex + .submit(Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "child update".to_string(), + /*trigger_turn*/ true, + ), + }) + .await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); + let resumed = builder.resume(&server, home, rollout_path).await?; + resumed.submit_turn("after resume").await?; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let agent_message_id = response_agent_message_item_id(&requests[0], "child update"); + let agent_message_uuid = agent_message_id + .strip_prefix("amsg_") + .expect("agent message ID should have the Responses API prefix"); + assert_eq!( + Uuid::parse_str(agent_message_uuid)?.get_version(), + Some(uuid::Version::SortRand) + ); + assert_eq!( + response_agent_message_item_id(&requests[1], "child update"), + agent_message_id + ); + + Ok(()) +} + /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cee54e24b310..223477034975 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1513,6 +1513,35 @@ async fn responses_websocket_connection_limit_error_reconnects_and_completes() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_gates_item_ids() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut client_session = harness.client.new_session(); + let prompt_without_ids = prompt_with_input(vec![assistant_message_item("msg-1", "first")]); + let mut prompt_with_ids = prompt_with_input(vec![assistant_message_item("msg-2", "second")]); + prompt_with_ids.include_item_ids = true; + + stream_until_complete(&mut client_session, &harness, &prompt_without_ids).await; + stream_until_complete(&mut client_session, &harness, &prompt_with_ids).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(); + assert_eq!(first["input"][0].get("id"), None); + assert_eq!(second["input"][0]["id"].as_str(), Some("msg-2")); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_uses_incremental_create_on_prefix() { skip_if_no_network!(); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ead2d5330b14..6911e65846c7 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1189,6 +1189,27 @@ impl ResponseItem { } } + /// Clears the Responses API item ID for variants that carry one. + pub fn clear_id(&mut self) { + match self { + Self::Message { id, .. } + | Self::AgentMessage { id, .. } + | Self::LocalShellCall { id, .. } + | Self::FunctionCall { id, .. } + | Self::ToolSearchCall { id, .. } + | Self::FunctionCallOutput { id, .. } + | Self::CustomToolCall { id, .. } + | Self::CustomToolCallOutput { id, .. } + | Self::ToolSearchOutput { id, .. } + | Self::WebSearchCall { id, .. } + | Self::Reasoning { id, .. } + | Self::ImageGenerationCall { id, .. } + | Self::Compaction { id, .. } + | Self::ContextCompaction { id, .. } => *id = None, + Self::CompactionTrigger { .. } | Self::Other => {} + } + } + /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { self.metadata() diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a0e5b24d4704..341f56049e9c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -676,6 +676,9 @@ impl From> for Op { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct InterAgentCommunication { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub id: Option, pub author: AgentPath, pub recipient: AgentPath, #[serde(default)] @@ -699,6 +702,7 @@ impl InterAgentCommunication { trigger_turn: bool, ) -> Self { Self { + id: None, author, recipient, other_recipients, @@ -717,6 +721,7 @@ impl InterAgentCommunication { trigger_turn: bool, ) -> Self { Self { + id: None, author, recipient, other_recipients, @@ -762,7 +767,7 @@ impl InterAgentCommunication { }], }; ResponseItem::AgentMessage { - id: None, + id: self.id.clone(), author: self.author.to_string(), recipient: self.recipient.to_string(), content, @@ -4251,6 +4256,7 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { let communication = InterAgentCommunication { + id: None, author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 7329fd3cf219..7afdcf584727 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -515,6 +515,7 @@ mod tests { traced, json!({ "type": "reasoning", + "id": "rs-1", "summary": [{"type": "summary_text", "text": "summary"}], "content": [{"type": "text", "text": "raw reasoning"}], "encrypted_content": "encoded", From 9d198cf1938a24412d6388f1f90afd09266c9fd5 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 11:13:33 -0700 Subject: [PATCH 03/14] codex: preserve legacy image generation IDs (#28814) --- codex-rs/codex-api/src/endpoint/responses.rs | 25 +++++++++----------- codex-rs/core/src/client.rs | 10 ++++++-- codex-rs/protocol/src/models.rs | 9 +++---- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 8d120618f232..ea68acaf1f82 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -15,6 +15,7 @@ use codex_client::EncodedJsonBody; use codex_client::HttpTransport; use codex_client::RequestCompression; use codex_client::RequestTelemetry; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; @@ -71,7 +72,7 @@ impl ResponsesClient { )] pub async fn stream_request( &self, - request: ResponsesApiRequest, + mut request: ResponsesApiRequest, options: ResponsesOptions, ) -> Result { let ResponsesOptions { @@ -86,22 +87,18 @@ impl ResponsesClient { let attach_azure_item_ids = request.store && self.session.provider().is_azure_responses_endpoint(); - let body = if !include_item_ids || attach_azure_item_ids { + let azure_item_ids = attach_azure_item_ids.then(|| request.input.clone()); + if !include_item_ids { + request + .input + .iter_mut() + .for_each(ResponseItem::clear_feature_gated_id); + } + let body = if let Some(azure_item_ids) = azure_item_ids { let mut body = serde_json::to_value(&request).map_err(|e| { ApiError::Stream(format!("failed to encode responses request: {e}")) })?; - if !include_item_ids - && let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) - { - for item in items { - if let Some(item) = item.as_object_mut() { - item.remove("id"); - } - } - } - if attach_azure_item_ids { - attach_item_ids(&mut body, &request.input); - } + attach_item_ids(&mut body, &azure_item_ids); EncodedJsonBody::encode(&body) } else { EncodedJsonBody::encode(&request) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 04e45475bca3..3c10210553e4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -518,7 +518,10 @@ impl ModelClient { responses_metadata, )?; if !prompt.include_item_ids { - request.input.iter_mut().for_each(ResponseItem::clear_id); + request + .input + .iter_mut() + .for_each(ResponseItem::clear_feature_gated_id); } let ResponsesApiRequest { model, @@ -1474,7 +1477,10 @@ impl ModelClientSession { self.prepare_websocket_request(ws_payload, &request); if !prompt.include_item_ids { let ResponsesWsRequest::ResponseCreate(payload) = &mut ws_request; - payload.input.iter_mut().for_each(ResponseItem::clear_id); + payload + .input + .iter_mut() + .for_each(ResponseItem::clear_feature_gated_id); } let inference_trace_attempt = if warmup { // Prewarm sends `generate=false`; it is connection setup, not a diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6911e65846c7..194a1ceef9ff 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1189,8 +1189,10 @@ impl ResponseItem { } } - /// Clears the Responses API item ID for variants that carry one. - pub fn clear_id(&mut self) { + /// Clears IDs whose serialization is controlled by the `item_ids` feature. + /// + /// Image generation call IDs predate that feature and remain serialized for compatibility. + pub fn clear_feature_gated_id(&mut self) { match self { Self::Message { id, .. } | Self::AgentMessage { id, .. } @@ -1203,10 +1205,9 @@ impl ResponseItem { | Self::ToolSearchOutput { id, .. } | Self::WebSearchCall { id, .. } | Self::Reasoning { id, .. } - | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } | Self::ContextCompaction { id, .. } => *id = None, - Self::CompactionTrigger { .. } | Self::Other => {} + Self::ImageGenerationCall { .. } | Self::CompactionTrigger { .. } | Self::Other => {} } } From f2e69e58fa468c3ba570bdbecb796e787626c233 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 11:28:46 -0700 Subject: [PATCH 04/14] codex: keep item ID serialization unconditional (#28814) --- codex-rs/codex-api/src/endpoint/responses.rs | 18 ++------- codex-rs/codex-api/tests/clients.rs | 22 ++--------- codex-rs/core/src/client.rs | 17 +-------- codex-rs/core/src/client_common.rs | 4 -- codex-rs/core/src/compact.rs | 2 - codex-rs/core/src/compact_remote.rs | 2 - codex-rs/core/src/compact_remote_v2.rs | 2 - codex-rs/core/src/session/turn.rs | 1 - codex-rs/core/tests/suite/client.rs | 38 ------------------- .../core/tests/suite/client_websockets.rs | 29 -------------- codex-rs/protocol/src/models.rs | 22 ----------- 11 files changed, 8 insertions(+), 149 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index ea68acaf1f82..ad79adf33dff 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -15,7 +15,6 @@ use codex_client::EncodedJsonBody; use codex_client::HttpTransport; use codex_client::RequestCompression; use codex_client::RequestTelemetry; -use codex_protocol::models::ResponseItem; use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; @@ -38,7 +37,6 @@ pub struct ResponsesOptions { pub extra_headers: HeaderMap, pub compression: Compression, pub turn_state: Option>>, - pub include_item_ids: bool, } impl ResponsesClient { @@ -72,7 +70,7 @@ impl ResponsesClient { )] pub async fn stream_request( &self, - mut request: ResponsesApiRequest, + request: ResponsesApiRequest, options: ResponsesOptions, ) -> Result { let ResponsesOptions { @@ -82,23 +80,13 @@ impl ResponsesClient { extra_headers, compression, turn_state, - include_item_ids, } = options; - let attach_azure_item_ids = - request.store && self.session.provider().is_azure_responses_endpoint(); - let azure_item_ids = attach_azure_item_ids.then(|| request.input.clone()); - if !include_item_ids { - request - .input - .iter_mut() - .for_each(ResponseItem::clear_feature_gated_id); - } - let body = if let Some(azure_item_ids) = azure_item_ids { + let body = if request.store && self.session.provider().is_azure_responses_endpoint() { let mut body = serde_json::to_value(&request).map_err(|e| { ApiError::Stream(format!("failed to encode responses request: {e}")) })?; - attach_item_ids(&mut body, &azure_item_ids); + attach_item_ids(&mut body, &request.input); EncodedJsonBody::encode(&body) } else { EncodedJsonBody::encode(&request) diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 824f60d04bd3..d8489ed7487f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_gates_item_ids() -> Result<()> { +async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -330,25 +330,12 @@ async fn responses_client_stream_request_gates_item_ids() -> Result<()> { let expected = serde_json::to_vec(&request)?; let _stream = client - .stream_request(request.clone(), ResponsesOptions::default()) - .await?; - let _stream = client - .stream_request( - request, - ResponsesOptions { - include_item_ids: true, - ..Default::default() - }, - ) + .stream_request(request, ResponsesOptions::default()) .await?; let requests = state.take_stream_requests(); - assert_eq!(requests.len(), 2); - let body_without_ids: serde_json::Value = - serde_json::from_slice(request_body_bytes(&requests[0]))?; - assert_eq!(body_without_ids["input"][0].get("id"), None); - - let prepared = requests[1] + assert_eq!(requests.len(), 1); + let prepared = requests[0] .prepare_body_for_send() .expect("body should prepare"); assert_eq!(prepared.body.as_deref(), Some(expected.as_slice())); @@ -555,7 +542,6 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { extra_headers, compression: Compression::None, turn_state: None, - include_item_ids: false, }, ) .await?; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3c10210553e4..4ea8c0ff1fb8 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -508,7 +508,7 @@ impl ModelClient { RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), self.state.auth_env_telemetry.clone(), ); - let mut request = self.build_responses_request( + let request = self.build_responses_request( &client_setup.api_provider, prompt, model_info, @@ -517,12 +517,6 @@ impl ModelClient { settings.service_tier, responses_metadata, )?; - if !prompt.include_item_ids { - request - .input - .iter_mut() - .for_each(ResponseItem::clear_feature_gated_id); - } let ResponsesApiRequest { model, instructions, @@ -1039,7 +1033,6 @@ impl ModelClientSession { }, compression, turn_state: Some(Arc::clone(&self.turn_state)), - include_item_ids: false, } } @@ -1305,7 +1298,6 @@ impl ModelClientSession { model_info.use_responses_lite, ) .await; - options.include_item_ids = prompt.include_item_ids; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1475,13 +1467,6 @@ impl ModelClientSession { let (mut ws_request, previous_response_id_from_untraced_warmup) = self.prepare_websocket_request(ws_payload, &request); - if !prompt.include_item_ids { - let ResponsesWsRequest::ResponseCreate(payload) = &mut ws_request; - payload - .input - .iter_mut() - .for_each(ResponseItem::clear_feature_gated_id); - } let inference_trace_attempt = if warmup { // Prewarm sends `generate=false`; it is connection setup, not a // model inference attempt that should appear in rollout traces. diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 41edf4add8f4..e0c2ff03c302 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -19,9 +19,6 @@ pub struct Prompt { /// Conversation context input items. pub input: Vec, - /// Whether Responses API item IDs should be included on the wire. - pub include_item_ids: bool, - /// Tools available to the model, including additional tools sourced from /// external MCP servers. pub(crate) tools: Vec, @@ -42,7 +39,6 @@ impl Default for Prompt { fn default() -> Self { Self { input: Vec::new(), - include_item_ids: false, tools: Vec::new(), parallel_tool_calls: false, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 943b4a50f9a3..9d79138e87ce 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -25,7 +25,6 @@ use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_analytics::now_unix_seconds; -use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -239,7 +238,6 @@ async fn run_compact_task_inner_impl( let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, - include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), base_instructions: sess.get_base_instructions().await, ..Default::default() }; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 115106b936b3..c88d8062d602 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -23,7 +23,6 @@ use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; use codex_app_server_protocol::AuthMode; -use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -226,7 +225,6 @@ async fn run_remote_compact_task_inner_impl( .await?; let prompt = Prompt { input: prompt_input, - include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 864a814ef626..3b79789c02ba 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -27,7 +27,6 @@ use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; -use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -235,7 +234,6 @@ async fn run_remote_compact_task_inner_impl( input.push(ResponseItem::CompactionTrigger { metadata: None }); let prompt = Prompt { input, - include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 31b5671231fc..4beef792bb30 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1026,7 +1026,6 @@ pub(crate) fn build_prompt( ) -> Prompt { Prompt { input, - include_item_ids: turn_context.config.features.enabled(Feature::ItemIds), tools: router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, base_instructions, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 95a285015197..8e808c76c7e7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -254,38 +254,6 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn response_item_ids_are_omitted_when_feature_is_disabled() -> anyhow::Result<()> { - let server = MockServer::start().await; - let response_mock = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg_server", "first reply"), - ev_completed("resp-1"), - ]), - sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), - ], - ) - .await; - let test = test_codex().build(&server).await?; - - test.submit_turn("first turn").await?; - test.submit_turn("second turn").await?; - - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - for item in requests[1].input() { - assert!( - item.get("id").is_none(), - "input item should omit IDs while the feature is disabled: {item}" - ); - } - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> anyhow::Result<()> { let server = MockServer::start().await; @@ -319,9 +287,6 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an }) .await; - builder = builder.with_config(|config| { - let _ = config.features.enable(Feature::ItemIds); - }); let resumed = builder.resume(&server, home, rollout_path).await?; resumed.submit_turn("after resume").await?; @@ -394,9 +359,6 @@ async fn inter_agent_response_item_ids_persist_across_resume() -> anyhow::Result }) .await; - builder = builder.with_config(|config| { - let _ = config.features.enable(Feature::ItemIds); - }); let resumed = builder.resume(&server, home, rollout_path).await?; resumed.submit_turn("after resume").await?; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 223477034975..cee54e24b310 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1513,35 +1513,6 @@ async fn responses_websocket_connection_limit_error_reconnects_and_completes() { server.shutdown().await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_gates_item_ids() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ev_response_created("resp-1"), ev_completed("resp-1")], - vec![ev_response_created("resp-2"), ev_completed("resp-2")], - ]]) - .await; - - let harness = websocket_harness(&server).await; - let mut client_session = harness.client.new_session(); - let prompt_without_ids = prompt_with_input(vec![assistant_message_item("msg-1", "first")]); - let mut prompt_with_ids = prompt_with_input(vec![assistant_message_item("msg-2", "second")]); - prompt_with_ids.include_item_ids = true; - - stream_until_complete(&mut client_session, &harness, &prompt_without_ids).await; - stream_until_complete(&mut client_session, &harness, &prompt_with_ids).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(); - assert_eq!(first["input"][0].get("id"), None); - assert_eq!(second["input"][0]["id"].as_str(), Some("msg-2")); - - server.shutdown().await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_uses_incremental_create_on_prefix() { skip_if_no_network!(); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 194a1ceef9ff..ead2d5330b14 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1189,28 +1189,6 @@ impl ResponseItem { } } - /// Clears IDs whose serialization is controlled by the `item_ids` feature. - /// - /// Image generation call IDs predate that feature and remain serialized for compatibility. - pub fn clear_feature_gated_id(&mut self) { - match self { - Self::Message { id, .. } - | Self::AgentMessage { id, .. } - | Self::LocalShellCall { id, .. } - | Self::FunctionCall { id, .. } - | Self::ToolSearchCall { id, .. } - | Self::FunctionCallOutput { id, .. } - | Self::CustomToolCall { id, .. } - | Self::CustomToolCallOutput { id, .. } - | Self::ToolSearchOutput { id, .. } - | Self::WebSearchCall { id, .. } - | Self::Reasoning { id, .. } - | Self::Compaction { id, .. } - | Self::ContextCompaction { id, .. } => *id = None, - Self::ImageGenerationCall { .. } | Self::CompactionTrigger { .. } | Self::Other => {} - } - } - /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { self.metadata() From 2ea01cc1a36034c6b24b6788c11a51922448a216 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 11:50:34 -0700 Subject: [PATCH 05/14] codex: update item ID fixtures (#28814) --- codex-rs/core/tests/suite/client.rs | 2 ++ codex-rs/core/tests/suite/compact.rs | 3 +++ ..._manual_compact_api_auth_prompt_cache_key_request_diff.snap | 2 ++ ...hatgpt_auth_service_tier_prompt_cache_key_request_diff.snap | 2 ++ 4 files changed, 9 insertions(+) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8e808c76c7e7..5289117c6d79 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -3477,6 +3477,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }, { "type": "message", + "id": "msg-1", "role": "assistant", "content": [{"type":"output_text","text":"Hey there!\n"}] }, @@ -3487,6 +3488,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }, { "type": "message", + "id": "msg-1", "role": "assistant", "content": [{"type":"output_text","text":"Hey there!\n"}] }, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 3b709d54a206..bf268cee9412 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1305,6 +1305,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_1, + "id": "m1", "summary": [ { "text": "I will create a react app", @@ -1405,6 +1406,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_2, + "id": "m3", "summary": [ { "text": "I will create a node app", @@ -1505,6 +1507,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_3, + "id": "m6", "summary": [ { "text": "I will create a python app", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 0df96a7c064b..748ac7914863 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -26,6 +26,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s + } + ], + "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "id": "turn-five-reasoning", + "summary": [ + { + "text": "TURN_FIVE_REASONING", @@ -41,6 +42,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s + "type": "output_text" + } + ], ++ "id": "turn-five-assistant", + "role": "assistant", + "type": "message" - "service_tier": "priority", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index d959d526cc92..f997de046652 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -26,6 +26,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses + } + ], + "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "id": "turn-five-reasoning", + "summary": [ + { + "text": "TURN_FIVE_REASONING", @@ -41,6 +42,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses + "type": "output_text" + } + ], ++ "id": "turn-five-assistant", + "role": "assistant", + "type": "message" - "store": false, From 20d146f59535391ca5e9b13d4f0c4b936d1039d5 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 11:56:54 -0700 Subject: [PATCH 06/14] codex: defer inter-agent item IDs (#28814) --- codex-rs/core/src/session/mod.rs | 3 +- codex-rs/core/tests/suite/client.rs | 83 ----------------------------- codex-rs/protocol/src/protocol.rs | 8 +-- 3 files changed, 2 insertions(+), 92 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f91ccbd09402..e9fa300c1f8e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2717,7 +2717,7 @@ impl Session { pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, - mut communication: InterAgentCommunication, + communication: InterAgentCommunication, ) { let response_item = communication.to_model_input_item(); let items = self.prepare_conversation_items_for_history( @@ -2725,7 +2725,6 @@ impl Session { std::slice::from_ref(&response_item), ); let items = items.as_ref(); - communication.id = items.first().and_then(ResponseItem::id).map(str::to_owned); { let mut state = self.state.lock().await; state.record_items( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5289117c6d79..ae8c6c1ebe3e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -19,7 +19,6 @@ use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; -use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -43,7 +42,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -152,19 +150,6 @@ fn response_message_item_id(request: &ResponsesRequest, role: &str, text: &str) .unwrap_or_else(|| panic!("missing item ID for {role} message {text:?}")) } -fn response_agent_message_item_id(request: &ResponsesRequest, text: &str) -> String { - request - .inputs_of_type("agent_message") - .into_iter() - .find(|item| message_input_texts(item).contains(&text)) - .and_then(|item| { - item.get("id") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| panic!("missing item ID for agent message {text:?}")) -} - fn assert_codex_client_metadata( request_body: &serde_json::Value, installation_id: &str, @@ -312,74 +297,6 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inter_agent_response_item_ids_persist_across_resume() -> anyhow::Result<()> { - let server = MockServer::start().await; - let response_mock = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-mail"), - ev_completed("resp-mail"), - ]), - sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), - ], - ) - .await; - let mut builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ItemIds); - }); - let initial = builder.build(&server).await?; - let home = Arc::clone(&initial.home); - let rollout_path = initial - .session_configured - .rollout_path - .clone() - .expect("rollout path"); - - initial - .codex - .submit(Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "child update".to_string(), - /*trigger_turn*/ true, - ), - }) - .await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - initial.codex.submit(Op::Shutdown).await?; - wait_for_event(&initial.codex, |event| { - matches!(event, EventMsg::ShutdownComplete) - }) - .await; - - let resumed = builder.resume(&server, home, rollout_path).await?; - resumed.submit_turn("after resume").await?; - - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2); - let agent_message_id = response_agent_message_item_id(&requests[0], "child update"); - let agent_message_uuid = agent_message_id - .strip_prefix("amsg_") - .expect("agent message ID should have the Responses API prefix"); - assert_eq!( - Uuid::parse_str(agent_message_uuid)?.get_version(), - Some(uuid::Version::SortRand) - ); - assert_eq!( - response_agent_message_item_id(&requests[1], "child update"), - agent_message_id - ); - - Ok(()) -} - /// Writes an `auth.json` into the provided `codex_home` with the specified parameters. /// Returns the fake JWT string written to `tokens.id_token`. #[expect(clippy::unwrap_used)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 341f56049e9c..a0e5b24d4704 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -676,9 +676,6 @@ impl From> for Op { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct InterAgentCommunication { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub id: Option, pub author: AgentPath, pub recipient: AgentPath, #[serde(default)] @@ -702,7 +699,6 @@ impl InterAgentCommunication { trigger_turn: bool, ) -> Self { Self { - id: None, author, recipient, other_recipients, @@ -721,7 +717,6 @@ impl InterAgentCommunication { trigger_turn: bool, ) -> Self { Self { - id: None, author, recipient, other_recipients, @@ -767,7 +762,7 @@ impl InterAgentCommunication { }], }; ResponseItem::AgentMessage { - id: self.id.clone(), + id: None, author: self.author.to_string(), recipient: self.recipient.to_string(), content, @@ -4256,7 +4251,6 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { let communication = InterAgentCommunication { - id: None, author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], From 92d942d981879648f9a02c1c7afbed3828bbdc05 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 12:25:35 -0700 Subject: [PATCH 07/14] Gate response item IDs in outbound requests --- codex-rs/codex-api/src/endpoint/compact.rs | 7 +- codex-rs/codex-api/src/endpoint/responses.rs | 19 ++--- .../src/endpoint/responses_websocket.rs | 57 +++++++++++-- codex-rs/codex-api/src/endpoint/search.rs | 9 +- codex-rs/codex-api/src/requests/mod.rs | 2 +- codex-rs/codex-api/src/requests/responses.rs | 82 ++++++++++++++----- codex-rs/codex-api/tests/clients.rs | 59 +++++++++++-- codex-rs/core/src/client.rs | 6 ++ codex-rs/core/src/client_tests.rs | 2 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 3 + codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 6 ++ .../core/tests/suite/client_websockets.rs | 1 + ...pi_auth_prompt_cache_key_request_diff.snap | 2 - ...ce_tier_prompt_cache_key_request_diff.snap | 2 - 16 files changed, 211 insertions(+), 50 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index c8730235fd5f..b2c9bc5b433b 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -3,6 +3,7 @@ use crate::common::CompactionInput; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; +use crate::requests::strip_response_item_ids; use codex_client::HttpTransport; use codex_client::RequestTelemetry; use codex_protocol::models::ResponseItem; @@ -75,9 +76,13 @@ impl CompactClient { extra_headers: HeaderMap, request_timeout: Duration, turn_state: Option<&OnceLock>, + include_item_ids: bool, ) -> Result, ApiError> { - let body = to_value(input) + let mut body = to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; + if !include_item_ids { + strip_response_item_ids(&mut body); + } self.compact(body, extra_headers, request_timeout, turn_state) .await } diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index ad79adf33dff..399a84f8b410 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -5,10 +5,10 @@ use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; -use crate::requests::attach_item_ids; use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; +use crate::requests::strip_response_item_ids; use crate::sse::spawn_response_stream; use crate::telemetry::SseTelemetry; use codex_client::EncodedJsonBody; @@ -37,6 +37,7 @@ pub struct ResponsesOptions { pub extra_headers: HeaderMap, pub compression: Compression, pub turn_state: Option>>, + pub include_item_ids: bool, } impl ResponsesClient { @@ -80,18 +81,16 @@ impl ResponsesClient { extra_headers, compression, turn_state, + include_item_ids, } = options; - let body = if request.store && self.session.provider().is_azure_responses_endpoint() { - let mut body = serde_json::to_value(&request).map_err(|e| { - ApiError::Stream(format!("failed to encode responses request: {e}")) - })?; - attach_item_ids(&mut body, &request.input); - EncodedJsonBody::encode(&body) - } else { - EncodedJsonBody::encode(&request) + let mut body = serde_json::to_value(&request) + .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; + if !include_item_ids { + strip_response_item_ids(&mut body); } - .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; + let body = EncodedJsonBody::encode(&body) + .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; let mut headers = extra_headers; if let Some(ref thread_id) = thread_id { diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 4ea564f139aa..cba9d09202f2 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -5,6 +5,7 @@ use crate::common::ResponsesWsRequest; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; +use crate::requests::strip_response_item_ids; use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; @@ -216,6 +217,7 @@ impl ResponsesWebsocketConnection { request: ResponsesWsRequest, connection_reused: bool, turn_state: Option>>, + include_item_ids: bool, ) -> Result { let (tx_event, rx_event) = mpsc::channel::>(1600); @@ -225,7 +227,7 @@ impl ResponsesWebsocketConnection { let models_etag = self.models_etag.clone(); let server_model = self.server_model.clone(); let telemetry = self.telemetry.clone(); - let request_text = serialize_websocket_request(&request)?; + let request_text = serialize_websocket_request(&request, include_item_ids)?; let current_span = Span::current(); tokio::spawn( @@ -787,8 +789,16 @@ async fn send_websocket_request( Ok(()) } -fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result { - serde_json::to_string(request) +fn serialize_websocket_request( + request: &ResponsesWsRequest, + include_item_ids: bool, +) -> Result { + let mut payload = serde_json::to_value(request) + .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}")))?; + if !include_item_ids { + strip_response_item_ids(&mut payload); + } + serde_json::to_string(&payload) .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}"))) } @@ -839,14 +849,51 @@ mod tests { }); let previous_payload = serde_json::to_value(&request).expect("serialize previous payload"); - let request_text = - serialize_websocket_request(&request).expect("serialize websocket request"); + let request_text = serialize_websocket_request(&request, /*include_item_ids*/ true) + .expect("serialize websocket request"); let wire_payload = serde_json::from_str::(&request_text).expect("parse websocket request"); assert_eq!(wire_payload, previous_payload); } + #[test] + fn websocket_serialization_strips_item_ids_by_default() { + let request = ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { + model: "gpt-test".to_string(), + instructions: "Use the available tools.".to_string(), + previous_response_id: None, + input: vec![ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hello".to_string(), + }], + phase: None, + metadata: None, + }], + tools: Vec::new(), + tool_choice: "auto".to_string(), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: Vec::new(), + service_tier: None, + prompt_cache_key: None, + text: None, + generate: None, + client_metadata: None, + }); + + let request_text = serialize_websocket_request(&request, /*include_item_ids*/ false) + .expect("serialize websocket request"); + let wire_payload = + serde_json::from_str::(&request_text).expect("parse websocket request"); + + assert_eq!(wire_payload["input"][0].get("id"), None); + } + #[test] fn websocket_config_enables_permessage_deflate() { let config = websocket_config(); diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 1c231d6bef85..abf06777b10b 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -2,6 +2,8 @@ use crate::auth::SharedAuthProvider; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; +use crate::requests::strip_response_item_ids; +use crate::search::SearchInput; use crate::search::SearchRequest; use crate::search::SearchResponse; use codex_client::HttpTransport; @@ -37,8 +39,11 @@ impl SearchClient { request: &SearchRequest, extra_headers: HeaderMap, ) -> Result { - let body = to_value(request) + let mut body = to_value(request) .map_err(|e| ApiError::Stream(format!("failed to encode search request: {e}")))?; + if matches!(&request.input, Some(SearchInput::Items(_))) { + strip_response_item_ids(&mut body); + } let resp = self .session .execute(Method::POST, Self::path(), extra_headers, Some(body)) @@ -149,7 +154,7 @@ mod tests { model: "gpt-test".to_string(), reasoning: None, input: Some(SearchInput::Items(vec![ResponseItem::Message { - id: None, + id: Some("msg_search".to_string()), role: "user".to_string(), content: vec![ ContentItem::InputText { diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 1c357b2a613b..37dd1304db77 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -2,4 +2,4 @@ pub(crate) mod headers; pub(crate) mod responses; pub use responses::Compression; -pub(crate) use responses::attach_item_ids; +pub(crate) use responses::strip_response_item_ids; diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 836f525c49b7..3ac618aafe13 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -1,4 +1,3 @@ -use codex_protocol::models::ResponseItem; use serde_json::Value; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -8,30 +7,69 @@ pub enum Compression { Zstd, } -pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { - let Some(input_value) = payload_json.get_mut("input") else { - return; - }; - let Value::Array(items) = input_value else { +pub(crate) fn strip_response_item_ids(payload_json: &mut Value) { + let Some(Value::Array(items)) = payload_json.get_mut("input") else { return; }; - for (value, item) in items.iter_mut().zip(original_items.iter()) { - if let ResponseItem::Reasoning { id: Some(id), .. } - | ResponseItem::Message { id: Some(id), .. } - | ResponseItem::WebSearchCall { id: Some(id), .. } - | ResponseItem::FunctionCall { id: Some(id), .. } - | ResponseItem::ToolSearchCall { id: Some(id), .. } - | ResponseItem::LocalShellCall { id: Some(id), .. } - | ResponseItem::CustomToolCall { id: Some(id), .. } = item - { - if id.is_empty() { - continue; - } - - if let Some(obj) = value.as_object_mut() { - obj.insert("id".to_string(), Value::String(id.clone())); - } + for item in items { + if let Value::Object(object) = item { + object.remove("id"); } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn strip_response_item_ids_removes_ids_from_input_items() { + let mut payload = json!({ + "model": "gpt-test", + "id": "request-id", + "input": [ + { + "type": "message", + "id": "msg_1", + "content": [ + {"type": "input_text", "text": "hello"}, + {"type": "input_image", "id": "img_1", "image_url": "https://example.com/image.png"} + ] + }, + {"type": "function_call_output", "id": "fco_1", "call_id": "call_1", "output": "done"} + ] + }); + + strip_response_item_ids(&mut payload); + + assert_eq!( + payload, + json!({ + "model": "gpt-test", + "id": "request-id", + "input": [ + { + "type": "message", + "content": [ + {"type": "input_text", "text": "hello"}, + {"type": "input_image", "id": "img_1", "image_url": "https://example.com/image.png"} + ] + }, + {"type": "function_call_output", "call_id": "call_1", "output": "done"} + ] + }) + ); + } + + #[test] + fn strip_response_item_ids_ignores_missing_input() { + let mut payload = json!({"id": "request-id"}); + + strip_response_item_ids(&mut payload); + + assert_eq!(payload, json!({"id": "request-id"})); + } +} diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index d8489ed7487f..625dd348baa3 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> { +async fn responses_client_stream_request_preserves_item_ids_when_enabled() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -327,10 +327,16 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( text: None, client_metadata: None, }; - let expected = serde_json::to_vec(&request)?; + let expected = serde_json::to_value(&request)?; let _stream = client - .stream_request(request, ResponsesOptions::default()) + .stream_request( + request, + ResponsesOptions { + include_item_ids: true, + ..Default::default() + }, + ) .await?; let requests = state.take_stream_requests(); @@ -338,7 +344,10 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( let prepared = requests[0] .prepare_body_for_send() .expect("body should prepare"); - assert_eq!(prepared.body.as_deref(), Some(expected.as_slice())); + let body: serde_json::Value = + serde_json::from_slice(prepared.body.as_deref().expect("body should be JSON"))?; + assert_eq!(body, expected); + assert_eq!(body["input"][0]["id"], "msg_1"); assert_eq!( prepared.headers.get(http::header::CONTENT_TYPE), Some(&HeaderValue::from_static("application/json")) @@ -346,6 +355,45 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( Ok(()) } +#[tokio::test] +async fn responses_client_stream_request_strips_item_ids_by_default() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); + let request = ResponsesApiRequest { + model: "gpt-test".into(), + instructions: "Say hi".into(), + input: vec![ResponseItem::Message { + id: Some("msg_1".into()), + role: "user".into(), + content: vec![ContentItem::InputText { text: "hi".into() }], + phase: None, + metadata: None, + }], + tools: Vec::new(), + tool_choice: "auto".into(), + parallel_tool_calls: false, + reasoning: None, + store: false, + stream: true, + include: Vec::new(), + service_tier: None, + prompt_cache_key: None, + text: None, + client_metadata: None, + }; + + let _stream = client + .stream_request(request, ResponsesOptions::default()) + .await?; + + let requests = state.take_stream_requests(); + assert_eq!(requests.len(), 1); + let body: serde_json::Value = serde_json::from_slice(request_body_bytes(&requests[0]))?; + assert_eq!(body["input"][0].get("id"), None); + Ok(()) +} + #[tokio::test] async fn streaming_client_adds_auth_headers() -> Result<()> { let state = RecordingState::default(); @@ -502,7 +550,7 @@ async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { } #[tokio::test] -async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { +async fn azure_store_sends_ids_when_enabled_and_headers() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth)); @@ -542,6 +590,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { extra_headers, compression: Compression::None, turn_state: None, + include_item_ids: true, }, ) .await?; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4ea8c0ff1fb8..417634e2359d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -178,6 +178,7 @@ struct ModelClientState { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, include_attestation: bool, attestation_provider: Option>, disable_websockets: AtomicBool, @@ -378,6 +379,7 @@ impl ModelClient { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + item_ids_enabled: bool, attestation_provider: Option>, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); @@ -398,6 +400,7 @@ impl ModelClient { enable_request_compression, include_timing_metrics, beta_features_header, + item_ids_enabled, include_attestation, attestation_provider, disable_websockets: AtomicBool::new(false), @@ -572,6 +575,7 @@ impl ModelClient { extra_headers, compact_request_timeout, turn_state.as_deref(), + /*include_item_ids*/ self.state.item_ids_enabled, ) .await .map_err(map_api_error); @@ -1033,6 +1037,7 @@ impl ModelClientSession { }, compression, turn_state: Some(Arc::clone(&self.turn_state)), + include_item_ids: self.client.state.item_ids_enabled, } } @@ -1496,6 +1501,7 @@ impl ModelClientSession { ws_request, self.websocket_session.connection_reused(), Some(Arc::clone(&self.turn_state)), + /*include_item_ids*/ self.client.state.item_ids_enabled, ) .await .map_err(|err| { diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 06074a5d2024..1d0c4c48c29a 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -76,6 +76,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) } @@ -566,6 +567,7 @@ fn model_client_with_counting_attestation( /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c4bc064a473f..e3686baffc01 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1027,6 +1027,7 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), attestation_provider, ) .with_prompt_cache_key_override( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d0b7149d87d7..c804f398975d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -437,6 +437,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ) .new_session() @@ -5047,6 +5048,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), @@ -7090,6 +7092,7 @@ where config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index b5bc2b0d4440..cf9b9a939fcd 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -127,6 +127,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -258,6 +259,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -375,6 +377,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ae8c6c1ebe3e..f06c9723dc1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -272,6 +272,9 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an }) .await; + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ItemIds); + }); let resumed = builder.resume(&server, home, rollout_path).await?; resumed.submit_turn("after resume").await?; @@ -1100,6 +1103,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); @@ -2560,6 +2564,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); + let _ = config.features.enable(Feature::ItemIds); let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); @@ -2592,6 +2597,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cee54e24b310..996b7632ccf0 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2189,6 +2189,7 @@ async fn websocket_harness_with_provider_options( /*enable_request_compression*/ false, runtime_metrics_enabled, /*beta_features_header*/ None, + /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 748ac7914863..0df96a7c064b 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -26,7 +26,6 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s + } + ], + "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", -+ "id": "turn-five-reasoning", + "summary": [ + { + "text": "TURN_FIVE_REASONING", @@ -42,7 +41,6 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s + "type": "output_text" + } + ], -+ "id": "turn-five-assistant", + "role": "assistant", + "type": "message" - "service_tier": "priority", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index f997de046652..d959d526cc92 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -26,7 +26,6 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses + } + ], + "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", -+ "id": "turn-five-reasoning", + "summary": [ + { + "text": "TURN_FIVE_REASONING", @@ -42,7 +41,6 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses + "type": "output_text" + } + ], -+ "id": "turn-five-assistant", + "role": "assistant", + "type": "message" - "store": false, From a5cd238565f89e5d1e07b80ba6df7c0e944c3255 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 13:19:14 -0700 Subject: [PATCH 08/14] codex: fix CI failure on PR #28814 --- codex-rs/memories/write/src/runtime.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 8dae6ca61704..74d8445b7566 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -235,6 +235,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, + config.features.enabled(Feature::ItemIds), /*attestation_provider*/ None, ); From de68ecbd0d36a9de5ee09a279fe5377dff96e921 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 13:33:59 -0700 Subject: [PATCH 09/14] codex: update default item ID expectations --- codex-rs/core/tests/suite/compact.rs | 3 --- codex-rs/core/tests/suite/model_switching.rs | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index bf268cee9412..3b709d54a206 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1305,7 +1305,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_1, - "id": "m1", "summary": [ { "text": "I will create a react app", @@ -1406,7 +1405,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_2, - "id": "m3", "summary": [ { "text": "I will create a node app", @@ -1507,7 +1505,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { { "content": null, "encrypted_content": encrypted_content_3, - "id": "m6", "summary": [ { "text": "I will create a python app", diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 22363eabea3a..ef7a305bd762 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -649,8 +649,8 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "expected the original image generation call id to be preserved" + None, + "expected the image generation call id to be omitted" ); assert_eq!( image_generation_calls[0]["result"].as_str(), @@ -766,8 +766,8 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima ); assert_eq!( image_generation_calls[0]["id"].as_str(), - Some("ig_123"), - "second request should preserve the original generated image call id" + None, + "second request should omit the generated image call id" ); assert_eq!( image_generation_calls[0]["result"].as_str(), From 949475bda25bd59dabcc3be52291e73eb89a141a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 13:41:45 -0700 Subject: [PATCH 10/14] codex: omit IDs from default history fixture --- codex-rs/core/tests/suite/client.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f06c9723dc1c..e5c8a0ed3da2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -3400,7 +3400,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }, { "type": "message", - "id": "msg-1", "role": "assistant", "content": [{"type":"output_text","text":"Hey there!\n"}] }, @@ -3411,7 +3410,6 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }, { "type": "message", - "id": "msg-1", "role": "assistant", "content": [{"type":"output_text","text":"Hey there!\n"}] }, From f5750e75cd9e2d639c989fde27e691c39da6efbb Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 14:26:54 -0700 Subject: [PATCH 11/14] codex: address PR review feedback (#28814) --- codex-rs/codex-api/src/endpoint/compact.rs | 8 ++----- codex-rs/codex-api/src/endpoint/responses.rs | 7 ++---- .../src/endpoint/responses_websocket.rs | 7 ++---- codex-rs/codex-api/src/lib.rs | 1 + codex-rs/codex-api/src/requests/mod.rs | 1 + codex-rs/codex-api/src/requests/responses.rs | 20 ++++++++++++++--- codex-rs/core/src/client.rs | 14 +++++++----- codex-rs/core/src/session/mod.rs | 5 +++-- codex-rs/core/src/session/tests.rs | 22 +++++++++++++++++++ 9 files changed, 59 insertions(+), 26 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index b2c9bc5b433b..9631a5f018d3 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -3,14 +3,13 @@ use crate::common::CompactionInput; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; -use crate::requests::strip_response_item_ids; +use crate::requests::response_request_json; use codex_client::HttpTransport; use codex_client::RequestTelemetry; use codex_protocol::models::ResponseItem; use http::HeaderMap; use http::Method; use serde::Deserialize; -use serde_json::to_value; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; @@ -78,11 +77,8 @@ impl CompactClient { turn_state: Option<&OnceLock>, include_item_ids: bool, ) -> Result, ApiError> { - let mut body = to_value(input) + let body = response_request_json(input, include_item_ids) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; - if !include_item_ids { - strip_response_item_ids(&mut body); - } self.compact(body, extra_headers, request_timeout, turn_state) .await } diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 399a84f8b410..205fe91f6d92 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -8,7 +8,7 @@ use crate::requests::Compression; use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; -use crate::requests::strip_response_item_ids; +use crate::requests::response_request_json; use crate::sse::spawn_response_stream; use crate::telemetry::SseTelemetry; use codex_client::EncodedJsonBody; @@ -84,11 +84,8 @@ impl ResponsesClient { include_item_ids, } = options; - let mut body = serde_json::to_value(&request) + let body = response_request_json(&request, include_item_ids) .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; - if !include_item_ids { - strip_response_item_ids(&mut body); - } let body = EncodedJsonBody::encode(&body) .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index cba9d09202f2..d954d3761674 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -5,7 +5,7 @@ use crate::common::ResponsesWsRequest; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; -use crate::requests::strip_response_item_ids; +use crate::requests::response_request_json; use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; @@ -793,11 +793,8 @@ fn serialize_websocket_request( request: &ResponsesWsRequest, include_item_ids: bool, ) -> Result { - let mut payload = serde_json::to_value(request) + let payload = response_request_json(request, include_item_ids) .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}")))?; - if !include_item_ids { - strip_response_item_ids(&mut payload); - } serde_json::to_string(&payload) .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}"))) } diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index df6723f023f6..f9f7c10e7997 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -78,6 +78,7 @@ pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; +pub use crate::requests::response_request_json; pub use crate::search::AllowedCaller; pub use crate::search::ApproximateLocation; pub use crate::search::ClickOperation; diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 37dd1304db77..8cd81169c2b2 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod headers; pub(crate) mod responses; pub use responses::Compression; +pub use responses::response_request_json; pub(crate) use responses::strip_response_item_ids; diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 3ac618aafe13..8e3bb7193e86 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use serde_json::Value; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -7,6 +8,18 @@ pub enum Compression { Zstd, } +/// Serializes a Responses-shaped request using the item ID mode shared by transport and tracing. +pub fn response_request_json( + request: &impl Serialize, + include_item_ids: bool, +) -> Result { + let mut payload = serde_json::to_value(request)?; + if !include_item_ids { + strip_response_item_ids(&mut payload); + } + Ok(payload) +} + pub(crate) fn strip_response_item_ids(payload_json: &mut Value) { let Some(Value::Array(items)) = payload_json.get_mut("input") else { return; @@ -26,8 +39,8 @@ mod tests { use serde_json::json; #[test] - fn strip_response_item_ids_removes_ids_from_input_items() { - let mut payload = json!({ + fn response_request_json_removes_ids_from_input_items() { + let request = json!({ "model": "gpt-test", "id": "request-id", "input": [ @@ -43,7 +56,8 @@ mod tests { ] }); - strip_response_item_ids(&mut payload); + let payload = response_request_json(&request, /*include_item_ids*/ false) + .expect("request should serialize"); assert_eq!( payload, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 417634e2359d..18c1c5036752 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -61,6 +61,7 @@ use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; +use codex_api::response_request_json; use codex_app_server_protocol::AuthMode; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -1315,7 +1316,9 @@ impl ModelClientSession { )?; let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); - inference_trace_attempt.record_started(&request); + let trace_request = + response_request_json(&request, self.client.state.item_ids_enabled)?; + inference_trace_attempt.record_started(&trace_request); let client = ApiResponsesClient::new( transport, client_setup.api_provider, @@ -1480,14 +1483,15 @@ impl ModelClientSession { inference_trace.start_attempt() }; stamp_ws_stream_request_start_ms(&mut ws_request); - if previous_response_id_from_untraced_warmup { + let trace_request = if previous_response_id_from_untraced_warmup { // The transport can reuse an untraced warmup response id and omit the // already-sent input, but rollout replay needs the logical model-visible // request rather than the compressed websocket delta. - inference_trace_attempt.record_started(&request); + response_request_json(&request, self.client.state.item_ids_enabled)? } else { - inference_trace_attempt.record_started(&ws_request); - } + response_request_json(&ws_request, self.client.state.item_ids_enabled)? + }; + inference_trace_attempt.record_started(&trace_request); self.websocket_session.last_request = Some(request); self.websocket_session.last_response_from_untraced_warmup = warmup; let websocket_connection = diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index e9fa300c1f8e..abac00a439fd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2658,7 +2658,6 @@ impl Session { } let prefix = match item { ResponseItem::Message { .. } => "msg", - ResponseItem::AgentMessage { .. } => "amsg", ResponseItem::Reasoning { .. } => "rs", ResponseItem::LocalShellCall { .. } => "lsh", ResponseItem::FunctionCall { .. } => "fc", @@ -2670,7 +2669,9 @@ impl Session { ResponseItem::WebSearchCall { .. } => "ws", ResponseItem::ImageGenerationCall { .. } => "ig", ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => "cmp", - ResponseItem::CompactionTrigger { .. } | ResponseItem::Other => continue, + ResponseItem::AgentMessage { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => continue, }; item.set_id(format!("{prefix}_{}", Uuid::now_v7())); } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c804f398975d..1a3745a4e80d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; @@ -197,6 +198,27 @@ fn user_message(text: &str) -> ResponseItem { } } +#[test] +fn assign_missing_response_item_ids_skips_agent_messages() { + let mut items = Cow::Owned(vec![ + ResponseItem::AgentMessage { + id: None, + author: "worker".to_string(), + recipient: "root".to_string(), + content: vec![AgentMessageInputContent::InputText { + text: "done".to_string(), + }], + metadata: None, + }, + user_message("hello"), + ]); + + Session::assign_missing_response_item_ids(&mut items); + + assert_eq!(items[0].id(), None); + assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, From 1abdae46d80b72e80d814999b9e746d09bc7495c Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 14:37:03 -0700 Subject: [PATCH 12/14] codex: preserve IDs for stored requests --- codex-rs/core/src/client.rs | 16 +++++++++++----- codex-rs/core/tests/suite/client.rs | 3 +-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 18c1c5036752..f2e8073ceb5e 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -835,6 +835,10 @@ impl ModelClient { Ok(request) } + fn include_item_ids_for_request(&self, request: &ResponsesApiRequest) -> bool { + self.state.item_ids_enabled || request.store + } + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// WebSocket use is controlled by provider capability and session-scoped fallback state. @@ -1314,10 +1318,11 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let include_item_ids = self.client.include_item_ids_for_request(&request); + options.include_item_ids = include_item_ids; let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); - let trace_request = - response_request_json(&request, self.client.state.item_ids_enabled)?; + let trace_request = response_request_json(&request, include_item_ids)?; inference_trace_attempt.record_started(&trace_request); let client = ApiResponsesClient::new( transport, @@ -1421,6 +1426,7 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let include_item_ids = self.client.include_item_ids_for_request(&request); let mut client_metadata = self .client .build_ws_client_metadata(responses_metadata, model_info.use_responses_lite); @@ -1487,9 +1493,9 @@ impl ModelClientSession { // The transport can reuse an untraced warmup response id and omit the // already-sent input, but rollout replay needs the logical model-visible // request rather than the compressed websocket delta. - response_request_json(&request, self.client.state.item_ids_enabled)? + response_request_json(&request, include_item_ids)? } else { - response_request_json(&ws_request, self.client.state.item_ids_enabled)? + response_request_json(&ws_request, include_item_ids)? }; inference_trace_attempt.record_started(&trace_request); self.websocket_session.last_request = Some(request); @@ -1505,7 +1511,7 @@ impl ModelClientSession { ws_request, self.websocket_session.connection_reused(), Some(Arc::clone(&self.turn_state)), - /*include_item_ids*/ self.client.state.item_ids_enabled, + include_item_ids, ) .await .map_err(|err| { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e5c8a0ed3da2..0c7844d55ee4 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2564,7 +2564,6 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let _ = config.features.enable(Feature::ItemIds); let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); @@ -2597,7 +2596,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, - /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*item_ids_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); From d5070ac8cc2b61048f48cf2304a4ab4d4ee0680e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 14:56:13 -0700 Subject: [PATCH 13/14] codex: centralize response item ID request policy --- codex-rs/codex-api/src/endpoint/compact.rs | 4 +- codex-rs/codex-api/src/endpoint/responses.rs | 7 +- .../src/endpoint/responses_websocket.rs | 54 ++---------- codex-rs/codex-api/src/endpoint/search.rs | 8 +- codex-rs/codex-api/src/lib.rs | 1 - codex-rs/codex-api/src/requests/mod.rs | 2 - codex-rs/codex-api/src/requests/responses.rs | 83 ------------------- codex-rs/codex-api/tests/clients.rs | 52 +----------- codex-rs/core/src/client.rs | 41 +++++---- codex-rs/core/src/session/mod.rs | 2 +- .../core/tests/suite/client_websockets.rs | 4 +- codex-rs/ext/web-search/src/history.rs | 16 ++-- codex-rs/protocol/src/models.rs | 12 ++- 13 files changed, 58 insertions(+), 228 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index 9631a5f018d3..fd3fd17dbca7 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -3,7 +3,6 @@ use crate::common::CompactionInput; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; -use crate::requests::response_request_json; use codex_client::HttpTransport; use codex_client::RequestTelemetry; use codex_protocol::models::ResponseItem; @@ -75,9 +74,8 @@ impl CompactClient { extra_headers: HeaderMap, request_timeout: Duration, turn_state: Option<&OnceLock>, - include_item_ids: bool, ) -> Result, ApiError> { - let body = response_request_json(input, include_item_ids) + let body = serde_json::to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; self.compact(body, extra_headers, request_timeout, turn_state) .await diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 205fe91f6d92..804f0027ff84 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -8,7 +8,6 @@ use crate::requests::Compression; use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; -use crate::requests::response_request_json; use crate::sse::spawn_response_stream; use crate::telemetry::SseTelemetry; use codex_client::EncodedJsonBody; @@ -37,7 +36,6 @@ pub struct ResponsesOptions { pub extra_headers: HeaderMap, pub compression: Compression, pub turn_state: Option>>, - pub include_item_ids: bool, } impl ResponsesClient { @@ -81,12 +79,9 @@ impl ResponsesClient { extra_headers, compression, turn_state, - include_item_ids, } = options; - let body = response_request_json(&request, include_item_ids) - .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; - let body = EncodedJsonBody::encode(&body) + let body = EncodedJsonBody::encode(&request) .map_err(|e| ApiError::Stream(format!("failed to encode responses request: {e}")))?; let mut headers = extra_headers; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d954d3761674..4ea564f139aa 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -5,7 +5,6 @@ use crate::common::ResponsesWsRequest; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; -use crate::requests::response_request_json; use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; @@ -217,7 +216,6 @@ impl ResponsesWebsocketConnection { request: ResponsesWsRequest, connection_reused: bool, turn_state: Option>>, - include_item_ids: bool, ) -> Result { let (tx_event, rx_event) = mpsc::channel::>(1600); @@ -227,7 +225,7 @@ impl ResponsesWebsocketConnection { let models_etag = self.models_etag.clone(); let server_model = self.server_model.clone(); let telemetry = self.telemetry.clone(); - let request_text = serialize_websocket_request(&request, include_item_ids)?; + let request_text = serialize_websocket_request(&request)?; let current_span = Span::current(); tokio::spawn( @@ -789,13 +787,8 @@ async fn send_websocket_request( Ok(()) } -fn serialize_websocket_request( - request: &ResponsesWsRequest, - include_item_ids: bool, -) -> Result { - let payload = response_request_json(request, include_item_ids) - .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}")))?; - serde_json::to_string(&payload) +fn serialize_websocket_request(request: &ResponsesWsRequest) -> Result { + serde_json::to_string(request) .map_err(|err| ApiError::Stream(format!("failed to encode websocket request: {err}"))) } @@ -846,51 +839,14 @@ mod tests { }); let previous_payload = serde_json::to_value(&request).expect("serialize previous payload"); - let request_text = serialize_websocket_request(&request, /*include_item_ids*/ true) - .expect("serialize websocket request"); + let request_text = + serialize_websocket_request(&request).expect("serialize websocket request"); let wire_payload = serde_json::from_str::(&request_text).expect("parse websocket request"); assert_eq!(wire_payload, previous_payload); } - #[test] - fn websocket_serialization_strips_item_ids_by_default() { - let request = ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { - model: "gpt-test".to_string(), - instructions: "Use the available tools.".to_string(), - previous_response_id: None, - input: vec![ResponseItem::Message { - id: Some("msg-1".to_string()), - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "hello".to_string(), - }], - phase: None, - metadata: None, - }], - tools: Vec::new(), - tool_choice: "auto".to_string(), - parallel_tool_calls: true, - reasoning: None, - store: false, - stream: true, - include: Vec::new(), - service_tier: None, - prompt_cache_key: None, - text: None, - generate: None, - client_metadata: None, - }); - - let request_text = serialize_websocket_request(&request, /*include_item_ids*/ false) - .expect("serialize websocket request"); - let wire_payload = - serde_json::from_str::(&request_text).expect("parse websocket request"); - - assert_eq!(wire_payload["input"][0].get("id"), None); - } - #[test] fn websocket_config_enables_permessage_deflate() { let config = websocket_config(); diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index abf06777b10b..864f7ec9b0a8 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -2,8 +2,6 @@ use crate::auth::SharedAuthProvider; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; -use crate::requests::strip_response_item_ids; -use crate::search::SearchInput; use crate::search::SearchRequest; use crate::search::SearchResponse; use codex_client::HttpTransport; @@ -39,11 +37,8 @@ impl SearchClient { request: &SearchRequest, extra_headers: HeaderMap, ) -> Result { - let mut body = to_value(request) + let body = to_value(request) .map_err(|e| ApiError::Stream(format!("failed to encode search request: {e}")))?; - if matches!(&request.input, Some(SearchInput::Items(_))) { - strip_response_item_ids(&mut body); - } let resp = self .session .execute(Method::POST, Self::path(), extra_headers, Some(body)) @@ -233,6 +228,7 @@ mod tests { "model": "gpt-test", "input": [{ "type": "message", + "id": "msg_search", "role": "user", "content": [ {"type": "input_text", "text": "find this"}, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index f9f7c10e7997..df6723f023f6 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -78,7 +78,6 @@ pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; -pub use crate::requests::response_request_json; pub use crate::search::AllowedCaller; pub use crate::search::ApproximateLocation; pub use crate::search::ClickOperation; diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 8cd81169c2b2..abe57a886dce 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -2,5 +2,3 @@ pub(crate) mod headers; pub(crate) mod responses; pub use responses::Compression; -pub use responses::response_request_json; -pub(crate) use responses::strip_response_item_ids; diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index 8e3bb7193e86..9a16ceb1c44c 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -1,89 +1,6 @@ -use serde::Serialize; -use serde_json::Value; - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Compression { #[default] None, Zstd, } - -/// Serializes a Responses-shaped request using the item ID mode shared by transport and tracing. -pub fn response_request_json( - request: &impl Serialize, - include_item_ids: bool, -) -> Result { - let mut payload = serde_json::to_value(request)?; - if !include_item_ids { - strip_response_item_ids(&mut payload); - } - Ok(payload) -} - -pub(crate) fn strip_response_item_ids(payload_json: &mut Value) { - let Some(Value::Array(items)) = payload_json.get_mut("input") else { - return; - }; - - for item in items { - if let Value::Object(object) = item { - object.remove("id"); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn response_request_json_removes_ids_from_input_items() { - let request = json!({ - "model": "gpt-test", - "id": "request-id", - "input": [ - { - "type": "message", - "id": "msg_1", - "content": [ - {"type": "input_text", "text": "hello"}, - {"type": "input_image", "id": "img_1", "image_url": "https://example.com/image.png"} - ] - }, - {"type": "function_call_output", "id": "fco_1", "call_id": "call_1", "output": "done"} - ] - }); - - let payload = response_request_json(&request, /*include_item_ids*/ false) - .expect("request should serialize"); - - assert_eq!( - payload, - json!({ - "model": "gpt-test", - "id": "request-id", - "input": [ - { - "type": "message", - "content": [ - {"type": "input_text", "text": "hello"}, - {"type": "input_image", "id": "img_1", "image_url": "https://example.com/image.png"} - ] - }, - {"type": "function_call_output", "call_id": "call_1", "output": "done"} - ] - }) - ); - } - - #[test] - fn strip_response_item_ids_ignores_missing_input() { - let mut payload = json!({"id": "request-id"}); - - strip_response_item_ids(&mut payload); - - assert_eq!(payload, json!({"id": "request-id"})); - } -} diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 625dd348baa3..488f6dc67436 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -301,7 +301,7 @@ async fn responses_client_uses_responses_path() -> Result<()> { } #[tokio::test] -async fn responses_client_stream_request_preserves_item_ids_when_enabled() -> Result<()> { +async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -330,13 +330,7 @@ async fn responses_client_stream_request_preserves_item_ids_when_enabled() -> Re let expected = serde_json::to_value(&request)?; let _stream = client - .stream_request( - request, - ResponsesOptions { - include_item_ids: true, - ..Default::default() - }, - ) + .stream_request(request, ResponsesOptions::default()) .await?; let requests = state.take_stream_requests(); @@ -355,45 +349,6 @@ async fn responses_client_stream_request_preserves_item_ids_when_enabled() -> Re Ok(()) } -#[tokio::test] -async fn responses_client_stream_request_strips_item_ids_by_default() -> Result<()> { - let state = RecordingState::default(); - let transport = RecordingTransport::new(state.clone()); - let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); - let request = ResponsesApiRequest { - model: "gpt-test".into(), - instructions: "Say hi".into(), - input: vec![ResponseItem::Message { - id: Some("msg_1".into()), - role: "user".into(), - content: vec![ContentItem::InputText { text: "hi".into() }], - phase: None, - metadata: None, - }], - tools: Vec::new(), - tool_choice: "auto".into(), - parallel_tool_calls: false, - reasoning: None, - store: false, - stream: true, - include: Vec::new(), - service_tier: None, - prompt_cache_key: None, - text: None, - client_metadata: None, - }; - - let _stream = client - .stream_request(request, ResponsesOptions::default()) - .await?; - - let requests = state.take_stream_requests(); - assert_eq!(requests.len(), 1); - let body: serde_json::Value = serde_json::from_slice(request_body_bytes(&requests[0]))?; - assert_eq!(body["input"][0].get("id"), None); - Ok(()) -} - #[tokio::test] async fn streaming_client_adds_auth_headers() -> Result<()> { let state = RecordingState::default(); @@ -550,7 +505,7 @@ async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { } #[tokio::test] -async fn azure_store_sends_ids_when_enabled_and_headers() -> Result<()> { +async fn azure_store_sends_ids_and_headers() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth)); @@ -590,7 +545,6 @@ async fn azure_store_sends_ids_when_enabled_and_headers() -> Result<()> { extra_headers, compression: Compression::None, turn_state: None, - include_item_ids: true, }, ) .await?; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f2e8073ceb5e..d8f9df2f5382 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -61,7 +61,6 @@ use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; -use codex_api::response_request_json; use codex_app_server_protocol::AuthMode; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -524,7 +523,7 @@ impl ModelClient { let ResponsesApiRequest { model, instructions, - input, + mut input, tools, parallel_tool_calls, reasoning, @@ -533,6 +532,7 @@ impl ModelClient { text, .. } = request; + self.prepare_response_items_for_request(&mut input, /*store*/ false); let payload = ApiCompactionInput { model: &model, input: &input, @@ -576,7 +576,6 @@ impl ModelClient { extra_headers, compact_request_timeout, turn_state.as_deref(), - /*include_item_ids*/ self.state.item_ids_enabled, ) .await .map_err(map_api_error); @@ -835,8 +834,14 @@ impl ModelClient { Ok(request) } - fn include_item_ids_for_request(&self, request: &ResponsesApiRequest) -> bool { - self.state.item_ids_enabled || request.store + fn prepare_response_items_for_request(&self, input: &mut [ResponseItem], store: bool) { + if self.state.item_ids_enabled || store { + return; + } + + for item in input { + item.set_id(None); + } } /// Returns whether the Responses-over-WebSocket transport is active for this session. @@ -1042,7 +1047,6 @@ impl ModelClientSession { }, compression, turn_state: Some(Arc::clone(&self.turn_state)), - include_item_ids: self.client.state.item_ids_enabled, } } @@ -1309,7 +1313,7 @@ impl ModelClientSession { ) .await; - let request = self.client.build_responses_request( + let mut request = self.client.build_responses_request( &client_setup.api_provider, prompt, model_info, @@ -1318,12 +1322,12 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; - let include_item_ids = self.client.include_item_ids_for_request(&request); - options.include_item_ids = include_item_ids; + let store = request.store; + self.client + .prepare_response_items_for_request(&mut request.input, store); let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); - let trace_request = response_request_json(&request, include_item_ids)?; - inference_trace_attempt.record_started(&trace_request); + inference_trace_attempt.record_started(&request); let client = ApiResponsesClient::new( transport, client_setup.api_provider, @@ -1426,7 +1430,6 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; - let include_item_ids = self.client.include_item_ids_for_request(&request); let mut client_metadata = self .client .build_ws_client_metadata(responses_metadata, model_info.use_responses_lite); @@ -1489,15 +1492,18 @@ impl ModelClientSession { inference_trace.start_attempt() }; stamp_ws_stream_request_start_ms(&mut ws_request); - let trace_request = if previous_response_id_from_untraced_warmup { + let ResponsesWsRequest::ResponseCreate(ws_payload) = &mut ws_request; + let store = ws_payload.store; + self.client + .prepare_response_items_for_request(&mut ws_payload.input, store); + if previous_response_id_from_untraced_warmup { // The transport can reuse an untraced warmup response id and omit the // already-sent input, but rollout replay needs the logical model-visible // request rather than the compressed websocket delta. - response_request_json(&request, include_item_ids)? + inference_trace_attempt.record_started(&request); } else { - response_request_json(&ws_request, include_item_ids)? - }; - inference_trace_attempt.record_started(&trace_request); + inference_trace_attempt.record_started(&ws_request); + } self.websocket_session.last_request = Some(request); self.websocket_session.last_response_from_untraced_warmup = warmup; let websocket_connection = @@ -1511,7 +1517,6 @@ impl ModelClientSession { ws_request, self.websocket_session.connection_reused(), Some(Arc::clone(&self.turn_state)), - include_item_ids, ) .await .map_err(|err| { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index abac00a439fd..7c725fa151d6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2673,7 +2673,7 @@ impl Session { | ResponseItem::CompactionTrigger { .. } | ResponseItem::Other => continue, }; - item.set_id(format!("{prefix}_{}", Uuid::now_v7())); + item.set_id(Some(format!("{prefix}_{}", Uuid::now_v7()))); } } diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 996b7632ccf0..1ccbd302d8a4 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -157,7 +157,8 @@ async fn responses_websocket_streams_request() { let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); - let prompt = prompt_with_input(vec![message_item("hello")]); + let mut prompt = prompt_with_input(vec![message_item("hello")]); + prompt.input[0].set_id(Some("msg_existing".to_string())); stream_until_complete(&mut client_session, &harness, &prompt).await; @@ -169,6 +170,7 @@ async fn responses_websocket_streams_request() { assert_eq!(body["model"].as_str(), Some(MODEL)); assert_eq!(body["stream"], serde_json::Value::Bool(true)); assert_eq!(body["input"].as_array().map(Vec::len), Some(1)); + assert_eq!(body["input"][0].get("id"), None); let handshake = server.single_handshake(); assert_eq!( handshake.header(OPENAI_BETA_HEADER), diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index 3065ed178b10..80bc6dd32405 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -29,7 +29,9 @@ pub(crate) fn recent_input(items: &[ResponseItem]) -> Option { fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { match item { ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => { - messages.push(item.clone()); + let mut message = item.clone(); + message.set_id(None); + messages.push(message); } ResponseItem::AgentMessage { author, @@ -50,7 +52,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { } } ResponseItem::Message { - id, + id: _, role, content, phase, @@ -65,7 +67,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { .collect::>(); if !content.is_empty() { messages.push(ResponseItem::Message { - id: id.clone(), + id: None, role: role.clone(), content, phase: phase.clone(), @@ -108,11 +110,15 @@ mod tests { #[test] fn keeps_current_user_and_previous_visible_turn() { + let mut previous_user = message(USER_ROLE, "previous user"); + previous_user.set_id(Some("msg_previous_user".to_string())); + let mut previous_assistant = message(ASSISTANT_ROLE, "previous assistant"); + previous_assistant.set_id(Some("msg_previous_assistant".to_string())); let items = vec![ message("system", "system"), message(USER_ROLE, "old user"), message(ASSISTANT_ROLE, "old assistant"), - message(USER_ROLE, "previous user"), + previous_user, ResponseItem::FunctionCall { id: None, name: "tool".to_string(), @@ -121,7 +127,7 @@ mod tests { call_id: "call-1".to_string(), metadata: None, }, - message(ASSISTANT_ROLE, "previous assistant"), + previous_assistant, message("developer", "developer"), message(USER_ROLE, "current user"), message(ASSISTANT_ROLE, "current commentary"), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ead2d5330b14..dc97e7c55d0a 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1168,8 +1168,8 @@ impl ResponseItem { } } - /// Sets the Responses API item ID for variants that carry one. - pub fn set_id(&mut self, new_id: String) { + /// Sets or clears the Responses API item ID for variants that carry one. + pub fn set_id(&mut self, new_id: Option) { match self { Self::Message { id, .. } | Self::AgentMessage { id, .. } @@ -1184,7 +1184,7 @@ impl ResponseItem { | Self::Reasoning { id, .. } | Self::ImageGenerationCall { id, .. } | Self::Compaction { id, .. } - | Self::ContextCompaction { id, .. } => *id = Some(new_id), + | Self::ContextCompaction { id, .. } => *id = new_id, Self::CompactionTrigger { .. } | Self::Other => {} } } @@ -2132,9 +2132,13 @@ mod tests { let mut item = response_item_with_metadata(/*metadata*/ None); assert_eq!(item.id(), None); - item.set_id("msg_test".to_string()); + item.set_id(Some("msg_test".to_string())); assert_eq!(item.id(), Some("msg_test")); + + item.set_id(None); + + assert_eq!(item.id(), None); } fn response_item_with_metadata(metadata: Option) -> ResponseItem { From 5233e4da7db737cb2cf1fb5aeca7dbca22946506 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 18 Jun 2026 15:05:58 -0700 Subject: [PATCH 14/14] codex: fix CI failure on PR #28814 --- codex-rs/core/src/client.rs | 2 +- codex-rs/ext/web-search/src/history.rs | 2 +- codex-rs/protocol/src/models.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d8f9df2f5382..fe290c16bf2b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -840,7 +840,7 @@ impl ModelClient { } for item in input { - item.set_id(None); + item.set_id(/*new_id*/ None); } } diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index 80bc6dd32405..df3faf123c3a 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -30,7 +30,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { match item { ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => { let mut message = item.clone(); - message.set_id(None); + message.set_id(/*new_id*/ None); messages.push(message); } ResponseItem::AgentMessage { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index dc97e7c55d0a..b0d373cfb053 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -2136,7 +2136,7 @@ mod tests { assert_eq!(item.id(), Some("msg_test")); - item.set_id(None); + item.set_id(/*new_id*/ None); assert_eq!(item.id(), None); }