From e79d72d75d479c1aceaf6a5853833e7ddad33c29 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 10:05:11 -0700 Subject: [PATCH 01/10] remove flag for image preparation (#29429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - make Fjord's centralized response-item image preparation unconditional for new and resumed history - have local user images and `view_image` outputs always defer decoding and resizing to that path - retain `resize_all_images` as an ignored, removed compatibility key for released clients - delete the flag-off producer paths and obsolete policy-specific tests ## Why Centralized preparation is now the intended image path. Keeping the runtime feature checks also kept two image-processing implementations alive and allowed client config to select the legacy behavior. This is a clean replacement for #28975, rebuilt from the latest `main`. ## How `prepare_response_items` now runs whenever items enter history and whenever persisted history is reconstructed. Producers emit deferred image data, so malformed images become the existing model-visible placeholder instead of failing the session at the producer. ## Test plan - `just fmt` - `just fix -p codex-core -p codex-features` - `just test -p codex-features` — 52 passed - focused affected `codex-core` set — 20 passed - `just test -p codex-core handle_accepts_explicit_high_detail` — 1 passed - full `just test -p codex-core` attempt — 2,723 passed; 88 unrelated environment failures from read-only `~/.codex` SQLite state and unavailable integration helper binaries --- .../tests/suite/v2/dynamic_tools.rs | 8 +- .../tests/suite/v2/imagegen_extension.rs | 10 +- codex-rs/core/src/prompt_debug.rs | 2 +- codex-rs/core/src/session/mod.rs | 43 ++----- codex-rs/core/src/session/tests.rs | 14 +-- .../core/src/tools/handlers/view_image.rs | 28 +---- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/code_mode.rs | 20 +-- codex-rs/core/tests/suite/compact.rs | 2 +- .../core/tests/suite/extension_sandbox.rs | 10 +- codex-rs/core/tests/suite/image_rollout.rs | 2 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- codex-rs/core/tests/suite/responses_lite.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 5 - codex-rs/core/tests/suite/truncation.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 114 +----------------- codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 17 +++ 18 files changed, 72 insertions(+), 221 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index ef1cf86753..2adc2be0b2 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -38,6 +38,8 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. #[cfg(any(target_os = "macos", windows))] @@ -650,7 +652,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]; let content_items = response_content_items @@ -695,7 +697,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]) ); @@ -721,7 +723,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( }, { "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": TINY_PNG_DATA_URL, "detail": "high" } ]) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 5c18aa8a4e..7a29bb3542 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -28,13 +28,13 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const RESULT: &str = "cG5n"; +const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; -const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -116,7 +116,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul assert_eq!(status, "completed"); assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); assert_eq!(result, RESULT); - assert_eq!(std::fs::read(&saved_path)?, b"png"); + assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES); let requests = response_mock.requests(); assert_eq!(requests.len(), 2); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index bd9289b770..307115c505 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -81,7 +81,7 @@ pub(crate) async fn build_prompt_input_from_session( .await; if !input.is_empty() { - let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); + let response_item = sess.response_item_from_user_input(input); sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item)) .await; } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 42eb0c29e3..32fcfd09e2 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1372,17 +1372,11 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - // Keep the recorded rollout unchanged. Prepare its reconstructed history before - // installing it, so legacy images are processed once for this resume or fork and - // will be processed again if the rollout is reconstructed in a future session. - // This meets image resizing requirements without modifying persisted rollouts. - prepare_response_items(&mut history); - } + // Keep the recorded rollout unchanged. Prepare its reconstructed history before + // installing it, so legacy images are processed once for this resume or fork and + // will be processed again if the rollout is reconstructed in a future session. + // This meets image resizing requirements without modifying persisted rollouts. + prepare_response_items(&mut history); { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); @@ -2689,13 +2683,7 @@ impl Session { items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - prepare_response_items(items.to_mut()); - } + prepare_response_items(items.to_mut()); if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) } else { @@ -2734,23 +2722,10 @@ impl Session { items } - pub(crate) fn response_item_from_user_input( - &self, - turn_context: &TurnContext, - input: Vec, - ) -> ResponseItem { - let local_image_preparation = if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - LocalImagePreparation::Defer - } else { - LocalImagePreparation::Process - }; + pub(crate) fn response_item_from_user_input(&self, input: Vec) -> ResponseItem { ResponseItem::from(ResponseInputItem::from_user_input( input, - local_image_preparation, + LocalImagePreparation::Defer, )) } @@ -3666,7 +3641,7 @@ impl Session { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. - let response_item = self.response_item_from_user_input(turn_context, input.to_vec()); + let response_item = self.response_item_from_user_input(input.to_vec()); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; let mut user_message_item = UserMessageItem::new(input); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index edd5ace9ed..5c56f03d02 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1694,12 +1694,11 @@ async fn record_initial_history_reconstructs_resumed_transcript() { } #[tokio::test] -async fn resize_all_images_prepares_failures_before_history_insertion() { +async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { - let _ = config.features.enable(Feature::ResizeAllImages); let _ = config.features.enable(Feature::ItemIds); }, ) @@ -1764,15 +1763,8 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { } #[tokio::test] -async fn resize_all_images_prepares_resumed_history_before_installing_it() { - let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, - ) - .await; +async fn prepares_resumed_history_before_installing_it() { + let (session, _turn_context) = make_session_and_context().await; let resumed_item = ResponseItem::Message { id: None, role: "user".to_string(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 84911fb815..09a6844f19 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,4 +1,3 @@ -use codex_features::Feature; use codex_protocol::items::ImageViewItem; use codex_protocol::items::TurnItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -8,9 +7,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; -use codex_utils_image::PromptImageMode; use codex_utils_image::data_url_from_bytes; -use codex_utils_image::load_for_prompt_bytes; use serde::Deserialize; use crate::function_tool::FunctionCallError; @@ -195,24 +192,8 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - let image_url = if turn.config.features.enabled(Feature::ResizeAllImages) { - // The history insertion path owns image decoding and resizing when this is enabled. - data_url_from_bytes("application/octet-stream", &file_bytes) - } else { - let image_mode = if use_original_detail { - PromptImageMode::Original - } else { - PromptImageMode::ResizeToFit - }; - load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode) - .map_err(|error| { - FunctionCallError::RespondToModel(format!( - "unable to process image at `{}`: {error}", - abs_path.display() - )) - })? - .into_data_url() - }; + // The history insertion path owns image decoding and resizing. + let image_url = data_url_from_bytes("application/octet-stream", &file_bytes); let item = TurnItem::ImageView(ImageViewItem { id: call_id, @@ -420,9 +401,6 @@ mod tests { }) .await; - let Err(FunctionCallError::RespondToModel(message)) = result else { - panic!("expected image processing error"); - }; - assert!(message.contains("unable to process image"), "{message}"); + result.expect("explicit high detail should be accepted"); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f9b2fe42ef..3c905351cc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -860,7 +860,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { async fn resume_replays_image_tool_outputs_with_detail() { skip_if_no_network!(); - let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; let thread_id = ThreadId::default(); @@ -888,7 +888,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { id: None, name: "view_image".to_string(), namespace: None, - arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), + arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), metadata: None, }), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a765a0e26..14bee0fb8d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2681,7 +2681,7 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { &server, "use exec to return images", r#" -image("data:image/png;base64,AAA"); +image("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="); "#, ) .await?; @@ -2706,7 +2706,7 @@ image("data:image/png;base64,AAA"); items[1], serde_json::json!({ "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "detail": "high" }), ); @@ -2715,17 +2715,14 @@ image("data:image/png;base64,AAA"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { +async fn code_mode_replaces_malformed_image() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn( &server, "use exec to return an image", r#"image("data:image/png;base64,AAA");"#, - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, ) .await?; @@ -2746,7 +2743,7 @@ async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result<()> { +async fn code_mode_resizes_explicit_original_image() -> Result<()> { skip_if_no_network!(Ok(())); let original_dimensions = (6401, 100); @@ -2772,12 +2769,7 @@ async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result "use exec to return a large original-detail image", &code, "gpt-5.3-codex", - |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); - }, + |_| {}, ) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index e40a13dcd4..fb20603850 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -4403,7 +4403,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ) .await .expect("override thread settings"); - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e4c01557de..aa835fa9ac 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -42,9 +42,11 @@ use wiremock::matchers::path; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; +const TINY_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn image_generation_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); @@ -148,7 +150,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { .and(path("/v1/images/edits")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "created": 1, - "data": [{"b64_json": "cG5n"}], + "data": [{"b64_json": TINY_PNG_BASE64}], }))) .expect(1) .mount(&server) @@ -299,7 +301,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { let output = request.function_call_output(image_call_id); let image = &output["output"][0]; assert_eq!(image["type"], "input_image"); - assert_eq!(image["image_url"], "data:image/png;base64,cG5n"); + assert_eq!(image["image_url"], TINY_PNG_DATA_URL); Ok(()) } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2b18559075..c97e8cc896 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -200,7 +200,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> .. } = test_codex().build(&server).await?; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string(); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(); let response = sse(vec![ ev_response_created("resp-1"), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index ef7a305bd7..4688434a4d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -506,7 +506,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let _ = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); test.codex diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc73..db4faa675e 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -55,7 +55,7 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> Result<()> { +async fn responses_lite_strips_data_image_detail() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dde226a194..2b4e09bd1c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -24,7 +24,6 @@ use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; -use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_models_manager::manager::RefreshStrategy; @@ -1455,10 +1454,6 @@ async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> { let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); insert_mcp_server( config, server_name, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cc942cbc17..c7e296ec0c 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -474,7 +474,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL - let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; + let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 1857eedefd..c2c7b562d8 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -7,7 +7,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -181,15 +180,10 @@ async fn write_workspace_png( async fn assert_user_turn_local_image_resizes_to( original_dimensions: (u32, u32), expected_dimensions: (u32, u32), - resize_policy: TestImageResizePolicy, ) -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - if resize_policy == TestImageResizePolicy::AllImages { - let _ = config.features.enable(Feature::ResizeAllImages); - } - }); + let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { codex, @@ -263,42 +257,25 @@ async fn assert_user_turn_local_image_resizes_to( Ok(()) } -#[derive(Clone, Copy, Eq, PartialEq)] -enum TestImageResizePolicy { - Legacy, - AllImages, -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768), TestImageResizePolicy::Legacy) - .await + assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (1024, 4096), - (512, 2048), - TestImageResizePolicy::Legacy, - ) - .await + assert_user_turn_local_image_resizes_to((1024, 4096), (512, 2048)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_applies_patch_budget_to_local_user_image() -> anyhow::Result<()> { +async fn user_turn_local_image_applies_patch_budget() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (2048, 2048), - (1600, 1600), - TestImageResizePolicy::AllImages, - ) - .await + assert_user_turn_local_image_resizes_to((2048, 2048), (1600, 1600)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1209,11 +1186,10 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { +async fn view_image_tool_turns_invalid_image_into_placeholder() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { @@ -1222,84 +1198,6 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { .. } = &test; - let rel_path = "assets/example.json"; - let abs_path = - write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; - - let call_id = "view-image-non-image"; - let arguments = serde_json::json!({ "path": rel_path }).to_string(); - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "view_image", &arguments), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(disabled_user_turn( - &test, - vec![UserInput::Text { - text: "please use the view_image tool to read the json file".into(), - text_elements: Vec::new(), - }], - session_model, - )) - .await?; - - wait_for_event_with_timeout( - codex, - |event| matches!(event, EventMsg::TurnComplete(_)), - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - - let request = mock.single_request(); - assert!( - request.inputs_of_type("input_image").is_empty(), - "non-image file should not produce an input_image message" - ); - let (error_text, success) = request - .function_call_output_content_and_success(call_id) - .expect("function_call_output should be present"); - assert_eq!(success, None); - let error_text = error_text.expect("error text present"); - - let expected_error = format!( - "unable to process image at `{}`: unsupported image `application/json`", - abs_path.display() - ); - assert!( - error_text.contains(&expected_error), - "error should describe unsupported file type: {error_text}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_turns_invalid_view_image_into_placeholder() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }); - let test = builder.build_with_remote_env(&server).await?; - let TestCodex { - codex, - session_configured, - .. - } = &test; - let rel_path = "assets/invalid-image.json"; write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; let call_id = "view-image-invalid-placeholder"; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 75808e49d0..933ec73c60 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -193,7 +193,7 @@ pub enum Feature { ImageGeneration, /// Replace hosted image generation with the standalone image-generation extension. ImageGenExt, - /// Resize all inline data-URL images before recording them in history. + /// Removed compatibility flag for always-on centralized image preparation. ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, @@ -472,7 +472,7 @@ impl Features { "tool_search" | "apps_mcp_path_override" => { continue; } - "image_detail_original" => { + "image_detail_original" | "resize_all_images" => { continue; } "plugin_hooks" => { @@ -1160,8 +1160,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::ItemIds, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 476c80aedc..b9902320bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -476,6 +476,23 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_resize_all_images_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("resize_all_images".to_string(), false)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn from_sources_ignores_removed_undo_feature_key() { let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)])); From c1f8b280b5cfc280c75682cab1b5be4a4dc58709 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 10:26:30 -0700 Subject: [PATCH 02/10] ci: restore custom Windows runner with hermetic LLVM 0.7.9 (#29143) The custom Windows argument-comment-lint job was temporarily moved to `windows-2022` in #28940 after hermetic LLVM source extraction failed on the newer runner. This takes the upstream extraction fix so the job can return to the intended custom runner. This upgrades `llvm` to `0.7.9` and `rules_cc` to `0.2.18`, refreshes the module lock, rebases the remaining Windows and custom libc++ patches, drops the obsolete symlink-extraction workaround, and restores the `windows-x64` runner configuration. Validation: - Verified all LLVM patches apply cleanly against the `0.7.9` source. - Built `@llvm-project//compiler-rt:clang_rt.builtins.static`. --- .github/workflows/rust-ci.yml | 5 ++- MODULE.bazel | 11 +++--- MODULE.bazel.lock | 12 ++++-- patches/BUILD.bazel | 2 +- patches/llvm_windows_arm64_powl.patch | 13 ++++--- ....patch => llvm_windows_mingw_compat.patch} | 38 ++++++------------- 6 files changed, 37 insertions(+), 44 deletions(-) rename patches/{llvm_windows_symlink_extract.patch => llvm_windows_mingw_compat.patch} (51%) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 59a7820151..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,8 +173,11 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-2022 + runner: windows-x64 timeout_minutes: 30 + runs_on: + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/MODULE.bazel b/MODULE.bazel index e9ec662d09..a24f93afd8 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,18 +2,17 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.7.1") +bazel_dep(name = "llvm", version = "0.7.9") -# The upstream LLVM archive contains a few unix-only symlink entries and is -# missing a couple of MinGW compatibility archives that windows-gnullvm needs -# during extraction and linking, so patch it until upstream grows native support. +# Patch hermetic LLVM for Codex's custom libc++ and Windows gnullvm runtime +# needs that have not landed upstream. single_version_override( module_name = "llvm", patch_strip = 1, patches = [ "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_arm64_powl.patch", - "//patches:llvm_windows_symlink_extract.patch", + "//patches:llvm_windows_mingw_compat.patch", ], ) @@ -80,7 +79,7 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") -bazel_dep(name = "rules_cc", version = "0.2.16") +bazel_dep(name = "rules_cc", version = "0.2.18") single_version_override( module_name = "rules_cc", patch_strip = 1, diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index be8d988f5d..1826001540 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -45,6 +45,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", + "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91", "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", "https://bcr.bazel.build/modules/bazel_features/1.45.0/source.json": "635e4536e09ff125b8972e0fa239c135fde5f18701f7d5115680560651dfb41d", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", @@ -63,6 +64,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", @@ -87,8 +89,8 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/llvm/0.7.0/MODULE.bazel": "3c07a4e5734b0ad41fe24dedaacbf3a35ce4377b7e1a21f24488a0c9ac4f1e6b", - "https://bcr.bazel.build/modules/llvm/0.7.1/MODULE.bazel": "74ac75efc6385b8a95d83bfa36ad399500f747c3d0f50287f9b6f9e854ec4814", - "https://bcr.bazel.build/modules/llvm/0.7.1/source.json": "0cac59d04dafa0ca1f70ea21cf1569e034ef8736c2c7510e834a9e8141d7e631", + "https://bcr.bazel.build/modules/llvm/0.7.9/MODULE.bazel": "452b272a6b5ecc8747db8e5b7fd9d6799ef0db17b8d437aa8d4dda4f56a3e9ea", + "https://bcr.bazel.build/modules/llvm/0.7.9/source.json": "0c098ab546a8c4b0a720dcb010714e13f9ba6ceab0d23a87b7f1083788928c31", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", @@ -143,7 +145,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", @@ -221,8 +224,9 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/MODULE.bazel": "452a22d7f02b1c9d7a22ab25edf20f46f3e1101f0f67dc4bfbf9a474ddf02445", - "https://bcr.bazel.build/modules/tar.bzl/0.9.0/source.json": "c732760a374831a2cf5b08839e4be75017196b4d796a5aa55235272ee17cd839", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index a431c3f3a6..ed14137170 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -6,7 +6,7 @@ exports_files([ "bzip2_windows_stack_args.patch", "llvm_rusty_v8_custom_libcxx.patch", "llvm_windows_arm64_powl.patch", - "llvm_windows_symlink_extract.patch", + "llvm_windows_mingw_compat.patch", "rules_rust_windows_bootstrap_process_wrapper_linker.patch", "rules_rust_windows_build_script_runner_paths.patch", "rules_rust_windows_exec_bin_target.patch", diff --git a/patches/llvm_windows_arm64_powl.patch b/patches/llvm_windows_arm64_powl.patch index 5e12bd7d3b..0fd33c9766 100644 --- a/patches/llvm_windows_arm64_powl.patch +++ b/patches/llvm_windows_arm64_powl.patch @@ -15,10 +15,11 @@ diff --git a/runtimes/mingw/crt_sources.bzl b/runtimes/mingw/crt_sources.bzl diff --git a/toolchain/args/windows/BUILD.bazel b/toolchain/args/windows/BUILD.bazel --- a/toolchain/args/windows/BUILD.bazel +++ b/toolchain/args/windows/BUILD.bazel -@@ -49,5 +49,6 @@ cc_args( +@@ -45,5 +45,6 @@ cc_args( + args = [ + "-L{mingw_import_library_search_path}", + "-L{mingw_crt_library_search_path}", +- + "-lmingwex", - # For now, we force on ucrt. - # TODO(cerisier): make this either a constraint or a build setting - "-lucrt", - ], - data = [ ++ + # Clang will respect the user's chosen CRT variant. diff --git a/patches/llvm_windows_symlink_extract.patch b/patches/llvm_windows_mingw_compat.patch similarity index 51% rename from patches/llvm_windows_symlink_extract.patch rename to patches/llvm_windows_mingw_compat.patch index 9f548636b2..66cf4e7b34 100644 --- a/patches/llvm_windows_symlink_extract.patch +++ b/patches/llvm_windows_mingw_compat.patch @@ -1,26 +1,11 @@ -diff --git a/extensions/llvm_source.bzl b/extensions/llvm_source.bzl -index 89dcf81..cf27c92 100644 ---- a/extensions/llvm_source.bzl -+++ b/extensions/llvm_source.bzl -@@ -83,6 +83,13 @@ def _llvm_source_archive_excludes(): - "offload", - "libc/docs", - "libc/utils/gn", -+ # These entries are unix symlinks in the upstream source tarball. -+ # Windows tar.exe cannot materialize them during repository extraction, -+ # but nothing in our Bazel usage needs the symlink entrypoints. -+ "llvm/utils/mlgo-utils/extract_ir.py", -+ "llvm/utils/mlgo-utils/make_corpus.py", -+ "llvm/utils/mlgo-utils/combine_training_corpus.py", -+ "llvm/docs/_themes/llvm-theme", - ] - - test_docs_subprojects = [ +# What: provide MinGW compatibility archives required by Windows gnullvm links. +# Why: clang and prebuilt MSVC archives can request these names even though the +# hermetic MinGW runtime does not publish them by default. + diff --git a/runtimes/mingw/BUILD.bazel b/runtimes/mingw/BUILD.bazel -index ebd99db..9eb5d5b 100644 --- a/runtimes/mingw/BUILD.bazel +++ b/runtimes/mingw/BUILD.bazel -@@ -334,6 +334,30 @@ stub_library( +@@ -386,14 +386,42 @@ stub_library( name = "stdc++", ) @@ -51,14 +36,15 @@ index ebd99db..9eb5d5b 100644 copy_to_directory( name = "mingw_crt_library_search_directory", srcs = [ -@@ -344,6 +364,10 @@ copy_to_directory( ++ ":libcmt", + ":m", + ":mingw32", + ":mingwex", ":moldname", + ":oldnames", ":pthread", - ":stdc++", + ":ssp", + ":ssp_nonshared", -+ ":libcmt", - ":ucrt", - ":ucrtbase", - ":ucrtbased", + ":stdc++", + ":uuid", + ":winpthread", From 44dbae90eb5a2e3e047d9636bf2463f3beb00fa8 Mon Sep 17 00:00:00 2001 From: jameswt-oai Date: Mon, 22 Jun 2026 10:27:23 -0700 Subject: [PATCH 03/10] [codex] Centralize Plugin Analytics Metadata (#27102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR moves construction of `PluginTelemetryMetadata` from loader and model helpers into `PluginsManager`, which already owns installed plugin state and will eventually perform remote identity enrichment. The metadata type remains in `codex-plugin`, and serialized analytics events remain unchanged. ## Before ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end subgraph Construction["Metadata construction"] direction TB Loader["Loader telemetry helpers"] Summary["PluginCapabilitySummary::telemetry_metadata"] Override["Caller adds remote_plugin_id"] end Metadata["PluginTelemetryMetadata"] Lifecycle --> Loader Config --> Loader Remote --> Loader Loader -->|"local events"| Metadata Loader -->|"remote install"| Override Override --> Metadata Used --> Summary Summary --> Metadata ``` Telemetry metadata was constructed through loader helpers, a capability-summary method, and a remote-install call-site override. ## After ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end Manager["PluginsManager — single construction owner"] Metadata["PluginTelemetryMetadata"] Lifecycle --> Manager Config --> Manager Remote -->|"authoritative remote ID"| Manager Used -->|"capability summary"| Manager Manager --> Metadata ``` Every analytics path delegates metadata construction to `PluginsManager`. Remote install still supplies its authoritative backend ID explicitly. ## What Changes - Make loader code return a focused plugin capability summary instead of constructing analytics metadata. - Centralize immutable plugin telemetry metadata construction in `PluginsManager`. - Route local install/uninstall, remote install, enable/disable, and plugin-used emitters through the manager. - Preserve the current serialized analytics contract exactly. Normal metadata still has no remote override. Remote install continues to provide its authoritative backend ID explicitly, so the existing serializer continues reporting that ID through `plugin_id`. Snapshot-based enrichment is intentionally deferred to the final PR. ## Testing - `just test -p codex-core-plugins` (238 tests passed) - `just test -p codex-plugin` (3 tests passed) - Scoped Clippy/compile checks passed for `codex-plugin`, `codex-core-plugins`, `codex-app-server`, and `codex-core`. ## Split Overview ```text main ├── #27093 Debug analytics capture (merged) ├── #27099 Non-mutating plugin smoke (merged) ├── #27100 Remote install/uninstall smoke (merged) └── #27102 Plugin telemetry metadata refactor ← you are here └── #27669 Persist remote plugin identity After #27102 and #27669 merge: └── Final PR: add explicit local and remote IDs to plugin analytics ``` Review order and dependencies: 1. [#27093 Add debug-only analytics event capture](https://github.com/openai/codex/pull/27093) (merged) 2. [#27099 Add a plugin analytics smoke workflow](https://github.com/openai/codex/pull/27099) (merged) 3. [#27100 Add a remote plugin analytics mutation smoke workflow](https://github.com/openai/codex/pull/27100) (merged) 4. This metadata refactor, independent and based on `main` 5. [#27669 Persist remote plugin identity](https://github.com/openai/codex/pull/27669), stacked on this PR 6. Final remote-ID behavior PR, created after the prerequisites merge The original [#26281](https://github.com/openai/codex/pull/26281) remains open as the aggregate reference until the final replacement PR is published. --- .../analytics/src/analytics_client_tests.rs | 9 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/config_processor.rs | 9 +- .../src/request_processors/plugins.rs | 18 ++-- codex-rs/core-plugins/src/loader.rs | 47 +++------- codex-rs/core-plugins/src/manager.rs | 72 ++++++++++++++-- codex-rs/core-plugins/src/manager_tests.rs | 85 +++++++++++++++++-- codex-rs/core/src/session/turn.rs | 17 ++-- codex-rs/plugin/src/lib.rs | 22 ----- 9 files changed, 189 insertions(+), 91 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a9a1d90e74..091a85e11f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3504,10 +3504,11 @@ async fn reducer_ingests_plugin_install_failed_fact() { async fn reducer_ingests_plugin_install_failed_fact_without_detail() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - let mut plugin = PluginTelemetryMetadata::from_plugin_id( - &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), - ); - plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + let plugin = PluginTelemetryMetadata { + plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), + capability_summary: None, + }; reducer .ingest( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index c8bd48c474..2972d05f09 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -337,7 +337,6 @@ use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::PluginsManager; use codex_core_plugins::loader::load_plugin_apps; use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 15c3b7c864..50bba54009 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -295,15 +295,14 @@ impl ConfigRequestProcessor { &self, pending_changes: std::collections::BTreeMap, ) { + let plugins_manager = self.thread_manager.plugins_manager(); for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { continue; }; - let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( - self.config_manager.codex_home(), - &plugin_id, - ) - .await; + let metadata = plugins_manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; if enabled { self.analytics_events_client.track_plugin_enabled(metadata); } else { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index e9bf044766..1e4716a88d 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,7 +23,6 @@ use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; -use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1620,9 +1619,14 @@ impl PluginRequestProcessor { Some(self.effective_plugins_changed_callback()), ); - let mut plugin_metadata = - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone()); + let plugin_metadata = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_installed_plugin_with_remote_id( + &result.plugin_id, + &remote_plugin_id, + ) + .await; self.analytics_events_client .track_plugin_installed(plugin_metadata); @@ -1713,8 +1717,10 @@ impl PluginRequestProcessor { else { return; }; - let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); - plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + let plugin = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id); self.analytics_events_client .track_plugin_install_failed(plugin, error_type.to_string()); } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 54eb371686..2a8d44fc97 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -38,7 +38,6 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; -use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; @@ -1164,13 +1163,11 @@ fn cleaned_app_category(category: Option) -> Option { .filter(|category| !category.is_empty()) } -pub async fn plugin_telemetry_metadata_from_root( +pub async fn plugin_capability_summary_from_root( plugin_id: &PluginId, plugin_root: &AbsolutePathBuf, -) -> PluginTelemetryMetadata { - let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; +) -> Option { + let manifest = load_plugin_manifest(plugin_root.as_path())?; let manifest_paths = &manifest.paths; let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); @@ -1192,18 +1189,14 @@ pub async fn plugin_telemetry_metadata_from_root( .await; let app_connector_ids = app_connector_ids_from_declarations(&app_declarations); - PluginTelemetryMetadata { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: Some(PluginCapabilitySummary { - config_name: plugin_id.as_key(), - display_name: plugin_id.plugin_name.clone(), - description: None, - has_skills, - mcp_server_names, - app_connector_ids, - }), - } + Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids, + }) } pub async fn load_plugin_mcp_servers( @@ -1279,24 +1272,6 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest( mcp_servers } -pub async fn installed_plugin_telemetry_metadata( - codex_home: &Path, - plugin_id: &PluginId, -) -> PluginTelemetryMetadata { - let store = match PluginStore::try_new(codex_home.to_path_buf()) { - Ok(store) => store, - Err(err) => { - warn!("failed to resolve plugin cache root: {err}"); - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - } - }; - let Some(plugin_root) = store.active_plugin_root(plugin_id) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; - - plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await -} - async fn load_mcp_servers_from_file( plugin_root: &Path, mcp_config_path: &AbsolutePathBuf, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b502106ea9..19b48b2c01 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,7 +6,6 @@ use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; -use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; @@ -15,7 +14,7 @@ use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; -use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::plugin_capability_summary_from_root; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -709,6 +708,66 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub async fn telemetry_metadata_for_installed_plugin( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + let mut metadata = self.telemetry_metadata_for_plugin_id(plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub async fn telemetry_metadata_for_installed_plugin_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + let mut metadata = + self.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub fn telemetry_metadata_for_plugin_id( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + remote_plugin_id: None, + capability_summary: None, + } + } + + pub fn telemetry_metadata_for_plugin_id_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..self.telemetry_metadata_for_plugin_id(plugin_id) + } + } + + pub fn telemetry_metadata_for_capability_summary( + &self, + summary: &PluginCapabilitySummary, + ) -> Option { + let plugin_id = PluginId::parse(&summary.config_name).ok()?; + Some(PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(summary.clone()), + }) + } + pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, visible_marketplaces: &[&str], @@ -1281,7 +1340,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( - PluginTelemetryMetadata::from_plugin_id(plugin_id), + self.telemetry_metadata_for_plugin_id(plugin_id), error_type.to_string(), ); } @@ -1351,7 +1410,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_installed( - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path) + self.telemetry_metadata_for_installed_plugin(&result.plugin_id) .await, ); } @@ -1392,7 +1451,10 @@ impl PluginsManager { async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { - Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await) + Some( + self.telemetry_metadata_for_installed_plugin(&plugin_id) + .await, + ) } else { None }; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 4f448ff160..50a5fe3e73 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -845,6 +845,81 @@ remote_plugin = true assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn installed_plugin_telemetry_metadata_collects_capabilities() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "test", "sample"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + +#[tokio::test] +async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: None, + } + ); +} + +#[test] +fn capability_summary_telemetry_metadata_uses_local_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let summary = PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "Linear".to_string(), + description: Some("Track work".to_string()), + has_skills: true, + mcp_server_names: vec!["linear".to_string()], + app_connector_ids: vec![AppConnectorId("linear-app".to_string())], + }; + + let metadata = manager.telemetry_metadata_for_capability_summary(&summary); + + assert_eq!( + metadata, + Some(PluginTelemetryMetadata { + plugin_id: PluginId::parse("linear@openai-curated-remote") + .expect("plugin id should parse"), + remote_plugin_id: None, + capability_summary: Some(summary), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); @@ -1154,14 +1229,14 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), @@ -1174,7 +1249,7 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { } #[tokio::test] -async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { +async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1195,14 +1270,14 @@ async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("counter-sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "counter-sample@test".to_string(), display_name: "counter-sample".to_string(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9b9c7cc66e..81f9f52356 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -656,13 +656,16 @@ async fn build_skills_and_plugins( sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); + for summary in &mentioned_plugins { + if let Some(plugin) = sess + .services + .plugins_manager + .telemetry_metadata_for_capability_summary(summary) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } } let mut injection_items: Vec = match injected_host_skill_prompts { diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06..1a4eb23af1 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -76,25 +76,3 @@ pub struct PluginTelemetryMetadata { pub remote_plugin_id: Option, pub capability_summary: Option, } - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - remote_plugin_id: None, - capability_summary: Some(self.clone()), - }) - } -} From f6fa2593126d726fed67920e24eadc5c5c12bf0d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 10:30:16 -0700 Subject: [PATCH 04/10] TUI Plugin Sharing 3 - render remote plugin catalog sections (#26703) ## Summary [#26701](https://github.com/openai/codex/pull/26701) added remote plugin identity support, [#26702](https://github.com/openai/codex/pull/26702) added remote-section fetching and state, and [#28768](https://github.com/openai/codex/pull/28768) extracted the catalog rendering module. This PR builds the product-facing `/plugins` catalog on that foundation so remote records appear as OpenAI Curated, Workspace, and Shared with me sections rather than backend marketplace implementation details. Plugin details remain read-only for sharing metadata. This PR does not add share-authoring actions or change the app-server protocol. ## Changes - Renders OpenAI Curated, Workspace, and Shared with me sections with loading, empty, and error states. - Preserves section selection and stable tab ordering as remote sections transition between fallback and populated states. - Shows OpenAI Curated loading only when the explicit vertical fallback request was issued. - Centralizes remote marketplace identity matching around the existing marketplace constants. - Uses product labels for remote marketplaces and identifies the personal marketplace as Local by its path. - Shows read-only source, authentication, version, and sharing metadata in plugin detail views. - Applies narrow display deduplication for local and remote records sharing a remote plugin ID: - installed records take precedence; - local mapped sources are preferred for details only when their installed state matches the selected record. - Returns from detail and confirmation views through the current plugin cache so newly loaded remote sections are not overwritten by an older captured response. - Keeps admin-disabled plugins view-only and labels default-installed plugins as Available by default. ## Tests New tests: - `plugins_popup_admin_disabled_available_plugin_has_view_only_hint` - `plugins_popup_remote_section_fallback_states_snapshot` - `plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled` Updated existing plugin catalog tests and snapshots for product labels, detail metadata, personal-marketplace labeling, and stable tab ordering. Verification: - `cargo clippy -p codex-tui --all-targets -- -D warnings` ## Follow-ups - Local/remote duplicate normalization should eventually move into app-server. This PR intentionally keeps the compatibility behavior narrow and display-only. - PR5 will sanitize sensitive components before displaying Git source URLs. --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 807 +++++++++++++++--- codex-rs/tui/src/chatwidget/plugins.rs | 35 + ...ests__plugin_detail_popup_installable.snap | 4 +- ..._tests__plugin_detail_popup_installed.snap | 2 + ...ts__plugins_popup_curated_marketplace.snap | 8 +- ...ins_popup_newly_installed_marketplace.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 284 +++++- 9 files changed, 1003 insertions(+), 150 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 181e60d963..0830c69b0f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -507,6 +507,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::OpenPluginsList { cwd, response } => { + self.chat_widget.open_plugins_list(cwd, response); + } AppEvent::PluginRemoteSectionsLoaded { cwd, marketplaces, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 643cd9e2d8..26686c3b53 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -450,6 +450,12 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the plugin list from an already cached response. + OpenPluginsList { + cwd: PathBuf, + response: PluginListResponse, + }, + /// Result of explicitly fetching remote-backed plugin sections. PluginRemoteSectionsLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index c51e2e8ea9..a19ede3ab5 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use std::time::Instant; @@ -9,6 +10,7 @@ use super::plugins::PLUGINS_SELECTION_VIEW_ID; use super::plugins::PluginsCacheState; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -24,18 +26,24 @@ use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSharePrincipal; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -55,6 +63,179 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; +const REMOTE_LOADING_TAB_ID_PREFIX: &str = "remote-loading:"; +const REMOTE_EMPTY_TAB_ID_PREFIX: &str = "remote-empty:"; +const REMOTE_ERROR_TAB_ID_PREFIX: &str = "remote-error:"; +const WORKSPACE_SECTION_TAB_ORDER: u8 = 0; +const SHARED_WITH_ME_SECTION_TAB_ORDER: u8 = 1; +const SHARED_WITH_ME_LINK_SECTION_TAB_ORDER: u8 = 2; +const LOCAL_MARKETPLACE_TAB_ORDER: u8 = 3; +const OTHER_MARKETPLACE_TAB_ORDER: u8 = 4; + +#[derive(Debug, Clone)] +struct PreferredLocalPluginSource { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + installed: bool, +} + +#[derive(Debug, Clone, Copy)] +enum MarketplaceProduct { + OpenAiCurated, + Workspace, + SharedWithMe, + SharedWithMeLink, + Local, + Other, +} + +impl MarketplaceProduct { + fn from_marketplace(marketplace: &PluginMarketplaceEntry) -> Self { + Self::from_marketplace_parts(&marketplace.name, marketplace.path.as_ref()) + } + + fn from_marketplace_parts( + marketplace_name: &str, + marketplace_path: Option<&AbsolutePathBuf>, + ) -> Self { + if marketplace_path.is_some_and(is_personal_marketplace_path) { + return Self::Local; + } + + Self::from_marketplace_name(marketplace_name) + } + + fn from_marketplace_name(marketplace_name: &str) -> Self { + if is_openai_curated_marketplace_name(marketplace_name) + || marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME + { + return Self::OpenAiCurated; + } + + match marketplace_name { + REMOTE_WORKSPACE_MARKETPLACE_NAME => Self::Workspace, + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME => Self::SharedWithMe, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Self::SharedWithMeLink, + _ => Self::Other, + } + } + + fn label(self) -> Option<&'static str> { + match self { + Self::OpenAiCurated => Some("OpenAI Curated"), + Self::Workspace => Some("Workspace"), + Self::SharedWithMe => Some("Shared with me"), + Self::SharedWithMeLink => Some("Shared with me (link)"), + Self::Local => Some("Local"), + Self::Other => None, + } + } + + fn tab_order(self) -> u8 { + match self { + Self::Workspace => WORKSPACE_SECTION_TAB_ORDER, + Self::SharedWithMe => SHARED_WITH_ME_SECTION_TAB_ORDER, + Self::SharedWithMeLink => SHARED_WITH_ME_LINK_SECTION_TAB_ORDER, + Self::Local => LOCAL_MARKETPLACE_TAB_ORDER, + Self::OpenAiCurated | Self::Other => OTHER_MARKETPLACE_TAB_ORDER, + } + } + + fn is_by_openai(self) -> bool { + matches!(self, Self::OpenAiCurated) + } +} + +#[derive(Debug, Clone, Copy)] +struct RemoteMarketplaceSection { + id: &'static str, + label: &'static str, + loading_tab_id: &'static str, + marketplace_names: &'static [&'static str], + empty_item_name: &'static str, + empty_item_description: &'static str, + tab_order: u8, +} + +const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [ + RemoteMarketplaceSection { + id: "workspace", + label: "Workspace", + loading_tab_id: "workspace-loading", + marketplace_names: &[REMOTE_WORKSPACE_MARKETPLACE_NAME], + empty_item_name: "No workspace plugins available", + empty_item_description: "No workspace directory plugins are available.", + tab_order: WORKSPACE_SECTION_TAB_ORDER, + }, + RemoteMarketplaceSection { + id: "shared-with-me", + label: "Shared with me", + loading_tab_id: "shared-with-me-loading", + marketplace_names: &[ + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + ], + empty_item_name: "No shared plugins available", + empty_item_description: "No plugins have been shared with you.", + tab_order: SHARED_WITH_ME_SECTION_TAB_ORDER, + }, +]; + +impl RemoteMarketplaceSection { + fn fallback_tab( + self, + marketplaces: &[PluginMarketplaceEntry], + remote_sections_loading: bool, + remote_sections_loaded: bool, + section_errors: &[PluginRemoteSectionError], + ) -> Option<(u8, SelectionTab)> { + if marketplaces + .iter() + .any(|marketplace| self.contains_marketplace(&marketplace.name)) + { + return None; + } + + let tab = if remote_sections_loading { + remote_section_loading_tab(self.loading_tab_id, self.label) + } else if remote_sections_loaded { + if let Some(section_error) = plugin_remote_section_error(section_errors, self.id) { + remote_section_error_tab(section_error) + } else { + remote_section_empty_tab( + self.id, + self.label, + self.empty_item_name, + self.empty_item_description, + ) + } + } else { + return None; + }; + + Some((self.tab_order, tab)) + } + + fn contains_marketplace(self, marketplace_name: &str) -> bool { + self.marketplace_names.contains(&marketplace_name) + } + + fn is_fallback_tab_id(self, tab_id: &str) -> bool { + tab_id.strip_prefix(REMOTE_LOADING_TAB_ID_PREFIX) == Some(self.loading_tab_id) + || tab_id.strip_prefix(REMOTE_EMPTY_TAB_ID_PREFIX) == Some(self.id) + || tab_id.strip_prefix(REMOTE_ERROR_TAB_ID_PREFIX) == Some(self.id) + } + + fn contains_tab_id(self, tab_id: &str) -> bool { + self.is_fallback_tab_id(tab_id) + || tab_id + .strip_prefix(MARKETPLACE_TAB_ID_PREFIX) + .is_some_and(|marketplace_name| self.contains_marketplace(marketplace_name)) + } +} struct DelayedLoadingHeader { started_at: Instant, @@ -237,18 +418,18 @@ impl ChatWidget { description: Some("Keep this marketplace installed.".to_string()), selected_description: Some("Keep this marketplace installed.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), + response: plugins_response_for_cancel.clone(), }); })], ..Default::default() }, ], on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), + response: plugins_response_for_on_cancel.clone(), }); })), ..Default::default() @@ -421,9 +602,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -478,9 +659,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -518,9 +699,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -542,19 +723,15 @@ impl ChatWidget { active_tab_id: Option, initial_selected_idx: Option, ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + let marketplaces = &response.marketplaces; + let preferred_local_sources = preferred_local_plugin_sources(marketplaces); - let total: usize = marketplaces + let all_entries = plugin_entries_for_marketplaces(marketplaces); + let total = all_entries.len(); + let installed = all_entries .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) + .filter(|(_, plugin, _)| plugin.installed) .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); let name_column_width = all_entries .iter() .map(|(_, _, display_name)| { @@ -570,6 +747,14 @@ impl ChatWidget { let mut tabs = Vec::new(); let mut tab_footer_hints = Vec::new(); + let all_items = self.plugin_selection_items( + all_entries, + &preferred_local_sources, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ); + tabs.push(SelectionTab { id: ALL_PLUGINS_TAB_ID.to_string(), label: "All Plugins".to_string(), @@ -577,12 +762,7 @@ impl ChatWidget { "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), + items: all_items, }); tabs.push(SelectionTab { @@ -594,24 +774,61 @@ impl ChatWidget { ), items: self.plugin_selection_items( installed_entries, + &preferred_local_sources, /*include_marketplace_names*/ true, "No installed plugins", "No installed plugins.", ), }); - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); + let curated_entries = + plugin_entries_for_marketplaces(marketplaces.iter().filter(|marketplace| { + MarketplaceProduct::from_marketplace(marketplace).is_by_openai() + })); let curated_total = curated_entries.len(); let curated_installed = curated_entries .iter() .filter(|(_, plugin, _)| plugin.installed) .count(); + let curated_has_entries = !curated_entries.is_empty(); + let curated_loading = self.plugin_remote_sections_loading + && self.plugins_fetch_state.vertical_section_requested; + let by_openai_section_error = + plugin_remote_section_error(&self.plugin_remote_section_errors, "vertical"); + let (curated_empty_name, curated_empty_description) = + if curated_loading && !curated_has_entries { + ( + "Loading OpenAI Curated plugins...", + "This section updates when app-server returns it.", + ) + } else if let Some(section_error) = by_openai_section_error + && !curated_has_entries + { + ("OpenAI Curated unavailable", section_error.message.as_str()) + } else { + ( + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ) + }; + let mut curated_items = self.plugin_selection_items( + curated_entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + curated_empty_name, + curated_empty_description, + ); + if curated_loading && curated_has_entries { + curated_items.push(remote_section_loading_item("OpenAI Curated")); + } + if let Some(section_error) = by_openai_section_error + && curated_has_entries + { + curated_items.push(remote_section_error_item( + §ion_error.label, + §ion_error.message, + )); + } tabs.push(SelectionTab { id: OPENAI_CURATED_TAB_ID.to_string(), label: "OpenAI Curated".to_string(), @@ -619,27 +836,35 @@ impl ChatWidget { "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), + items: curated_items, }); let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .filter(|marketplace| !MarketplaceProduct::from_marketplace(marketplace).is_by_openai()) .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) + additional_marketplaces.sort_by_cached_key(|marketplace| { + let display_name = marketplace_display_name(marketplace); + ( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + display_name.to_ascii_lowercase(), + display_name, + marketplace.name.clone(), + ) }); + let mut additional_tabs = Vec::new(); + for section in REMOTE_MARKETPLACE_SECTIONS { + if let Some(fallback_tab) = section.fallback_tab( + marketplaces, + self.plugin_remote_sections_loading, + self.plugin_remote_sections_loaded, + &self.plugin_remote_section_errors, + ) { + additional_tabs.push(fallback_tab); + } + } + let labels = disambiguate_duplicate_tab_labels( additional_marketplaces .iter() @@ -681,20 +906,28 @@ impl ChatWidget { ), ) }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); + additional_tabs.push(( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }, + )); } + additional_tabs.sort_by_key(|(tab_order, _)| *tab_order); + tabs.extend(additional_tabs.into_iter().map(|(_, tab)| tab)); tabs.push(self.marketplace_add_tab()); + let initial_tab_id = + active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs)); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -704,7 +937,7 @@ impl ChatWidget { )), tab_footer_hints, tabs, - initial_tab_id: active_tab_id, + initial_tab_id, is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, @@ -744,24 +977,15 @@ impl ChatWidget { plugins_response: &PluginListResponse, plugin: &PluginDetail, ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); + let marketplace_label = MarketplaceProduct::from_marketplace_parts( + &plugin.marketplace_name, + plugin.marketplace_path.as_ref(), + ) + .label() + .map(str::to_string) + .unwrap_or_else(|| plugin.marketplace_name.clone()); let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; + let detail_status_label = plugin_detail_status_label(&plugin.summary); let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -791,9 +1015,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -875,6 +1099,8 @@ impl ChatWidget { }); } + items.extend(plugin_metadata_items(plugin)); + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), @@ -913,6 +1139,7 @@ impl ChatWidget { fn plugin_selection_items<'a>( &self, mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + preferred_local_sources: &HashMap, include_marketplace_names: bool, empty_name: &str, empty_description: &str, @@ -933,7 +1160,8 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let plugin_detail_request = + plugin_detail_request_for_entry(marketplace, plugin, preferred_local_sources); let can_view_details = plugin_detail_request.is_some(); let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; let can_toggle_plugin = plugin.installed && !disabled_by_admin; @@ -1049,7 +1277,7 @@ pub(super) fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header(subtitle: String, count_line: String) -> Box { +pub(super) fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); @@ -1057,10 +1285,225 @@ fn plugins_header(subtitle: String, count_line: String) -> Box { Box::new(header) } +fn dedupe_plugin_entries<'a>( + entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + // App-server should eventually normalize local/remote duplicates. Keep this + // display-only pass narrow so shared plugins do not appear twice meanwhile. + let mut deduped: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = Vec::new(); + let mut remote_entry_indexes = HashMap::new(); + for entry in entries { + let Some(remote_plugin_id) = plugin_remote_identity(entry.1) else { + deduped.push(entry); + continue; + }; + if let Some(existing_index) = remote_entry_indexes.get(&remote_plugin_id).copied() { + if plugin_entry_preferred(&entry, &deduped[existing_index]) { + deduped[existing_index] = entry; + } + } else { + remote_entry_indexes.insert(remote_plugin_id, deduped.len()); + deduped.push(entry); + } + } + deduped +} + +fn plugin_entry_preferred( + candidate: &(&PluginMarketplaceEntry, &PluginSummary, String), + existing: &(&PluginMarketplaceEntry, &PluginSummary, String), +) -> bool { + if candidate.1.installed != existing.1.installed { + return candidate.1.installed; + } + + let candidate_is_local_share = + candidate.1.share_context.is_some() && !matches!(&candidate.1.source, PluginSource::Remote); + let existing_is_local_share = + existing.1.share_context.is_some() && !matches!(&existing.1.source, PluginSource::Remote); + if candidate_is_local_share != existing_is_local_share { + return candidate_is_local_share; + } + + !matches!(&candidate.1.source, PluginSource::Remote) + && matches!(&existing.1.source, PluginSource::Remote) +} + +fn preferred_local_plugin_sources( + marketplaces: &[PluginMarketplaceEntry], +) -> HashMap { + let mut sources = HashMap::new(); + for marketplace in marketplaces { + let Some(marketplace_path) = marketplace.path.as_ref() else { + continue; + }; + for plugin in &marketplace.plugins { + if matches!(&plugin.source, PluginSource::Remote) { + continue; + } + let Some(share_context) = plugin.share_context.as_ref() else { + continue; + }; + sources + .entry(share_context.remote_plugin_id.clone()) + .or_insert_with(|| PreferredLocalPluginSource { + marketplace_path: marketplace_path.clone(), + plugin_name: plugin.name.clone(), + installed: plugin.installed, + }); + } + } + sources +} + +fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + } +} + +fn plugin_metadata_items(plugin: &PluginDetail) -> Vec { + let mut items = Vec::new(); + items.push(SelectionItem { + name: "Source".to_string(), + description: Some(plugin_source_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Auth".to_string(), + description: Some(plugin_auth_policy_summary(plugin.summary.auth_policy)), + is_disabled: true, + ..Default::default() + }); + if let Some(version) = plugin_version_summary(&plugin.summary) { + items.push(SelectionItem { + name: "Version".to_string(), + description: Some(version), + is_disabled: true, + ..Default::default() + }); + } + if let Some(share_context) = &plugin.summary.share_context { + items.push(SelectionItem { + name: "Sharing".to_string(), + description: Some(plugin_share_context_summary(share_context)), + is_disabled: true, + ..Default::default() + }); + } + items +} + +fn plugin_source_summary(plugin: &PluginDetail) -> String { + match &plugin.summary.source { + PluginSource::Local { .. } => "Local".to_string(), + PluginSource::Git { url, ref_name, .. } => match ref_name { + Some(ref_name) => format!("Git · {url}@{ref_name}"), + None => format!("Git · {url}"), + }, + PluginSource::Remote => { + let marketplace_label = + MarketplaceProduct::from_marketplace_name(&plugin.marketplace_name) + .label() + .unwrap_or(plugin.marketplace_name.as_str()); + format!("Remote · {marketplace_label}") + } + } +} + +fn plugin_auth_policy_summary(auth_policy: PluginAuthPolicy) -> String { + match auth_policy { + PluginAuthPolicy::OnInstall => "Auth on install".to_string(), + PluginAuthPolicy::OnUse => "Auth on use".to_string(), + } +} + +fn plugin_version_summary(plugin: &PluginSummary) -> Option { + let mut parts = Vec::new(); + if let Some(local_version) = plugin.local_version.as_deref() { + parts.push(format!("local {local_version}")); + } + if let Some(remote_version) = plugin + .share_context + .as_ref() + .and_then(|context| context.remote_version.as_deref()) + { + parts.push(format!("remote {remote_version}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +fn plugin_share_context_summary(context: &PluginShareContext) -> String { + let mut parts = Vec::new(); + if let Some(discoverability) = context.discoverability { + parts.push(plugin_share_discoverability_label(discoverability).to_string()); + } + if let Some(creator_summary) = plugin_share_creator_summary(context) { + parts.push(creator_summary); + } + if let Some(principals) = context.share_principals.as_ref() { + parts.push(plugin_share_principals_summary(principals)); + } + if let Some(share_url) = context + .share_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + { + parts.push(share_url.to_string()); + } + if parts.is_empty() { + format!("Remote ID {}", context.remote_plugin_id) + } else { + parts.join(" · ") + } +} + +fn plugin_share_discoverability_label(discoverability: PluginShareDiscoverability) -> &'static str { + match discoverability { + PluginShareDiscoverability::Listed => "Listed", + PluginShareDiscoverability::Unlisted => "Workspace link", + PluginShareDiscoverability::Private => "Private", + } +} + +fn plugin_share_creator_summary(context: &PluginShareContext) -> Option { + match ( + context.creator_name.as_deref(), + context.creator_account_user_id.as_deref(), + ) { + (Some(name), Some(account_id)) => Some(format!("creator {name} ({account_id})")), + (Some(name), None) => Some(format!("creator {name}")), + (None, Some(account_id)) => Some(format!("creator account {account_id}")), + (None, None) => None, + } +} + +fn plugin_share_principals_summary(principals: &[PluginSharePrincipal]) -> String { + match principals.len() { + 0 => "No explicit principals".to_string(), + 1 => format!("1 principal: {}", principals[0].name), + count => format!("{count} principals"), + } +} + fn plugin_entries_for_marketplaces<'a>( marketplaces: impl IntoIterator, ) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces + let entries = marketplaces .into_iter() .flat_map(|marketplace| { marketplace @@ -1068,7 +1511,8 @@ fn plugin_entries_for_marketplaces<'a>( .iter() .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) }) - .collect() + .collect::>(); + dedupe_plugin_entries(entries) } fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { @@ -1103,6 +1547,10 @@ pub(super) fn marketplace_tab_id_matching_saved_id( saved_tab_id: &str, marketplaces: &[PluginMarketplaceEntry], ) -> Option { + if let Some(tab_id) = remote_section_marketplace_tab_id(saved_tab_id, marketplaces) { + return Some(tab_id); + } + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { let tab_id = marketplace_tab_id(marketplace); (tab_id == saved_tab_id).then_some(tab_id) @@ -1124,6 +1572,43 @@ pub(super) fn marketplace_tab_id_matching_saved_id( }) } +fn remote_section_marketplace_tab_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.is_fallback_tab_id(saved_tab_id))?; + + section + .marketplace_names + .iter() + .find_map(|marketplace_name| { + marketplaces + .iter() + .find(|marketplace| marketplace.name.as_str() == *marketplace_name) + .map(marketplace_tab_id) + }) +} + +fn plugin_tab_id_matching_saved_id(saved_tab_id: &str, tabs: &[SelectionTab]) -> Option { + if let Some(tab_id) = tabs + .iter() + .find(|tab| tab.id.as_str() == saved_tab_id) + .map(|tab| tab.id.clone()) + { + return Some(tab_id); + } + + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.contains_tab_id(saved_tab_id))?; + + tabs.iter() + .find(|tab| section.contains_tab_id(&tab.id)) + .map(|tab| tab.id.clone()) +} + pub(super) fn merge_remote_marketplaces( response: &mut PluginListResponse, remote_marketplaces: Vec, @@ -1132,62 +1617,134 @@ pub(super) fn merge_remote_marketplaces( .iter() .map(|marketplace| marketplace.name.clone()) .collect::>(); + let remote_curated_present = remote_names.contains(REMOTE_GLOBAL_MARKETPLACE_NAME); response.marketplaces.retain(|marketplace| { + if remote_curated_present + && marketplace.path.is_some() + && is_openai_curated_marketplace_name(&marketplace.name) + { + return false; + } + marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) + || !REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .any(|section| section.contains_marketplace(&marketplace.name)) && !remote_names.contains(marketplace.name.as_str()) }); response.marketplaces.extend(remote_marketplaces); } -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) +fn is_personal_marketplace_path(marketplace_path: &AbsolutePathBuf) -> bool { + dirs::home_dir() + .and_then(|home| { + AbsolutePathBuf::try_from(home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH)).ok() + }) + .is_some_and(|personal_path| personal_path.as_path() == marketplace_path.as_path()) +} + +fn remote_section_loading_item(label: &str) -> SelectionItem { + SelectionItem { + name: format!("Loading {label} plugins..."), + description: Some("This section updates when app-server returns it.".to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn remote_section_error_item(label: &str, message: &str) -> SelectionItem { + SelectionItem { + name: format!("{label} unavailable"), + description: Some(message.to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn plugin_remote_section_error<'a>( + section_errors: &'a [PluginRemoteSectionError], + section_id: &str, +) -> Option<&'a PluginRemoteSectionError> { + section_errors + .iter() + .find(|section_error| section_error.section_id == section_id) +} + +fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_LOADING_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("Loading {label} plugins."), + "Local plugin functionality is already available.".to_string(), + ), + items: vec![remote_section_loading_item(label)], + } +} + +fn remote_section_empty_tab( + id: &str, + label: &str, + item_name: &str, + item_description: &str, +) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_EMPTY_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("{label}."), + "This section loaded successfully.".to_string(), + ), + items: vec![SelectionItem { + name: item_name.to_string(), + description: Some(item_description.to_string()), + is_disabled: true, + ..Default::default() + }], + } +} + +fn remote_section_error_tab(section_error: &PluginRemoteSectionError) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_ERROR_TAB_ID_PREFIX}{}", section_error.section_id), + label: section_error.label.clone(), + header: plugins_header( + format!("{} unavailable.", section_error.label), + "Local plugin functionality is still available.".to_string(), + ), + items: vec![remote_section_error_item( + §ion_error.label, + §ion_error.message, + )], + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); + let mut counts = HashMap::new(); for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } + *counts.entry(label.clone()).or_insert(0) += 1; } - let mut seen: Vec<(String, usize)> = Vec::new(); + let mut seen = HashMap::new(); labels .into_iter() .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); + let total = counts[&label]; if total == 1 { return label; } - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; + let current = seen.entry(label.clone()).or_insert(0); + *current += 1; format!("{label} ({current}/{total})") }) .collect() } pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + if let Some(label) = MarketplaceProduct::from_marketplace(marketplace).label() { + return label.to_string(); + } marketplace .interface .as_ref() @@ -1271,7 +1828,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", + PluginInstallPolicy::InstalledByDefault => "Available by default", } } } @@ -1300,7 +1857,21 @@ fn plugin_detail_location(plugin: &PluginDetail) -> Option { fn plugin_detail_request_for_entry( marketplace: &PluginMarketplaceEntry, plugin: &PluginSummary, + preferred_local_sources: &HashMap, ) -> Option<(PluginLocation, String)> { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + && let Some(preferred_source) = preferred_local_sources.get(remote_plugin_id) + && preferred_source.installed == plugin.installed + { + return Some(( + PluginLocation::Local { + marketplace_path: preferred_source.marketplace_path.clone(), + }, + preferred_source.plugin_name.clone(), + )); + } + plugin_location_for_marketplace(marketplace, plugin) .map(|location| (location, plugin_request_name(plugin))) } @@ -1309,22 +1880,22 @@ fn plugin_request_name(plugin: &PluginSummary) -> String { if matches!(&plugin.source, PluginSource::Remote) && let Some(remote_plugin_id) = plugin_remote_identity(plugin) { - return remote_plugin_id; + return remote_plugin_id.to_string(); } plugin.name.clone() } -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { +fn plugin_remote_identity(plugin: &PluginSummary) -> Option<&str> { plugin .share_context .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) + .map(|context| context.remote_plugin_id.as_str()) + .or(plugin.remote_plugin_id.as_deref()) } fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); + return plugin_remote_identity(plugin).map(str::to_string); } Some(plugin.id.clone()) } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 8c259856fb..0ff8333ffc 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -42,6 +42,7 @@ pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; pub(super) struct PluginListFetchState { pub(super) cache_cwd: Option, pub(super) in_flight_cwd: Option, + pub(super) vertical_section_requested: bool, } #[derive(Debug, Clone)] @@ -147,6 +148,7 @@ impl ChatWidget { Err(err) => { self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = false; + self.plugins_fetch_state.vertical_section_requested = false; if should_refresh_plugins_popup { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); @@ -175,6 +177,7 @@ impl ChatWidget { .is_some(); self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = true; + self.plugins_fetch_state.vertical_section_requested = false; let refreshed_response = match &mut self.plugins_cache { PluginsCacheState::Ready(response) if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => @@ -212,6 +215,8 @@ impl ChatWidget { } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); + self.plugins_fetch_state.vertical_section_requested = + !self.config.features.enabled(Feature::RemotePlugin); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -245,6 +250,36 @@ impl ChatWidget { )); } + pub(crate) fn open_plugins_list(&mut self, cwd: PathBuf, response: PluginListResponse) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + let response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(current_response) => current_response, + PluginsCacheState::Uninitialized + | PluginsCacheState::Loading + | PluginsCacheState::Failed(_) => response, + }; + self.plugins_fetch_state.cache_cwd = Some(cwd); + self.plugins_cache = PluginsCacheState::Ready(response.clone()); + let active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()) + .or_else(|| Some(ALL_PLUGINS_TAB_ID.to_string())); + self.plugins_active_tab_id = active_tab_id.clone(); + let params = + self.plugins_popup_params(&response, active_tab_id, /*initial_selected_idx*/ None); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.open_plugins_popup(&response); + } + } + pub(crate) fn open_marketplace_add_prompt(&mut self) { self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); let tx = self.app_event_tx.clone(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index c7edae1714..e5d14a4c51 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,13 +3,15 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: strip_osc8_for_snapshot(&popup) --- Plugins - Figma · Can be installed · ChatGPT Marketplace + Figma · Can be installed · Local Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 4d91b17f34..2b69864cc5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -8,6 +8,8 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 40d26d3c9d..9f828ab3bc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -9,9 +9,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins -› [ ] Alpha Sync Disabled Space to enable; Enter view details. - [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - [-] Starter Available · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi… + [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap index 515b700925..803ff9fbe6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 339 expression: popup --- Plugins Debug Marketplace installed successfully. Select the plugins you want to use and press Enter to install or view details. - All Plugins Installed (0) OpenAI Curated [Debug Marketplace] Add Marketplace + All Plugins Installed (0) OpenAI Curated Workspace Shared with me [Debug Marketplace] + Add Marketplace Type to search plugins › [-] Debug Plugin Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4c225b3318..281d97305e 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginShareContext; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -250,7 +251,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() { .expect("expected verbose plugin row in popup"); insta::assert_snapshot!( verbose_row, - @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + @" [-] Verbose Plugin Available · OpenAI Curated · This description…" ); assert!( !popup @@ -459,11 +460,20 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); chat.add_plugins_output(); - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + let reopened_popup = (0..8) + .find_map(|_| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("[Debug Marketplace]") { + Some(popup) + } else { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + None + } + }) + .unwrap_or_else(|| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + panic!("expected Debug Marketplace tab after reopening, got:\n{popup}"); + }); assert!( reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") && !reopened_popup.contains("installed successfully"), @@ -584,10 +594,17 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { } #[tokio::test] -async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { +async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + let marketplace_name = "personal-marketplace"; + let personal_marketplace_path = AbsolutePathBuf::try_from( + dirs::home_dir() + .expect("home directory") + .join(".agents/plugins/marketplace.json"), + ) + .expect("absolute personal marketplace path"); let summary = plugins_test_summary( "plugin-figma", "figma", @@ -597,28 +614,29 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa /*enabled*/ true, PluginInstallPolicy::Available, ); - let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ - summary.clone(), - ])]); + let response = plugins_test_response(vec![PluginMarketplaceEntry { + name: marketplace_name.to_string(), + path: Some(personal_marketplace_path.clone()), + interface: None, + plugins: vec![summary.clone()], + }]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); - chat.on_plugin_detail_loaded( - cwd.to_path_buf(), - Ok(PluginReadResponse { - plugin: plugins_test_detail( - summary, - Some("Turn Figma files into implementation context."), - &["design-review", "extract-copy"], - &[ - (codex_app_server_protocol::HookEventName::PreToolUse, 1), - (codex_app_server_protocol::HookEventName::Stop, 2), - ], - &["Figma", "Slack"], - &["figma-mcp", "docs-mcp"], - ), - }), + let mut plugin = plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], + &["Figma", "Slack"], + &["figma-mcp", "docs-mcp"], ); + plugin.marketplace_name = marketplace_name.to_string(); + plugin.marketplace_path = Some(personal_marketplace_path); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin })); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!( @@ -1022,6 +1040,222 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { assert_eq!(after, before); } +#[tokio::test] +async fn plugins_popup_admin_disabled_available_plugin_has_view_only_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let admin_blocked_row = popup + .lines() + .find(|line| line.contains("Admin Blocked")) + .expect("expected admin-disabled plugin row"); + assert!( + admin_blocked_row.contains("Press Enter to view plugin details.") + && !admin_blocked_row.contains("install or view"), + "expected admin-disabled available plugin to stay view-only, got:\n{admin_blocked_row}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_section_fallback_states_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let select_tab_containing = |chat: &mut ChatWidget, visible_text: &str| -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, /*width*/ 100); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, /*width*/ 100); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); + }; + let remote_section_state = |popup: &str| -> String { + let header = popup + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .nth(1) + .expect("expected remote section header"); + let item = popup + .lines() + .find_map(|line| line.trim_start().strip_prefix('›')) + .expect("expected selected remote section item") + .trim(); + format!("{header}\n{item}") + }; + + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let curated_loading_popup = + select_tab_containing(&mut chat, "Loading OpenAI Curated plugins..."); + let workspace_loading_popup = select_tab_containing(&mut chat, "Loading Workspace plugins."); + + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), Vec::new(), Vec::new()); + let shared_empty_popup = select_tab_containing(&mut chat, "Shared with me."); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + Vec::new(), + vec![crate::app_event::PluginRemoteSectionError { + section_id: "workspace".to_string(), + label: "Workspace".to_string(), + message: "Sign in to ChatGPT to load workspace plugins.".to_string(), + }], + ); + let workspace_error_popup = select_tab_containing(&mut chat, "Workspace unavailable."); + + let (mut remote_chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + remote_chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + remote_chat.set_feature_enabled(Feature::RemotePlugin, /*enabled*/ true); + remote_chat.add_plugins_output(); + let remote_cwd = remote_chat.config.cwd.clone(); + remote_chat.on_plugins_loaded( + remote_cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let remote_curated_empty_popup = + select_tab_containing(&mut remote_chat, "No OpenAI Curated plugins available"); + + insta::assert_snapshot!( + [ + remote_section_state(&curated_loading_popup), + remote_section_state(&workspace_loading_popup), + remote_section_state(&shared_empty_popup), + remote_section_state(&workspace_error_popup), + remote_section_state(&remote_curated_empty_popup), + ] + .join("\n\n"), + @r###" + OpenAI Curated marketplace. + Loading OpenAI Curated plugins... This section updates when app-server returns it. + + Loading Workspace plugins. + Loading Workspace plugins... This section updates when app-server returns it. + + Shared with me. + No shared plugins available No plugins have been shared with you. + + Workspace unavailable. + Workspace unavailable Sign in to ChatGPT to load workspace plugins. + + OpenAI Curated marketplace. + No OpenAI Curated plugins available No OpenAI Curated plugins available. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let remote_marketplace_name = "workspace-shared-with-me-private"; + let local_summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: remote_plugin_id.to_string(), + remote_version: None, + discoverability: None, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local editable docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let popup = render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![local_summary]), + PluginMarketplaceEntry { + name: remote_marketplace_name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Shared docs plugin."), + /*installed*/ true, + )], + }, + ]), + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins.") + && all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Available"), + "expected installed remote duplicate to win over local mapped share, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginDetailLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Docs"); + } + other => panic!("expected OpenPluginDetailLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginDetail { params, .. }) => { + assert_eq!(params.marketplace_path, None); + assert_eq!( + params.remote_marketplace_name, + Some(remote_marketplace_name.to_string()) + ); + assert_eq!(params.plugin_name, remote_plugin_id); + } + other => panic!("expected FetchPluginDetail event, got {other:?}"), + } +} + #[tokio::test] async fn plugin_detail_error_popup_skips_disabled_row_numbering() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 9f06cf1a0907f650887ca26af5cee13b4f9a13ea Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 18:33:28 +0100 Subject: [PATCH 05/10] Report remote sandbox denials semantically (#29424) ## Why #29113 moved remote sandbox setup and enforcement to the exec server. That gives the executor ownership of the platform-specific work: a Linux executor chooses and runs a Linux sandbox even when the Codex orchestrator is running on macOS or Windows. It also means the orchestrator no longer knows which concrete sandbox the executor selected. When that sandbox blocks a remote command, the orchestrator currently sees only a failed process and can treat the denial as an ordinary command failure. The existing sandbox approval and retry path is then skipped. This PR lets the executor report one portable fact: > This command probably failed because the executor sandbox blocked it. The executor keeps its concrete sandbox type private. The protocol sends only the semantic result. ## Example Suppose a local macOS Codex session asks a Linux devbox to write outside the allowed workspace. Before this PR: ```text Linux sandbox blocks the write -> remote process exits with "Permission denied" -> local orchestrator sees an ordinary command failure -> the normal sandbox approval and retry path can be skipped ``` With this PR: ```text Linux sandbox blocks the write -> executor reports sandboxDenied: true -> unified exec returns UnifiedExecError::SandboxDenied -> the existing approval prompt is shown -> an approved retry runs through the existing unsandboxed retry path ``` ## What changes ### The executor remembers its selected sandbox The prepared remote process now retains the executor-selected `SandboxType`. This value never crosses the executor boundary. Commands started without a sandbox retain `SandboxType::None` and are never reported as sandbox denials. ### The executor uses the existing denial heuristic The existing local denial heuristic moves from `codex-core` into the shared `codex-sandboxing` crate. When a sandboxed remote process exits, the executor: 1. waits the same short output grace period used by local unified exec; 2. reads the output currently available in the existing retained output buffer; 3. runs the existing heuristic using the exit code and common denial messages; 4. stores the yes/no result before publishing the process exit. This deliberately matches the old local unified-exec behavior. It does not add a new streaming classifier, another output buffer, or stronger output-retention guarantees. ### The protocol reports a portable boolean `process/read` gains `sandboxDenied`: ```json { "exited": true, "exitCode": 1, "closed": false, "sandboxDenied": true } ``` The field defaults to `false` when an older executor omits it. The response does not expose the executor sandbox implementation or executor-native paths. ### Unified exec uses the existing error path The exec-server client carries `sandboxDenied` into the unified process state. If it is true, unified exec returns the existing `SandboxDenied` error instead of trying to classify remote output using an orchestrator-side sandbox type. Remote process exit remains visible as soon as the process exits. This PR does not wait for stdout or stderr to close and does not change the existing process lifecycle. ## Scope This PR is intentionally limited to matching the existing local unified-exec behavior for the initial command execution path. It does not add: - incremental denial tracking across the full output stream; - new denial handling for commands completed later through `write_stdin`; - new guarantees for preserving the semantic flag during the narrow reconnect-recovery race. Those can be considered separately if the same behavior is added for local execution. ## Test coverage One remote end-to-end integration test covers the complete intended flow: ```text remote read-only sandbox -> denied write -> executor reports the denial -> Codex requests approval -> user approves -> retry succeeds on the remote executor ``` Existing lifecycle coverage continues to verify that remote process exit is reported before late output streams close. --- codex-rs/core/src/exec.rs | 63 +------- codex-rs/core/src/unified_exec/mod_tests.rs | 20 ++- codex-rs/core/src/unified_exec/process.rs | 19 ++- .../core/src/unified_exec/process_manager.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 19 ++- .../core/src/unified_exec/process_state.rs | 3 + .../core/src/unified_exec/process_tests.rs | 7 +- codex-rs/core/tests/suite/remote_env.rs | 152 +++++++++++++++++- codex-rs/exec-server/src/client.rs | 2 + codex-rs/exec-server/src/client_recovery.rs | 1 + codex-rs/exec-server/src/local_process.rs | 46 ++++++ codex-rs/exec-server/src/process_sandbox.rs | 3 + codex-rs/exec-server/src/protocol.rs | 3 + codex-rs/exec-server/tests/exec_process.rs | 18 +-- codex-rs/exec-server/tests/process.rs | 4 +- codex-rs/exec-server/tests/relay.rs | 2 +- codex-rs/sandboxing/src/denial.rs | 57 +++++++ codex-rs/sandboxing/src/lib.rs | 2 + 18 files changed, 330 insertions(+), 106 deletions(-) create mode 100644 codex-rs/sandboxing/src/denial.rs diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 99c87cdd99..647203db2e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,6 +42,7 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -819,68 +820,6 @@ fn finalize_exec_result( } } -/// We don't have a fully deterministic way to tell if our command failed -/// because of the sandbox - a command in the user's zshrc file might hit an -/// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for well known command failure exit codes and -/// also look for common sandbox denial keywords in the command output. -pub(crate) fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - // Quick rejects: well-known non-sandbox shell exit codes - // 2: misuse of shell builtins - // 126: permission denied - // 127: command not found - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const SIGSYS_CODE: i32 = libc::SIGSYS; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE - { - return true; - } - } - - false -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index d5b813cb42..2dc0afd265 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,6 +228,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -286,17 +287,14 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started( - StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - }, - SandboxType::None, - ) + UnifiedExecProcess::from_exec_server_started(StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + }) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed2..5f785adfa7 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,8 +285,9 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { + let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if sandbox_type == SandboxType::None || !self.has_exited() { + if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -297,7 +298,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -374,10 +375,13 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, - sandbox_type: SandboxType, ) -> Result { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new( + process_handle, + SandboxType::None, + /*spawn_lifecycle*/ None, + ); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -437,6 +441,7 @@ impl UnifiedExecProcess { exit_code, closed, failure, + sandbox_denied, } = response; for chunk in chunks { @@ -457,6 +462,12 @@ impl UnifiedExecProcess { break; } + if sandbox_denied { + let mut state = state_tx.borrow().clone(); + state.sandbox_denied = true; + let _ = state_tx.send_replace(state); + } + if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3b4bcf9c7..2cb62a6d4b 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -10,6 +10,7 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; @@ -157,8 +158,14 @@ fn exec_server_params_for_request( tty: bool, ) -> codex_exec_server::ExecParams { let (env_policy, env) = exec_server_env_for_request(request); + // Sandbox retries reuse the unified-exec ID but start a distinct executor process. + let exec_server_process_id = if request.exec_server_sandbox.is_some() { + format!("{process_id}-{}", Uuid::new_v4()) + } else { + process_id.to_string() + }; codex_exec_server::ExecParams { - process_id: exec_server_process_id(process_id).into(), + process_id: exec_server_process_id.into(), argv: request.command.clone(), cwd: request.cwd.clone(), env_policy, @@ -198,10 +205,6 @@ impl Drop for InitialExecCommandGuard { } } -fn exec_server_process_id(process_id: i32) -> String { - process_id.to_string() -} - async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { if let Some(network_approval) = entry.network_approval.as_ref() && let Some(session) = entry.session.upgrade() @@ -1041,7 +1044,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; + return UnifiedExecProcess::from_exec_server_started(started).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index b6afdc85ba..cd48d01c7c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -76,7 +76,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; - let request = ExecRequest { + let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), env: HashMap::from([ @@ -106,7 +106,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - permission_profile, + permission_profile: permission_profile.clone(), file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, @@ -128,11 +128,16 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), ]) ); -} - -#[test] -fn exec_server_process_id_matches_unified_exec_process_id() { - assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321"); + request.exec_server_sandbox = Some( + codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), + ); + let first = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + let second = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + assert!(first.process_id.as_str().starts_with("123-")); + assert!(second.process_id.as_str().starts_with("123-")); + assert_ne!(first.process_id, second.process_id); } #[cfg(windows)] diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29..65e11b6f3e 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,6 +3,7 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, + pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -11,6 +12,7 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), + sandbox_denied: self.sandbox_denied, } } @@ -19,6 +21,7 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), + sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 37ec5d858b..db64d461ec 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -10,7 +10,6 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; -use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -40,6 +39,7 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, })) } @@ -103,7 +103,7 @@ async fn remote_process( }), }; - UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should start") } @@ -190,6 +190,7 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, + sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), @@ -201,7 +202,7 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + let process = UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should observe early exit"); diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 337ca8d405..fa805fa446 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -87,6 +87,7 @@ async fn submit_turn_with_approval_and_environments( test: &TestCodex, prompt: &str, environments: Vec, + approval_policy: AskForApproval, ) -> Result<()> { let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), @@ -103,7 +104,7 @@ async fn submit_turn_with_approval_and_environments( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), + approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -312,6 +313,151 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor"); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + const CALL_ID: &str = "remote-sandbox-denial"; + const CONTENTS: &str = "remote sandbox retry succeeded"; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); + let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); + let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let target_uri = PathUri::from_path(&target_path)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}"); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-remote-denial-1"), + ev_function_call( + CALL_ID, + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 5_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-remote-denial-1"), + ]), + sse(vec![ + ev_response_created("resp-remote-denial-2"), + ev_assistant_message("msg-remote-denial", "done"), + ev_completed("resp-remote-denial-2"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "retry a sandbox-denied command in the remote environment", + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }], + AskForApproval::OnFailure, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("expected remote sandbox approval before completion: {event:?}"); + }; + assert_eq!(approval.call_id, CALL_ID); + assert_eq!( + approval.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!( + approval.reason.as_deref(), + Some("command failed; retry without sandbox?") + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert!( + response_mock + .function_call_output_text(CALL_ID) + .is_some_and(|output| output.contains(CONTENTS)), + "approved retry should return the remote command output" + ); + assert_eq!( + test.fs() + .read_file_text(&target_uri, /*sandbox*/ None) + .await?, + CONTENTS + ); + + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; @@ -834,6 +980,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result cwd: PathUri::from_abs_path(&remote_cwd), }, ], + AskForApproval::OnRequest, ) .await?; @@ -1115,6 +1262,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in local environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-local").await; @@ -1134,6 +1282,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in remote environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-remote").await; @@ -1158,6 +1307,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch again in remote environment", environments, + AskForApproval::OnRequest, ) .await?; wait_for_completion_without_patch_approval(&test).await; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 88c0c80342..25d1b54e8b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -926,6 +926,7 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } @@ -1869,6 +1870,7 @@ mod tests { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 77feb0a4ba..f58e365728 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,6 +58,7 @@ impl SessionState { exit_code, closed, failure, + sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 802c8e69cf..c7e99de53c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,7 +10,11 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; +use codex_sandboxing::SandboxType; +use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -87,6 +91,8 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, + sandbox: SandboxType, + sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -313,6 +319,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -407,6 +415,7 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, + sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -797,12 +806,46 @@ async fn watch_exit( output_notify: Arc, ) { let exit_code = exit_rx.await.unwrap_or(-1); + let sandboxed = { + let processes = inner.processes.lock().await; + matches!( + processes.get(&process_id), + Some(ProcessEntry::Running(process)) if process.sandbox != SandboxType::None + ) + }; + if sandboxed { + let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await; + } let notification = { let mut processes = inner.processes.lock().await; if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) { let seq = process.next_seq; process.next_seq += 1; process.exit_code = Some(exit_code); + if process.sandbox != SandboxType::None { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut aggregated = Vec::new(); + for chunk in &process.output { + match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.extend_from_slice(&chunk.chunk); + } + ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), + } + aggregated.extend_from_slice(&chunk.chunk); + } + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), + stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), + aggregated_output: StreamOutput::new( + String::from_utf8_lossy(&aggregated).into_owned(), + ), + ..Default::default() + }; + process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); + } let _ = process.wake_tx.send(seq); process .events @@ -1000,6 +1043,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -1124,6 +1168,8 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: SandboxType::None, + sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index be95b92d34..d157f4c6dc 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -20,6 +20,7 @@ pub(crate) struct PreparedExecRequest { pub(crate) cwd: AbsolutePathBuf, pub(crate) env: HashMap, pub(crate) arg0: Option, + pub(crate) sandbox: SandboxType, } pub(crate) fn prepare_exec_request( @@ -33,6 +34,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(¶ms.cwd, "cwd")?, env, arg0: params.arg0.clone(), + sandbox: SandboxType::None, }); }; let runtime_paths = runtime_paths @@ -116,6 +118,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(&request.cwd, "cwd")?, env: request.env, arg0: request.arg0, + sandbox: request.sandbox, }) } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e05595f273..99ee3431aa 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -153,6 +153,9 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, + /// Whether the executor classified the process failure as a sandbox denial. + #[serde(default)] + pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index ac38f50eaf..f35923815d 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -230,7 +230,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -263,7 +263,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -312,7 +312,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -362,7 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -439,7 +439,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -479,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -513,7 +513,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -547,7 +547,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -645,7 +645,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 571fd427fa..ba7f64a610 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,7 +70,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); @@ -135,7 +135,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5f49655e39..c9f873c158 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -157,7 +157,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 0000000000..f355fa1c02 --- /dev/null +++ b/codex-rs/sandboxing/src/denial.rs @@ -0,0 +1,57 @@ +use codex_protocol::exec_output::ExecToolCallOutput; + +use crate::SandboxType; + +/// Returns whether a failed command was likely denied by the selected sandbox. +pub fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const EXIT_CODE_SIGNAL_BASE: i32 = 128; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS + { + return true; + } + } + + false +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 22c4984c5a..0688d0f920 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -11,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; From 5b95745eae1b13be75d6c395a6baec8e7b7ad99b Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 11:11:25 -0700 Subject: [PATCH 06/10] core: rename metadata -> internal_chat_message_metadata_passthrough (#28968) ## Description This PR cuts Codex over from generic `ResponseItem.metadata` (introduced here: https://github.com/openai/codex/pull/28355) to `ResponseItem.internal_chat_message_metadata_passthrough`, which is the blessed path and has strongly-typed keys. For now we have to drop this MAv2 usage of `metadata`: https://github.com/openai/codex/pull/28561 until we figure out where that should live. --- .../schema/json/ClientRequest.json | 89 +++--- .../codex_app_server_protocol.schemas.json | 89 +++--- .../codex_app_server_protocol.v2.schemas.json | 89 +++--- .../RawResponseItemCompletedNotification.json | 89 +++--- .../schema/json/v2/ThreadResumeParams.json | 89 +++--- .../InternalChatMessageMetadataPassthrough.ts | 11 + .../schema/typescript/ResponseItem.ts | 6 +- .../schema/typescript/ResponseItemMetadata.ts | 5 - .../schema/typescript/index.ts | 2 +- .../src/protocol/thread_history.rs | 2 +- .../app-server/tests/suite/v2/compaction.rs | 4 +- .../tests/suite/v2/thread_inject_items.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../src/endpoint/responses_websocket.rs | 2 +- codex-rs/codex-api/src/endpoint/search.rs | 2 +- codex-rs/codex-api/tests/clients.rs | 4 +- codex-rs/context-fragments/src/fragment.rs | 4 +- codex-rs/core/src/agent/control_tests.rs | 22 +- codex-rs/core/src/client.rs | 6 +- codex-rs/core/src/client_common_tests.rs | 12 +- codex-rs/core/src/client_tests.rs | 2 +- codex-rs/core/src/codex_delegate_tests.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/compact.rs | 21 +- codex-rs/core/src/compact_remote.rs | 12 +- codex-rs/core/src/compact_remote_v2.rs | 32 +- codex-rs/core/src/compact_tests.rs | 91 +++--- codex-rs/core/src/context_manager/history.rs | 8 +- .../core/src/context_manager/history_tests.rs | 207 +++++++------ .../core/src/context_manager/normalize.rs | 8 +- codex-rs/core/src/context_manager/updates.rs | 2 +- codex-rs/core/src/event_mapping_tests.rs | 38 +-- codex-rs/core/src/guardian/tests.rs | 54 ++-- codex-rs/core/src/image_preparation_tests.rs | 8 +- codex-rs/core/src/realtime_context_tests.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 6 +- codex-rs/core/src/session/tests.rs | 53 ++-- .../core/src/session/tests/guardian_tests.rs | 4 +- codex-rs/core/src/session/turn_tests.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 8 +- .../core/src/stream_events_utils_tests.rs | 20 +- codex-rs/core/src/tasks/mod.rs | 2 +- codex-rs/core/src/tasks/review.rs | 4 +- codex-rs/core/src/thread_manager_tests.rs | 8 +- .../src/thread_rollout_truncation_tests.rs | 10 +- codex-rs/core/src/tools/code_mode/delegate.rs | 2 +- .../src/tools/handlers/extension_tools.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 17 +- .../handlers/multi_agents_v2/message_tool.rs | 7 +- .../tools/handlers/multi_agents_v2/spawn.rs | 7 +- codex-rs/core/src/tools/router_tests.rs | 6 +- codex-rs/core/src/turn_timing_tests.rs | 10 +- codex-rs/core/tests/common/responses.rs | 2 +- codex-rs/core/tests/responses_headers.rs | 6 +- codex-rs/core/tests/suite/client.rs | 45 +-- .../core/tests/suite/client_websockets.rs | 4 +- codex-rs/core/tests/suite/compact.rs | 12 +- codex-rs/core/tests/suite/compact_remote.rs | 18 +- codex-rs/core/tests/suite/image_rollout.rs | 4 +- .../core/tests/suite/prompt_debug_tests.rs | 2 +- .../core/tests/suite/realtime_conversation.rs | 4 +- codex-rs/core/tests/suite/review.rs | 4 +- .../tests/suite/subagent_notifications.rs | 3 - codex-rs/ext/image-generation/src/tests.rs | 16 +- codex-rs/ext/web-search/src/history.rs | 14 +- .../external-agent-sessions/src/export.rs | 2 +- codex-rs/memories/write/src/phase1.rs | 16 +- codex-rs/memories/write/src/startup_tests.rs | 2 +- codex-rs/protocol/src/items.rs | 2 +- codex-rs/protocol/src/models.rs | 290 ++++++++++++------ codex-rs/protocol/src/protocol.rs | 13 +- codex-rs/rollout-trace/src/inference.rs | 2 +- codex-rs/rollout/src/tests.rs | 2 +- codex-rs/state/src/extract.rs | 2 +- codex-rs/thread-store/src/local/mod.rs | 2 +- codex-rs/tools/src/response_history.rs | 2 +- codex-rs/tui/src/app/side.rs | 2 +- 77 files changed, 866 insertions(+), 797 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7ea246f0ea..e1399a2dd8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1315,6 +1315,18 @@ ], "type": "object" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LegacyAppPathString": { "type": "string" }, @@ -2302,10 +2314,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2358,10 +2370,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2412,10 +2424,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2462,10 +2474,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2505,10 +2517,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2559,10 +2571,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2602,10 +2614,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2645,10 +2657,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2692,10 +2704,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2744,10 +2756,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2796,10 +2808,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2834,10 +2846,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2883,10 +2895,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2910,10 +2922,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2948,10 +2960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2990,23 +3002,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { 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 9c2abf525a..3a2af11db3 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 @@ -11145,6 +11145,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14948,10 +14960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15004,10 +15016,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15058,10 +15070,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15108,10 +15120,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15151,10 +15163,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15205,10 +15217,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15248,10 +15260,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15291,10 +15303,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15338,10 +15350,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15390,10 +15402,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15442,10 +15454,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15480,10 +15492,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15529,10 +15541,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15556,10 +15568,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15594,10 +15606,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15636,23 +15648,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { 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 c8b172c7dc..286fc0e2f6 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 @@ -7549,6 +7549,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -11352,10 +11364,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11408,10 +11420,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11462,10 +11474,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11512,10 +11524,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11555,10 +11567,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11609,10 +11621,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11652,10 +11664,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11695,10 +11707,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11742,10 +11754,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11794,10 +11806,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11846,10 +11858,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11884,10 +11896,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11933,10 +11945,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11960,10 +11972,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11998,10 +12010,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -12040,23 +12052,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { 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 22385f816f..d06d821a7d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -216,6 +216,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -383,10 +395,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -439,10 +451,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -493,10 +505,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -543,10 +555,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -586,10 +598,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -640,10 +652,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -683,10 +695,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -726,10 +738,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -773,10 +785,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -825,10 +837,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -877,10 +889,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -915,10 +927,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -964,10 +976,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -991,10 +1003,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1029,10 +1041,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1071,23 +1083,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { 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 528076c6ae..4ce88a1162 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -279,6 +279,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -454,10 +466,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -510,10 +522,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -564,10 +576,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -614,10 +626,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -657,10 +669,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -711,10 +723,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -754,10 +766,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -797,10 +809,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -844,10 +856,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -896,10 +908,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -948,10 +960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -986,10 +998,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1035,10 +1047,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1062,10 +1074,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1100,10 +1112,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1142,23 +1154,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts new file mode 100644 index 0000000000..6ccf386884 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Internal Responses API passthrough metadata copied into underlying chat messages. + * + * Responses API strongly types this payload. Do not modify it without first getting API + * approval and making the corresponding Responses API change. + */ +export type InternalChatMessageMetadataPassthrough = { turn_id?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index fc399b925f..074e11a16e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -4,15 +4,15 @@ import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; +import type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; -import type { ResponseItemMetadata } from "./ResponseItemMetadata"; import type { WebSearchAction } from "./WebSearchAction"; -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", +export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", /** * Legacy id field retained for compatibility with older payloads. */ @@ -20,4 +20,4 @@ id?: string, /** * Set when using the Responses API. */ -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" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts deleted file mode 100644 index 365c561fd1..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ResponseItemMetadata = { turn_id?: string, source_call_id?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7c94d9c601..dcfecf1282 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -41,6 +41,7 @@ export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; export type { InputModality } from "./InputModality"; +export type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; export type { InternalSessionSource } from "./InternalSessionSource"; export type { LegacyAppPathString } from "./LegacyAppPathString"; export type { LocalShellAction } from "./LocalShellAction"; @@ -68,7 +69,6 @@ export type { Resource } from "./Resource"; export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; -export type { ResponseItemMetadata } from "./ResponseItemMetadata"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 0157101cf0..1e087c4c5f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3775,7 +3775,7 @@ mod tests { text: "plain text".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 092c64505f..bca34d360d 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -134,12 +134,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 21dae50d6f..00b90e64d8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -60,7 +60,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu text: injected_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let inject_req = mcp @@ -198,7 +198,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() text: "Injected after first turn".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let injected_value = serde_json::to_value(&injected_item)?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 0d1fcd1e5d..ea7a1a9d46 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2589,7 +2589,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { text: "history override".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]), ..Default::default() }) @@ -3598,7 +3598,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { text: history_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; // Resume with explicit history and override the model. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d1f06ee2f8..d44825154a 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -825,7 +825,7 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: vec![json!({ "type": "function", diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 93f6249f10..d33e502ce3 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -162,7 +162,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }])), commands: Some(SearchCommands { search_query: Some(vec![SearchQuery { diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 488f6dc674..2d69f83860 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -313,7 +313,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: Vec::new(), tool_choice: "auto".into(), @@ -518,7 +518,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: Vec::new(), tool_choice: "auto".into(), diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index 38714e2512..5f44e33530 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -83,7 +83,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -95,7 +95,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7eac69a88f..17532bd785 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -72,7 +72,7 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -92,7 +92,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { namespace: None, arguments: "{}".to_string(), call_id: call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -909,7 +909,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -918,7 +918,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), @@ -928,7 +928,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { summary: Vec::new(), content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, trigger_message.to_response_input_item().into(), spawn_agent_call(&parent_spawn_call_id), @@ -996,7 +996,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "parent seed context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), ResponseItem::Message { @@ -1006,7 +1006,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Child subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!( @@ -1125,7 +1125,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "compacted parent summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1134,7 +1134,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; parent_thread @@ -1449,7 +1449,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li text: "parent startup developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1571,7 +1571,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, spawn_agent_call(&parent_spawn_call_id), ], diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 73d7398a3d..c6cb92907f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -781,7 +781,9 @@ impl ModelClient { let instructions = &prompt.base_instructions.text; let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); if !self.state.provider.info().is_openai() { - input.iter_mut().for_each(ResponseItem::clear_metadata); + input + .iter_mut() + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let tools = create_tools_json_for_responses_api(&prompt.tools)?; let reasoning = Self::build_reasoning(model_info, effort, summary); @@ -1072,7 +1074,7 @@ impl ModelClientSession { if !self.client.state.provider.info().is_openai() { response_items .iter_mut() - .for_each(ResponseItem::clear_metadata); + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice()) else { diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8107049ab3..8b25c7e300 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -20,7 +20,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Original), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -31,7 +31,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::High), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -43,7 +43,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Auto), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ..Default::default() @@ -68,7 +68,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -79,7 +79,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -91,7 +91,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 1d0c4c48c2..0584e290fc 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -226,7 +226,7 @@ fn output_message(id: &str, text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 21a86f224e..6d51a0032b 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -81,7 +81,7 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { call_id: "call-1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, }), }) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 85ca1e1a61..a522cd62aa 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -452,7 +452,7 @@ impl CodexThread { role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; self.codex .session diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 8a5efd5947..94c26b8ad7 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -30,9 +30,9 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; @@ -459,7 +459,7 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { #[derive(Clone, Debug, PartialEq)] pub(crate) struct CompactedUserMessage { message: String, - metadata: Option, + internal_chat_message_metadata_passthrough: Option, } pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { @@ -472,8 +472,11 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec metadata.clone(), + internal_chat_message_metadata_passthrough: match item { + ResponseItem::Message { + internal_chat_message_metadata_passthrough, + .. + } => internal_chat_message_metadata_passthrough.clone(), _ => None, }, }) @@ -580,7 +583,9 @@ fn build_compacted_history_with_limit( truncate_text(&message.message, TruncationPolicy::Tokens(remaining)); selected_messages.push(CompactedUserMessage { message: truncated, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); break; } @@ -596,7 +601,9 @@ fn build_compacted_history_with_limit( text: message.message.clone(), }], phase: None, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); } @@ -611,7 +618,7 @@ fn build_compacted_history_with_limit( role: "user".to_string(), content: vec![ContentItem::InputText { text: summary_text }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); history diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 7a47881e47..3d23140684 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -414,31 +414,31 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::ToolSearchOutput { call_id, status, execution, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => ResponseItem::ToolSearchOutput { id: item.id().map(str::to_string), @@ -446,7 +446,7 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option return None, }) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0b382700ac..0778c74729 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -234,7 +234,9 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { metadata: None }); + input.push(ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -526,7 +528,7 @@ fn truncate_message_text_to_token_budget( role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return Some(item); @@ -565,7 +567,7 @@ fn truncate_message_text_to_token_budget( role, content: truncated_content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, }) } @@ -586,7 +588,7 @@ mod tests { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -618,18 +620,18 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "old".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -658,7 +660,7 @@ mod tests { let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -685,12 +687,12 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (_, retained_image_count) = build_v2_compacted_history(&input, output); @@ -738,7 +740,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let truncated = @@ -762,7 +764,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -777,7 +779,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![ @@ -802,7 +804,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![image_only_message, newest.clone()]; @@ -818,7 +820,7 @@ mod tests { let compaction = ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index a09a6dbb3e..bb6adf643d 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -2,7 +2,7 @@ use super::*; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; -use codex_protocol::models::ResponseItemMetadata; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; async fn process_compacted_history_with_test_session( @@ -32,14 +32,14 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn compacted_user_message(text: &str) -> CompactedUserMessage { CompactedUserMessage { message: text.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -84,7 +84,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: Some("user".to_string()), @@ -93,7 +93,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "first".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Other, ]; @@ -118,7 +118,7 @@ do things .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -127,7 +127,7 @@ do things text: "cwd=/tmp".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -136,7 +136,7 @@ do things text: "real user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -231,15 +231,16 @@ fn build_token_limited_compacted_history_appends_summary_message() { } #[test] -fn build_compacted_history_preserves_user_message_metadata() { +fn build_compacted_history_preserves_user_message_passthrough_metadata() { let history = build_compacted_history( Vec::new(), &[CompactedUserMessage { message: "first user message".to_string(), - metadata: Some(ResponseItemMetadata { - turn_id: Some("turn-1".to_string()), - ..Default::default() - }), + internal_chat_message_metadata_passthrough: Some( + InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + }, + ), }], "summary text", ); @@ -282,7 +283,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -291,7 +292,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -300,7 +301,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale personality".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -315,7 +316,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -329,7 +330,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let (refreshed, mut expected) = process_compacted_history_with_test_session( compacted_history, @@ -343,7 +344,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -363,7 +364,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -376,7 +377,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -389,7 +390,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -398,7 +399,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -407,7 +408,7 @@ keep me updated text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -422,7 +423,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -462,7 +463,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -471,7 +472,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -480,7 +481,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -497,7 +498,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -506,7 +507,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; expected.extend(initial_context); @@ -517,7 +518,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -531,7 +532,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let previous_turn_settings = PreviousTurnSettings { model: "previous-regular-model".to_string(), @@ -562,7 +563,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -577,7 +578,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -586,7 +587,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -595,7 +596,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let initial_context = vec![ResponseItem::Message { @@ -605,7 +606,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -618,7 +619,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -627,7 +628,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -636,7 +637,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -645,7 +646,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); @@ -656,7 +657,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let initial_context = vec![ResponseItem::Message { id: None, @@ -665,7 +666,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -678,12 +679,12 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index eb6f094248..a639aa51ba 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -359,25 +359,25 @@ impl ContextManager { id, call_id, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bbf8d8059a..b388a1a060 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -10,12 +10,12 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; @@ -43,7 +43,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -62,7 +62,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -96,7 +96,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -108,7 +108,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -120,7 +120,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -135,7 +135,7 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { }) .collect(), phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -174,7 +174,7 @@ fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -188,7 +188,7 @@ fn reasoning_msg(text: &str) -> ResponseItem { text: text.to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -200,7 +200,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { }], content: None, encrypted_content: Some("a".repeat(len)), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -224,7 +224,7 @@ fn filters_non_api_messages() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let reasoning = reasoning_msg("thinking..."); h.record_items([&system, &reasoning, &ResponseItem::Other], policy); @@ -247,7 +247,7 @@ fn filters_non_api_messages() { text: "thinking...".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -256,7 +256,7 @@ fn filters_non_api_messages() { text: "hi".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -265,7 +265,7 @@ fn filters_non_api_messages() { text: "hello".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -415,7 +415,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -423,7 +423,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -437,7 +437,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -445,7 +445,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -460,7 +460,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let history = create_history_with_items(items); @@ -484,7 +484,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -492,7 +492,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -506,7 +506,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -514,7 +514,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -529,7 +529,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(stripped, expected); @@ -549,7 +549,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]); let preserved = with_images.for_prompt(&modalities); assert_eq!(preserved.len(), 1); @@ -569,7 +569,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -578,7 +578,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -590,7 +590,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -599,7 +599,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -615,14 +615,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -636,14 +636,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -680,13 +680,13 @@ fn remove_first_item_removes_matching_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -701,7 +701,7 @@ fn remove_first_item_removes_matching_call_for_output() { id: None, call_id: "call-2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -709,7 +709,7 @@ fn remove_first_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -733,7 +733,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut history = create_history_with_items(items); @@ -755,7 +755,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -771,7 +771,7 @@ fn replace_last_turn_images_does_not_touch_user_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut history = create_history_with_items(items.clone()); @@ -793,13 +793,13 @@ fn remove_first_item_handles_local_shell_pair() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-3".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1022,14 +1022,14 @@ fn remove_first_item_handles_custom_tool_pair() { call_id: "tool-1".to_string(), name: "my_tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1051,13 +1051,13 @@ fn normalization_retains_local_shell_outputs() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1082,9 +1082,8 @@ fn record_items_truncates_function_call_output_content() { body: FunctionCallOutputBody::Text(long_output.clone()), success: Some(true), }, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("turn-1".to_string()), - ..Default::default() }), }; @@ -1120,7 +1119,7 @@ fn record_items_truncates_custom_tool_call_output_content() { call_id: "tool-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1155,7 +1154,7 @@ fn record_items_respects_custom_token_limit() { body: FunctionCallOutputBody::Text(long_output), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1275,7 +1274,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1290,13 +1289,13 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1311,7 +1310,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1326,14 +1325,14 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-x".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1353,7 +1352,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1373,13 +1372,13 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1392,7 +1391,7 @@ fn normalize_removes_orphan_function_call_output() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1409,7 +1408,7 @@ fn normalize_removes_orphan_custom_tool_call_output() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1429,14 +1428,14 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Will get an inserted custom tool output ResponseItem::CustomToolCall { @@ -1445,7 +1444,7 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Local shell call also gets an inserted function call output ResponseItem::LocalShellCall { @@ -1459,7 +1458,7 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1475,13 +1474,13 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1489,14 +1488,14 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "t1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1509,13 +1508,13 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "s1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1529,7 +1528,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1542,13 +1541,13 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1562,7 +1561,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1577,7 +1576,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -1585,7 +1584,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1601,7 +1600,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1622,7 +1621,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1636,7 +1635,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1651,7 +1650,7 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1666,7 +1665,7 @@ fn normalize_removes_orphan_client_tool_search_output() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1685,7 +1684,7 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1699,7 +1698,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1713,7 +1712,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1729,13 +1728,13 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1743,7 +1742,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1756,7 +1755,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1780,7 +1779,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let text_only_item = ResponseItem::Message { id: None, @@ -1789,7 +1788,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { text: "Here is the screenshot".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&image_item).unwrap().len() as i64; @@ -1818,7 +1817,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1846,7 +1845,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1867,7 +1866,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let function_output_item = ResponseItem::FunctionCallOutput { id: None, @@ -1878,7 +1877,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1902,7 +1901,7 @@ fn encrypted_function_output_uses_plaintext_byte_estimate() { encrypted_content: encrypted_content.clone(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1923,7 +1922,7 @@ fn data_url_without_base64_marker_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1945,7 +1944,7 @@ fn non_image_base64_data_url_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1966,7 +1965,7 @@ fn mixed_case_data_url_markers_are_adjusted() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1999,7 +1998,7 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2034,7 +2033,7 @@ fn original_detail_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2066,7 +2065,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2101,7 +2100,7 @@ fn original_detail_webp_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2120,7 +2119,7 @@ fn text_only_items_unchanged() { text: "Hello world, this is a response.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let estimated = estimate_response_item_model_visible_bytes(&item); diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 13aacca1e6..d11cffe4e9 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -50,7 +50,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -67,7 +67,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -84,7 +84,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -102,7 +102,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 22f2dbe6fe..b83c1b985c 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -235,7 +235,7 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -235,7 +235,7 @@ fn skips_user_instructions_and_env() { text: "test_text".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -243,7 +243,7 @@ fn skips_user_instructions_and_env() { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -252,7 +252,7 @@ fn skips_user_instructions_and_env() { .to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -260,7 +260,7 @@ fn skips_user_instructions_and_env() { text: "echo 42".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -275,7 +275,7 @@ fn skips_user_instructions_and_env() { }, ], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ]; for item in items { @@ -325,7 +325,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { }, ], phase: None, - metadata: None,}; + internal_chat_message_metadata_passthrough: None,}; let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); @@ -357,7 +357,7 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { .render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(parse_turn_item(&item).is_none()); @@ -372,7 +372,7 @@ fn parses_agent_message() { text: "Hello from Codex".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected agent message turn item"); @@ -404,7 +404,7 @@ fn parses_reasoning_summary_and_raw_content() { text: "raw details".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -437,7 +437,7 @@ fn parses_reasoning_including_raw_content() { }, ]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -463,7 +463,7 @@ fn parses_web_search_call() { query: Some("weather".to_string()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -492,7 +492,7 @@ fn parses_web_search_open_page_call() { action: Some(WebSearchAction::OpenPage { url: Some("https://example.com".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -521,7 +521,7 @@ fn parses_web_search_find_in_page_call() { url: Some("https://example.com".to_string()), pattern: Some("needle".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -548,7 +548,7 @@ fn parses_partial_web_search_call_without_action_as_other() { id: Some("ws_partial".to_string()), status: Some("in_progress".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 15024990a2..db8c0647f3 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -298,7 +298,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc, turn: &Arc, turn: &Arc, turn: &Arc anyh text: "Please also push the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -540,7 +540,7 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh text: "I need approval for the second push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -663,7 +663,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted retained user request.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -672,7 +672,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted summary of earlier guardian context.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], /*reference_context_item*/ None, @@ -689,7 +689,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Please push after the compaction.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "I need approval for the post-compaction push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -746,7 +746,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "\n/tmp\n".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -755,7 +755,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -783,7 +783,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: "ordinary developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -792,7 +792,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: approval_text.clone(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -817,7 +817,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "check the repo".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -825,7 +825,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -833,7 +833,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { output: codex_protocol::models::FunctionCallOutputPayload::from_text( "repo is public".to_string(), ), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -842,7 +842,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "I need to push a fix".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1698,7 +1698,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1930,7 +1930,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the second docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1939,7 +1939,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -1977,7 +1977,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the third docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1986,7 +1986,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the third docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -2726,7 +2726,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Please inspect pending changes before pushing.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2734,7 +2734,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to run git diff.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; @@ -2793,7 +2793,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Now inspect whether pushing is safe.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2801,7 +2801,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to push after the diff check.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 8ddbd76965..7bc4b8e103 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -49,7 +49,7 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -86,7 +86,7 @@ fn detail_policies_apply_the_expected_budgets() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url, detail }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -133,7 +133,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -165,7 +165,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 352d6ae8e4..e74d5b3e06 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -78,7 +78,7 @@ fn message(role: &str, content: ContentItem) -> ResponseItem { role: role.to_string(), content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 1823b28fdb..b28bd37c79 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -20,7 +20,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -32,7 +32,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -51,7 +51,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5c56f03d02..903ea0b477 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -107,8 +107,8 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; @@ -196,7 +196,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -210,7 +210,7 @@ fn assign_missing_response_item_ids_skips_agent_messages() { content: vec![AgentMessageInputContent::InputText { text: "done".to_string(), }], - metadata: None, + internal_chat_message_metadata_passthrough: None, }, user_message("hello"), ]); @@ -229,7 +229,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -289,7 +289,7 @@ fn skill_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -1636,9 +1636,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "summary".to_string(), }], phase: None, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("compact-turn".to_string()), - ..Default::default() }), }; let replacement_history = vec![ @@ -1650,7 +1649,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let first_window_id = Uuid::now_v7(); @@ -1722,7 +1721,7 @@ async fn prepares_image_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1757,7 +1756,7 @@ async fn prepares_image_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; assert_eq!(history.raw_items(), expected.as_slice()); } @@ -1778,7 +1777,7 @@ async fn prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1803,7 +1802,7 @@ async fn prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -7923,7 +7922,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }], /*reference_context_item*/ None, ) @@ -8176,7 +8175,7 @@ async fn handle_output_item_done_records_image_save_history_message() { status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8233,7 +8232,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { status: "completed".to_string(), revised_prompt: Some("broken payload".to_string()), result: "_-8".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8399,7 +8398,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn_context, std::slice::from_ref(&compacted_summary)) @@ -8998,7 +8997,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!( history.raw_items().iter().any(|item| item == &expected), @@ -9431,7 +9430,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_state = { let mut active = sess.active_turn.lock().await; @@ -9690,7 +9689,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { sess: Arc::clone(&sess), @@ -9818,7 +9817,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { call_id: "call-1".to_string(), name: "shell_command".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item.clone()) @@ -9903,7 +9902,7 @@ async fn sample_rollout( text: "first user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user1), @@ -9918,7 +9917,7 @@ async fn sample_rollout( text: "assistant reply one".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant1), @@ -9950,7 +9949,7 @@ async fn sample_rollout( text: "second user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user2), @@ -9965,7 +9964,7 @@ async fn sample_rollout( text: "assistant reply two".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant2), @@ -9997,7 +9996,7 @@ async fn sample_rollout( text: "third user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user3), @@ -10012,7 +10011,7 @@ async fn sample_rollout( text: "assistant reply three".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant3), @@ -10158,7 +10157,7 @@ while :; do sleep 1; done"#, }) .to_string(), call_id: "shell-cleanup-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item)? .expect("shell command response item should build a tool call"); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 7011e6d0f5..e44b12ef72 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -535,7 +535,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "stale developer message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -544,7 +544,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], InitialContextInjection::BeforeLastUserMessage, diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 7c9f003cb4..051799cf85 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -33,7 +33,7 @@ fn assistant_output_text(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 5a2f854782..71a9ca7d21 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -627,7 +627,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::CustomToolCallOutput { @@ -639,7 +639,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti call_id: call_id.clone(), name: name.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.as_function_call_output_payload(); @@ -647,7 +647,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::ToolSearchOutput { @@ -661,7 +661,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti status: status.clone(), execution: execution.clone(), tools: tools.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), _ => None, } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 45c5d18e68..b01ed4c4ca 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -42,7 +42,7 @@ fn assistant_output_text_with_phase(text: &str, phase: Option) -> text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -53,7 +53,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { id: None, status: Some("completed".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchCall { id: None, @@ -61,7 +61,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: None, execution: "client".to_string(), arguments: serde_json::json!({"query": "calendar"}), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -69,7 +69,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -94,7 +94,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -102,13 +102,13 @@ fn external_context_pollution_items_exclude_local_tool_calls() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -116,14 +116,14 @@ fn external_context_pollution_items_exclude_local_tool_calls() { call_id: "custom-1".to_string(), name: "apply_patch".to_string(), input: "*** Begin Patch\n*** End Patch\n".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-1".to_string(), name: Some("apply_patch".to_string()), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_output_text("plain assistant text"), ]; @@ -425,7 +425,7 @@ fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(completed_item_defers_mailbox_delivery_to_next_turn( diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b24d43df3d..1745a40256 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -113,7 +113,7 @@ pub(crate) fn interrupted_turn_history_marker( text: marker.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 7c18a5f787..19ac0fdc35 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -248,7 +248,7 @@ pub(crate) async fn exit_review_mode( role: "user".to_string(), content: vec![ContentItem::InputText { text: user_message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -269,7 +269,7 @@ pub(crate) async fn exit_review_mode( text: assistant_message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ) .await; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d4515e87d8..afb103b883 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -42,7 +42,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn assistant_msg(text: &str) -> ResponseItem { @@ -53,7 +53,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -82,7 +82,7 @@ fn truncates_before_requested_user_message() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -90,7 +90,7 @@ fn truncates_before_requested_user_message() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bdbdbc4c75..2df3d1c98e 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -15,7 +15,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -27,7 +27,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -39,7 +39,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -79,7 +79,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -87,7 +87,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index 7dd6615095..c467fe0d6f 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -303,7 +303,7 @@ impl CoreTurnHost { call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]) .await .map_err(|_| { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index ae79ba405d..808a9c123d 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -329,7 +329,7 @@ mod tests { text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 76881f5a06..2561cd7ded 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1198,11 +1198,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-spawn-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -1230,11 +1225,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-send-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && !communication.trigger_turn ) })); @@ -2048,11 +2038,6 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() if communication.author == AgentPath::root() && communication.recipient == worker_path && communication.encrypted_content.as_deref() == Some("continue") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -2831,7 +2816,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { text: "materialized".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 81eebed4a3..7fdb80e8bc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -6,7 +6,6 @@ use super::*; use crate::tools::context::FunctionToolOutput; use crate::turn_timing::now_unix_timestamp_ms; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -100,12 +99,8 @@ pub(crate) async fn handle_message_string_tool( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, receiver_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index c04d46c6e4..837257dec9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -8,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -123,12 +122,8 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, new_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); Op::InterAgentCommunication { communication } } initial_operation => initial_operation, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index a38ab8af26..9cf237477f 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -157,7 +157,7 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() namespace: Some("mcp__codex_apps__calendar".to_string()), arguments: "{}".to_string(), call_id: "call-namespace".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); @@ -340,7 +340,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) @@ -376,7 +376,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: "call-extension".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); let result = router diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 9f1bcd31b1..956468da23 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -112,7 +112,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft( @@ -122,7 +122,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { call_id: "call-2".to_string(), name: "custom".to_string(), input: "echo hi".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft(&ResponseItem::Message { @@ -132,7 +132,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); } @@ -145,14 +145,14 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { text: String::new(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index b071535c52..10abf64b6b 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -684,7 +684,7 @@ pub fn user_message_item(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index cf9b9a939f..2f323e0dd3 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -141,7 +141,7 @@ async fn responses_stream_includes_subagent_header_on_review() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -273,7 +273,7 @@ async fn responses_stream_includes_subagent_header_on_other() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -391,7 +391,7 @@ async fn responses_respects_model_info_overrides_from_config() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3c905351cc..cbd014c3f7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -186,7 +186,7 @@ fn assert_codex_client_metadata( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_openai_responses_requests_omit_item_turn_metadata() { +async fn non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; let response_mock = mount_sse_once( &server, @@ -230,8 +230,9 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { assert!(!input.is_empty(), "request should include input items"); for item in input { assert!( - item.get("metadata").is_none(), - "input item should omit metadata: {item}" + item.get("internal_chat_message_metadata_passthrough") + .is_none(), + "input item should omit internal chat message metadata passthrough: {item}" ); assert!( item.get("id").is_none(), @@ -537,7 +538,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_user_json = serde_json::to_value(&prior_user).unwrap(); writeln!( @@ -559,7 +560,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed system instruction".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_system_json = serde_json::to_value(&prior_system).unwrap(); writeln!( @@ -581,7 +582,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed assistant message".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_item_json = serde_json::to_value(&prior_item).unwrap(); writeln!( @@ -724,7 +725,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: "js_repl".to_string(), input: "console.log('legacy image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let thread_id = ThreadId::default(); @@ -757,7 +758,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: None, output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -770,7 +771,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -890,7 +891,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { namespace: None, arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -904,7 +905,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -915,7 +916,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { call_id: custom_call_id.to_string(), name: "js_repl".to_string(), input: "console.log('image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -930,7 +931,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -1179,7 +1180,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session @@ -2793,7 +2794,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "content".into(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::Message { id: Some("message-id".into()), @@ -2802,7 +2803,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "message".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::WebSearchCall { id: Some("web-search-id".into()), @@ -2811,7 +2812,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { query: Some("weather".into()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), @@ -2819,13 +2820,13 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { id: None, call_id: "function-call-id".into(), output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::LocalShellCall { id: Some("local-shell-id".into()), @@ -2838,7 +2839,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCall { id: Some("custom-tool-id".into()), @@ -2846,14 +2847,14 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { call_id: "custom-tool-call-id".into(), name: "custom_tool".into(), input: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-tool-call-id".into(), name: None, output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1ccbd302d8..5d1721ee75 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2059,7 +2059,7 @@ fn message_item(text: &str) -> ResponseItem { role: "user".into(), content: vec![ContentItem::InputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -2069,7 +2069,7 @@ fn assistant_message_item(id: &str, text: &str) -> ResponseItem { role: "assistant".into(), content: vec![ContentItem::OutputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index fb20603850..dec106d0d8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2037,12 +2037,12 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { text: remote_summary.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4163,12 +4163,12 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4291,12 +4291,12 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 77561093c6..ececb02b22 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -159,7 +159,7 @@ fn compacted_summary_only_output(summary: &str) -> Vec { vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix(summary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] } @@ -333,7 +333,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), @@ -2361,7 +2361,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -2370,7 +2370,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text: "COMPACTED_ASSISTANT_NOTE".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2512,12 +2512,12 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2655,12 +2655,12 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -4067,7 +4067,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index c97e8cc896..f644f33404 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -178,7 +178,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(actual, expected); @@ -269,7 +269,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(actual, expected); diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 5883a8564b..0628f8ff98 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -49,7 +49,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text: "hello from debug prompt".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(input.last(), Some(&expected_user_message)); assert!(input.iter().any(|item| { diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index dd97e5a5d3..1a95223bff 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2350,7 +2350,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge role: "user".to_string(), content: vec![ContentItem::InputText { text: user_turn }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::ResponseItem(ResponseItem::Message { id: None, @@ -2359,7 +2359,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge text: assistant_turn, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ] }) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 99794939dc..af44baf6ca 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -532,7 +532,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: earlier user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let user_json = serde_json::to_value(&user).unwrap(); let user_line = serde_json::json!({ @@ -552,7 +552,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: assistant reply".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let assistant_json = serde_json::to_value(&assistant).unwrap(); let assistant_line = serde_json::json!({ diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 2b9395f598..b24f1c1654 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1090,9 +1090,6 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "type": "agent_message", "author": "/root", "recipient": "/root/worker", - "metadata": { - "source_call_id": SPAWN_CALL_ID, - }, "content": [ { "type": "input_text", diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index e2f5ac666c..fe915746f9 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -76,7 +76,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -84,13 +84,13 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { namespace: None, arguments: "{}".to_string(), call_id: "mcp-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "mcp-call".to_string(), output: image_output("mcp"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -98,27 +98,27 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { call_id: "code-mode-call".to_string(), name: "exec".to_string(), input: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "code-mode-call".to_string(), name: Some("exec".to_string()), output: image_output("code-mode"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("generated-call".to_string()), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "orphan-call".to_string(), output: image_output("orphan"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -206,7 +206,7 @@ async fn recent_image_fallback_requires_requested_count() { role: "user".to_string(), content: vec![input_image("only-image")], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], &[], ) diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index df3faf123c..10fb0dd65e 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -36,7 +36,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { ResponseItem::AgentMessage { author, content, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => { if let Some(text) = plaintext_agent_message_content(content) { @@ -47,7 +47,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { text: format!("Agent message from {author}:\n{text}"), }], phase: None, - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -56,7 +56,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } if role == USER_ROLE && matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) => { @@ -71,7 +71,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -104,7 +104,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -125,7 +125,7 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, previous_assistant, message("developer", "developer"), @@ -158,7 +158,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let items = vec![ previous_user, diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 2990c0f5ea..6391d27bd3 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -141,7 +141,7 @@ fn response_item(message: ConversationMessage) -> ResponseItem { }, content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 026826b7c9..5b5ae81662 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -303,7 +303,7 @@ mod job { )?, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prompt.base_instructions = BaseInstructions { text: crate::stage_one::PROMPT.to_string(), @@ -429,7 +429,7 @@ mod job { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return should_persist_response_item_for_memories(item).then(|| item.clone()); @@ -457,7 +457,7 @@ mod job { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }) } @@ -687,7 +687,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let skill_message = ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let subagent_message = ResponseItem::Message { id: None, @@ -708,7 +708,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let serialized = job::serialize_filtered_rollout_response_items(&[ @@ -730,7 +730,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, subagent_message, ] @@ -750,7 +750,7 @@ mod tests { ), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )]) .expect("serialize"); diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 13ba9da3b7..fad1ce14a4 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -685,7 +685,7 @@ async fn seed_stage1_candidate( text: "remember this startup test conversation".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; let jsonl = serde_json::to_string(&line)?; diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 08d257dc3b..76bc0e3992 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -404,7 +404,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub source_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -931,7 +932,7 @@ pub enum ResponseItem { phase: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, AgentMessage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -942,7 +943,7 @@ pub enum ResponseItem { content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, Reasoning { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -955,7 +956,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, LocalShellCall { /// Legacy id field retained for compatibility with older payloads. @@ -968,7 +969,7 @@ pub enum ResponseItem { action: LocalShellAction, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, FunctionCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -985,7 +986,7 @@ pub enum ResponseItem { call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1000,7 +1001,7 @@ pub enum ResponseItem { arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with // custom serialization. On the wire it is either: @@ -1017,7 +1018,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, CustomToolCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1032,7 +1033,7 @@ pub enum ResponseItem { input: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // `custom_tool_call_output.output` uses the same wire encoding as // `function_call_output.output` so freeform tools can return either plain @@ -1050,7 +1051,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchOutput { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1063,7 +1064,7 @@ pub enum ResponseItem { tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers a web search. // Example payload (from SSE `response.output_item.done`): @@ -1085,7 +1086,7 @@ pub enum ResponseItem { action: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers image generation. // Example payload: @@ -1107,7 +1108,7 @@ pub enum ResponseItem { result: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(alias = "compaction_summary")] Compaction { @@ -1117,14 +1118,14 @@ pub enum ResponseItem { encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Compaction triggers are request controls, and the Responses API does not // accept an `id` field for them. CompactionTrigger { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1135,7 +1136,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(other)] Other, @@ -1191,7 +1192,7 @@ impl ResponseItem { /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { - self.metadata() + self.internal_chat_message_metadata_passthrough() .and_then(|metadata| metadata.turn_id.as_deref()) .filter(|turn_id| !turn_id.is_empty()) } @@ -1201,59 +1202,154 @@ impl ResponseItem { if turn_id.is_empty() || self.turn_id().is_some() { return; } - let Some(metadata) = self.metadata_mut() else { + let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; metadata - .get_or_insert_with(ResponseItemMetadata::default) + .get_or_insert_with(InternalChatMessageMetadataPassthrough::default) .turn_id = Some(turn_id.to_string()); } - /// Removes Responses API item metadata before sending to a provider that does not accept it. - pub fn clear_metadata(&mut self) { - if let Some(metadata) = self.metadata_mut() { + /// Removes internal chat message metadata passthrough before sending to a provider that does + /// not accept it. + pub fn clear_internal_chat_message_metadata_passthrough(&mut self) { + if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() { *metadata = None; } } - fn metadata(&self) -> Option<&ResponseItemMetadata> { + fn internal_chat_message_metadata_passthrough( + &self, + ) -> Option<&InternalChatMessageMetadataPassthrough> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => metadata.as_ref(), + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => metadata.as_ref(), Self::Other => None, } } - fn metadata_mut(&mut self) -> Option<&mut Option> { + fn internal_chat_message_metadata_passthrough_mut( + &mut self, + ) -> Option<&mut Option> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => Some(metadata), + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => Some(metadata), Self::Other => None, } } @@ -1495,13 +1591,13 @@ impl From for ResponseItem { content, id: None, phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.into_function_call_output_payload(); @@ -1509,7 +1605,7 @@ impl From for ResponseItem { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } ResponseInputItem::CustomToolCallOutput { @@ -1521,7 +1617,7 @@ impl From for ResponseItem { call_id, name, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::ToolSearchOutput { call_id, @@ -1534,7 +1630,7 @@ impl From for ResponseItem { execution, tools, id: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, } } @@ -2087,14 +2183,15 @@ mod tests { text: "still working".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } #[test] - fn response_item_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { - let mut item = response_item_with_metadata(Some(response_item_metadata("turn-1"))); + fn response_item_passthrough_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { + let mut item = + response_item_with_passthrough_metadata(Some(passthrough_metadata("turn-1"))); let round_trip: ResponseItem = serde_json::from_value(serde_json::to_value(&item)?)?; assert_eq!(round_trip, item); @@ -2102,7 +2199,7 @@ mod tests { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}], - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", "other": "ignored", }, @@ -2112,11 +2209,14 @@ mod tests { item.stamp_turn_id_if_missing("turn-2"); assert_eq!(item.turn_id(), Some("turn-1")); - let mut empty_turn_id = response_item_with_metadata(Some(response_item_metadata(""))); + let mut empty_turn_id = + response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); empty_turn_id.stamp_turn_id_if_missing("turn-1"); assert_eq!(empty_turn_id.turn_id(), Some("turn-1")); - let mut missing_turn_id = response_item_with_metadata(/*metadata*/ None); + let mut missing_turn_id = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); missing_turn_id.stamp_turn_id_if_missing(""); missing_turn_id.stamp_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); @@ -2129,7 +2229,9 @@ mod tests { #[test] fn response_item_id_getter_and_setter() { - let mut item = response_item_with_metadata(/*metadata*/ None); + let mut item = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); assert_eq!(item.id(), None); item.set_id(Some("msg_test".to_string())); @@ -2141,7 +2243,9 @@ mod tests { assert_eq!(item.id(), None); } - fn response_item_with_metadata(metadata: Option) -> ResponseItem { + fn response_item_with_passthrough_metadata( + internal_chat_message_metadata_passthrough: Option, + ) -> ResponseItem { ResponseItem::Message { id: None, role: "user".to_string(), @@ -2149,14 +2253,13 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata, + internal_chat_message_metadata_passthrough, } } - fn response_item_metadata(turn_id: &str) -> ResponseItemMetadata { - ResponseItemMetadata { + fn passthrough_metadata(turn_id: &str) -> InternalChatMessageMetadataPassthrough { + InternalChatMessageMetadataPassthrough { turn_id: Some(turn_id.to_string()), - ..Default::default() } } @@ -2261,7 +2364,7 @@ mod tests { status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2283,7 +2386,7 @@ mod tests { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2635,7 +2738,7 @@ mod tests { namespace: Some("mcp__codex_apps__gmail".to_string()), arguments: "{\"top_k\":5}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2983,7 +3086,7 @@ mod tests { ResponseItem::Compaction { id: None, encrypted_content: "abc".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3000,7 +3103,7 @@ mod tests { ResponseItem::ContextCompaction { id: None, encrypted_content: Some("abc".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3008,7 +3111,9 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; assert_eq!( serde_json::to_value(item)?, @@ -3020,15 +3125,17 @@ mod tests { } #[test] - fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; + fn serializes_stamped_compaction_trigger_passthrough_metadata() -> Result<()> { + let mut item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( serde_json::to_value(item)?, serde_json::json!({ "type": "compaction_trigger", - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", }, }) @@ -3042,7 +3149,12 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!( + item, + ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + } + ); Ok(()) } @@ -3134,7 +3246,7 @@ mod tests { id: expected_id.clone(), status: expected_status.clone(), action: expected_action.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(parsed, expected); @@ -3219,7 +3331,7 @@ mod tests { "query": "calendar create", "limit": 1, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3281,7 +3393,7 @@ mod tests { "additionalProperties": false, } })], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3335,7 +3447,7 @@ mod tests { arguments: serde_json::json!({ "paths": ["crm"], }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3356,7 +3468,7 @@ mod tests { status: "completed".to_string(), execution: "server".to_string(), tools: vec![], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ea1df417a4..dfcc6cb7a5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -42,7 +42,6 @@ use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; use crate::models::ResponseItem; -use crate::models::ResponseItemMetadata; use crate::models::SandboxEnforcement; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -697,9 +696,6 @@ pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub encrypted_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub metadata: Option, pub trigger_turn: bool, } @@ -717,7 +713,6 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, - metadata: None, trigger_turn, } } @@ -735,7 +730,6 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), - metadata: None, trigger_turn, } } @@ -779,7 +773,7 @@ impl InterAgentCommunication { author: self.author.to_string(), recipient: self.recipient.to_string(), content, - metadata: self.metadata.clone(), + internal_chat_message_metadata_passthrough: None, } } @@ -3060,7 +3054,7 @@ impl From for ResponseItem { text: value.message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } } @@ -4349,7 +4343,6 @@ mod tests { other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, - metadata: None, trigger_turn: true, }; @@ -4390,7 +4383,7 @@ mod tests { encrypted_content: "encrypted payload".to_string(), }, ], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 7afdcf5847..66f26f9758 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -504,7 +504,7 @@ mod tests { text: "raw reasoning".to_string(), }]), encrypted_content: Some("encoded".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let normal = serde_json::to_value(&item).expect("response item serializes"); diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 21d712e0d1..983fff824c 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1320,7 +1320,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { text: format!("reply-{idx}"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; writeln!(file, "{}", serde_json::to_string(&response_line)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index b419ef3286..aa1dd04036 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -191,7 +191,7 @@ mod tests { text: "hello from response item".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); apply_rollout_item(&mut metadata, &item, "test-provider"); diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index e158c901ab..b96e05cfd5 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -513,7 +513,7 @@ mod tests { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TokenCount( codex_protocol::protocol::TokenCountEvent { diff --git a/codex-rs/tools/src/response_history.rs b/codex-rs/tools/src/response_history.rs index 4196d7c3ef..ab16769afd 100644 --- a/codex-rs/tools/src/response_history.rs +++ b/codex-rs/tools/src/response_history.rs @@ -95,7 +95,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 0b69153fa5..11ce6cb9e5 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -462,7 +462,7 @@ impl App { text: SIDE_BOUNDARY_PROMPT.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } From 20431d49a07d7cb6f3271a1e03929b444f3b50bb Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 11:34:14 -0700 Subject: [PATCH 07/10] [sdk/python] Stop advertising HTTP image URLs (#29464) ## Summary - use generated image data URLs in the Python SDK examples and notebook - document HTTP and HTTPS image URLs as deprecated and recommend `LocalImageInput` - replace the remote-URL integration test with data-URL coverage `ImageInput` remains available for data URLs. The SDK does not duplicate app-server URL validation. ## Testing - `uv run --frozen --no-sync ruff check --output-format=full .` - `uv run --frozen --no-sync ruff format --check .` - full Python SDK test suite with an isolated writable `CODEX_SQLITE_HOME` (119 passed, 38 skipped) --- sdk/python/docs/api-reference.md | 3 +++ .../examples/07_image_and_text/async.py | 6 ++--- sdk/python/examples/07_image_and_text/sync.py | 6 ++--- sdk/python/examples/README.md | 2 +- sdk/python/examples/_bootstrap.py | 6 +++++ sdk/python/notebooks/sdk_walkthrough.ipynb | 8 +++--- sdk/python/src/openai_codex/_inputs.py | 2 +- sdk/python/tests/test_app_server_inputs.py | 27 +++++++++++-------- 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index f003a28511..f253185dc5 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -245,6 +245,9 @@ Input = list[InputItem] | InputItem RunInput = Input | str ``` +Use `ImageInput` with a base64-encoded `data:image/...` URL. HTTP and HTTPS image URLs are +deprecated; download remote images and pass their local paths with `LocalImageInput` instead. + Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: `thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index c3237e320e..8673e455a3 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,7 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() @@ -13,7 +13,7 @@ import asyncio from openai_codex import AsyncCodex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() async def main() -> None: @@ -24,7 +24,7 @@ async def main() -> None: turn = await thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ) result = await turn.run() diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index f7402f18c9..1b20f8462d 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,20 +5,20 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() from openai_codex import Codex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ).run() diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 719fb29a41..a86580cc76 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -72,7 +72,7 @@ python examples/01_quickstart_constructor/async.py - `06_thread_lifecycle_and_controls/` - thread lifecycle + control calls - `07_image_and_text/` - - remote image URL + text multimodal turn + - image data URL + text multimodal turn - `08_local_image_and_text/` - local image + text multimodal turn using a generated temporary sample image - `09_async_parity/` diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index fde7a32c00..88039f4b9c 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import contextlib import importlib.util import sys @@ -95,6 +96,11 @@ def _generated_sample_png_bytes() -> bytes: ) +def generated_sample_image_data_url() -> str: + encoded = base64.b64encode(_generated_sample_png_bytes()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + @contextlib.contextmanager def temporary_sample_image_path() -> Iterator[Path]: with tempfile.TemporaryDirectory(prefix="codex-python-example-image-") as temp_root: diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1bb9bf731f..3f84cf0675 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -103,7 +103,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import server_label\n", + "from _bootstrap import generated_sample_image_data_url, server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -349,14 +349,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 6: multimodal with remote image\n", - "remote_image_url = 'https://raw.githubusercontent.com/github/explore/main/topics/python/python.png'\n", + "# Cell 6: multimodal with an image data URL\n", + "image_data_url = generated_sample_image_data_url()\n", "\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " result = thread.turn([\n", " TextInput('What do you see in this image? 3 bullets.'),\n", - " ImageInput(remote_image_url),\n", + " ImageInput(image_data_url),\n", " ]).run()\n", " print('status:', result.status)\n", " print(result.final_response)\n" diff --git a/sdk/python/src/openai_codex/_inputs.py b/sdk/python/src/openai_codex/_inputs.py index a6e5e7b528..0a1fbe1100 100644 --- a/sdk/python/src/openai_codex/_inputs.py +++ b/sdk/python/src/openai_codex/_inputs.py @@ -14,7 +14,7 @@ class TextInput: @dataclass(slots=True) class ImageInput: - """Remote image URL supplied as turn input.""" + """Image data URL supplied as turn input.""" url: str diff --git a/sdk/python/tests/test_app_server_inputs.py b/sdk/python/tests/test_app_server_inputs.py index 56505e4817..29b8fc7726 100644 --- a/sdk/python/tests/test_app_server_inputs.py +++ b/sdk/python/tests/test_app_server_inputs.py @@ -1,40 +1,45 @@ from __future__ import annotations +import base64 + from app_server_harness import AppServerHarness from app_server_helpers import TINY_PNG_BYTES from openai_codex import Codex, ImageInput, LocalImageInput, SkillInput, TextInput -def test_remote_image_input_reaches_responses_api( +def test_data_url_image_input_reaches_responses_api( tmp_path, ) -> None: - """Remote image inputs should survive the SDK and app-server boundary.""" - remote_image_url = "https://example.com/codex.png" + """Data URL image inputs should survive the SDK and app-server boundary.""" + image_data_url = "data:image/png;base64," + base64.b64encode(TINY_PNG_BYTES).decode("ascii") with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message( - "remote image received", - response_id="remote-image", + "data URL image received", + response_id="data-url-image", ) with Codex(config=harness.app_server_config()) as codex: result = codex.thread_start().run( [ - TextInput("Describe the remote image."), - ImageInput(remote_image_url), + TextInput("Describe the data URL image."), + ImageInput(image_data_url), ] ) request = harness.responses.single_request() assert { "final_response": result.final_response, - "contains_user_prompt": "Describe the remote image." in request.message_input_texts("user"), - "image_urls": request.message_image_urls("user"), + "contains_user_prompt": "Describe the data URL image." + in request.message_input_texts("user"), + "image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith( + "data:image/png;base64," + ), } == { - "final_response": "remote image received", + "final_response": "data URL image received", "contains_user_prompt": True, - "image_urls": [remote_image_url], + "image_url_is_png_data_url": True, } From 5c0fbf34925041ac94a267aa17237e4e07b0ce42 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 22 Jun 2026 11:45:41 -0700 Subject: [PATCH 08/10] [codex] Fix usage-limit reset copy and state (#28793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The reset flow introduced in #28154 still describes earned reset credits as "rate-limit resets" and uses generic reset-scope copy. It can also retain a stale available-credit count after redemption or an account change, leaving the reset action enabled after the last credit is used. This follow-up updates terminology only within that reset feature. Existing rate-limit wording elsewhere in the CLI and TUI is unchanged. ## What changed - Rename reset-specific `/usage` menu items, startup hints, and reset dialogs to "usage limit reset." - Describe monthly resets for Free, Go, and accounts that report a monthly usage window; otherwise describe the current 5-hour and weekly limits. - Recheck a cached zero balance when `/usage` is reopened, and refresh the balance after redemption so the final reset immediately disables the action. - Correlate async refresh results before updating snapshots and clear account-derived reset state, warnings, prompts, and status surfaces when the account changes. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 29 passed. - `just test -p codex-tui chatwidget::tests::status_command_tests` — 7 passed. - Account-boundary prompt and plan-mode prompt regression tests passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots.\ image view for monthly image --- codex-rs/tui/src/app/background_requests.rs | 24 +- codex-rs/tui/src/app/event_dispatch.rs | 59 ++++- codex-rs/tui/src/app_event.rs | 9 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 2 + codex-rs/tui/src/chatwidget/settings.rs | 25 ++- ...ests__rate_limit_reset_available_hint.snap | 2 +- ...it_reset_hint_waits_for_active_output.snap | 2 +- ..._tests__rate_limit_reset_popup_states.snap | 32 +-- ...chatwidget__tests__usage_command_menu.snap | 4 +- ...age_command_menu_before_reset_refresh.snap | 4 +- ...ts__usage_command_menu_without_resets.snap | 4 +- ...sage_limit_reset_confirmation_monthly.snap | 30 +++ .../tui/src/chatwidget/status_controls.rs | 16 +- .../src/chatwidget/tests/status_and_layout.rs | 33 +++ .../chatwidget/tests/status_command_tests.rs | 30 ++- codex-rs/tui/src/chatwidget/tests/usage.rs | 205 +++++++++++++++++- codex-rs/tui/src/chatwidget/usage.rs | 123 ++++++++--- codex-rs/tui/src/slash_command.rs | 2 +- 20 files changed, 496 insertions(+), 112 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 08e7f9b071..4a0652166d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -19,7 +19,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::RequestId; use crate::hooks_rpc::fetch_hooks_list; @@ -90,7 +89,8 @@ impl App { .and_then(|result| result.map_err(|err| err.to_string())) } RateLimitRefreshOrigin::StartupPrefetch { .. } - | RateLimitRefreshOrigin::StatusCommand { .. } => { + | RateLimitRefreshOrigin::StatusCommand { .. } + | RateLimitRefreshOrigin::UsageMenu { .. } => { request.await.map_err(|err| err.to_string()) } }; @@ -127,7 +127,7 @@ impl App { tokio::spawn(async move { let result = tokio::time::timeout( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_rate_limit_reset_credits(request_handle), + fetch_account_rate_limits(request_handle), ) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -789,24 +789,6 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } -pub(super) async fn fetch_rate_limit_reset_credits( - request_handle: AppServerRequestHandle, -) -> Result { - let request_id = RequestId::String(format!("account-rate-limit-resets-{}", Uuid::new_v4())); - let response: GetAccountRateLimitsResponse = request_handle - .request_typed(ClientRequest::GetAccountRateLimits { - request_id, - params: None, - }) - .await - .wrap_err("account/rateLimits/read failed in TUI")?; - response.rate_limit_reset_credits.ok_or_else(|| { - color_eyre::eyre::eyre!( - "account/rateLimits/read response did not include rateLimitResetCredits" - ) - }) -} - pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0830c69b0f..174c4d14e5 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -758,15 +758,14 @@ impl App { AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - for snapshot in app_server_rate_limit_snapshots(response) { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } + let snapshots = app_server_rate_limit_snapshots(response); match origin { RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { if self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -779,6 +778,7 @@ impl App { RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -788,7 +788,17 @@ impl App { } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, snapshots); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + snapshots, + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); } } } @@ -800,16 +810,27 @@ impl App { } => { self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetConsume { request_id } => { - self.chat_widget - .finish_post_consume_reset_credits_refresh(request_id, Err(err)); + self.chat_widget.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, Vec::new()); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + Vec::new(), + Err(err), + ); } } } @@ -822,15 +843,29 @@ impl App { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limit_reset_credits(app_server, request_id); } - AppEvent::RateLimitResetCreditsLoaded { request_id, result } => { - if let Err(err) = &result { + AppEvent::RateLimitResetCreditsLoaded { request_id, result } => match result { + Ok(response) => { + let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + app_server_rate_limit_snapshots(response), + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); + } + Err(err) => { tracing::warn!( "account/rateLimits/read failed during reset-credit refresh: {err}" ); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } - self.chat_widget - .finish_rate_limit_reset_credits_refresh(request_id, result); - } + }, AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 26686c3b53..55d67f2926 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -27,7 +27,6 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; @@ -119,7 +118,9 @@ pub(crate) struct PluginRemoteSectionError { /// updates the cached snapshots and any available reset-credit notice (no /// status card to finalize). A `StatusCommand` is tied to a specific `/status` /// invocation and must call `finish_status_rate_limit_refresh` when done so the -/// card stops showing a "refreshing" state. +/// card stops showing a "refreshing" state. A `UsageMenu` refreshes a cached +/// zero reset count so the disabled menu entry can become available without a +/// restart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -127,6 +128,8 @@ pub(crate) enum RateLimitRefreshOrigin { /// User-initiated via `/status`; the `request_id` correlates with the /// status card that should be updated when the fetch completes. StatusCommand { request_id: u64 }, + /// User reopened `/usage` while the cached reset-credit count was zero. + UsageMenu { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -315,7 +318,7 @@ pub(crate) enum AppEvent { /// Result of reading the current reset-credit balance. RateLimitResetCreditsLoaded { request_id: u64, - result: Result, + result: Result, }, /// Consume one reset credit using a stable idempotency key. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 20bef58a1a..aa7879f76d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -557,6 +557,7 @@ pub(crate) struct ChatWidget { next_token_activity_request_id: u64, pending_rate_limit_reset_request_id: Option, pending_rate_limit_reset_hint_request_id: Option, + pending_usage_menu_rate_limit_request_id: Option, pending_rate_limit_reset_hint: Option, available_rate_limit_reset_credits: Option, next_rate_limit_reset_request_id: u64, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 17f2c8c3b0..414a693bde 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -131,6 +131,7 @@ impl ChatWidget { next_token_activity_request_id: 0, pending_rate_limit_reset_request_id: None, pending_rate_limit_reset_hint_request_id: None, + pending_usage_menu_rate_limit_request_id: None, pending_rate_limit_reset_hint: None, available_rate_limit_reset_credits: None, next_rate_limit_reset_request_id: 0, diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index efb0b63628..77a0c5c7ec 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -5,6 +5,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; @@ -399,6 +400,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75c4cc66e4..269606098f 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_event::AppEvent; +use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -217,12 +218,22 @@ impl ChatWidget { has_chatgpt_account: bool, has_codex_backend_auth: bool, ) { - let account_state_changed = self.status_account_display != status_account_display - || self.has_chatgpt_account != has_chatgpt_account - || self.has_codex_backend_auth != has_codex_backend_auth; - if account_state_changed { - self.clear_pending_token_activity_refreshes(); - self.clear_pending_rate_limit_reset_requests(); + // Account-update notifications are the identity boundary. The visible account fields can + // be identical across two accounts, so always invalidate account-scoped requests and data. + self.clear_pending_token_activity_refreshes(); + self.clear_pending_rate_limit_reset_requests(); + self.codex_rate_limit_reached_type = None; + self.rate_limit_warnings = RateLimitWarningState::default(); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID); + let had_refreshing_status_outputs = !self.refreshing_status_outputs.is_empty(); + let now = Local::now(); + for (_, handle) in self.refreshing_status_outputs.drain(..) { + handle.finish_rate_limit_refresh(&[], now); + } + if had_refreshing_status_outputs { + self.request_redraw(); } self.status_line_workspace_headline = None; self.status_line_workspace_headline_pending_request_id = None; @@ -236,7 +247,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap index 5b528bb1df..56f0fc2f89 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/usage.rs expression: rendered --- -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap index 9e332a9256..e18178d460 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -4,4 +4,4 @@ expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expec --- active tool -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap index ba6066e0bc..12b1568f55 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -2,41 +2,41 @@ source: tui/src/chatwidget/tests/usage.rs expression: "states.join(\"\\n---\\n\")" --- - Rate-limit resets + Usage limit resets Checking your available resets... › Loading... Press enter to confirm or esc to go back --- - Rate-limit resets - You have 2 rate-limit resets available. + Usage limit resets + You have 2 usage limit resets available. - 1. Use a reset Reset your current Codex usage windows. + 1. Use a reset Reset your current 5-hour and weekly usage limits. › 2. Cancel Press enter to confirm or esc to go back --- - Rate-limit resets - You don't have any rate-limit resets available. + Usage limit resets + You don't have any usage limit resets available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - Couldn't load rate-limit resets. Please try again. + Usage limit resets + Couldn't load usage limit resets. Please try again. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Resetting your usage... › Using a reset... --- - Rate-limit resets + Usage limit resets Couldn't reset usage. Please try again. › 1. Try again @@ -44,27 +44,27 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Your usage does not need a reset right now. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - No rate-limit resets are available. + Usage limit resets + No usage limit resets are available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Usage reset. Checking your remaining resets... › Refreshing... --- - Rate-limit resets - Usage reset. You have 1 rate-limit reset left. + Usage limit resets + Usage reset. You have 1 usage limit reset left. › 1. Close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap index c3fc73a49f..70aa225aaf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset You have 2 rate-limit resets available. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset You have 2 usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap index 6bfee11dea..d4dc5deff1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset Check reset availability. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap index 6bfee11dea..127e37f7a3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + Redeem usage limit reset No usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap new file mode 100644 index 0000000000..08a595d4a2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "states.join(\"\\n---\\n\")" +--- +Free: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Go: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Business with monthly window: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d4de738314..2c58be9370 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -247,11 +247,23 @@ impl ChatWidget { self.add_to_history(cell); } - pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { + pub(crate) fn finish_status_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + ) { + if !self + .refreshing_status_outputs + .iter() + .any(|(pending_request_id, _)| *pending_request_id == request_id) + { return; } + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 9e528fb6c2..d484c1ee56 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1032,6 +1032,39 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { )); } +#[tokio::test] +async fn account_update_clears_derived_usage_limit_state_and_prompt() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + set_chatgpt_auth(&mut chat); + let mut limits = snapshot(/*percent*/ 95.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + chat.maybe_show_pending_rate_limit_prompt(); + + assert!(chat.rate_limit_warnings.primary_index > 0); + assert!(chat.codex_rate_limit_reached_type.is_some()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_eq!(chat.rate_limit_warnings.primary_index, 0); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + assert_eq!(chat.codex_rate_limit_reached_type, None); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 953de6b89c..21e02db252 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -46,8 +46,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( other => panic!("expected rate-limit refresh request, got {other:?}"), }; - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]); drain_insert_history(&mut rx); chat.dispatch_command(SlashCommand::Status); @@ -170,10 +169,31 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { "expected /status to avoid transient refresh text in terminal history, got: {second_rendered}" ); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, Vec::new()); pretty_assertions::assert_eq!(chat.refreshing_status_outputs.len(), 1); - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(second_request_id); + chat.finish_status_rate_limit_refresh(second_request_id, vec![snapshot(/*percent*/ 92.0)]); assert!(chat.refreshing_status_outputs.is_empty()); } + +#[tokio::test] +async fn account_update_rejects_stale_status_rate_limit_snapshots() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.dispatch_command(SlashCommand::Status); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }) => request_id, + other => panic!("expected status refresh request, got {other:?}"), + }; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_status_rate_limit_refresh(request_id, vec![snapshot(/*percent*/ 92.0)]); + + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 4e49e3561c..c05b4a62ff 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -13,6 +13,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -27,12 +28,13 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { } #[tokio::test] -async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() { +async fn usage_command_disables_reset_after_cached_zero_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); @@ -42,11 +44,109 @@ async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot "usage_command_menu_without_resets", render_bottom_popup(&chat, /*width*/ 80) ); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn usage_menu_refresh_enables_newly_available_reset() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } +#[tokio::test] +async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Err("backend unavailable".to_string()), + ); + + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("No usage limit resets available.")); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unchanged() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let startup_request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + vec![snapshot(/*percent*/ 92.0)], + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + ); + + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -65,7 +165,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis } #[tokio::test] -async fn usage_command_disables_reset_for_workspace_accounts() { +async fn usage_command_can_check_reset_availability_for_workspace_accounts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -74,7 +174,7 @@ async fn usage_command_disables_reset_for_workspace_accounts() { chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -84,6 +184,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); chat.dispatch_command(SlashCommand::Usage); @@ -104,6 +205,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); record_popup(&chat, &mut states); @@ -112,6 +214,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let empty_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); record_popup(&chat, &mut states); @@ -120,6 +223,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let load_error_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( load_error_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); record_popup(&chat, &mut states); @@ -165,6 +269,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); record_popup(&chat, &mut states); @@ -172,12 +277,66 @@ async fn rate_limit_reset_popup_states_snapshot() { assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let mut states = Vec::new(); + + chat.plan_type = Some(PlanType::Free); + let free_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + free_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Free:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + dismiss_popup(&mut chat); + chat.plan_type = Some(PlanType::Go); + let go_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + go_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + + dismiss_popup(&mut chat); + let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0); + monthly_business_snapshot.plan_type = Some(PlanType::Business); + monthly_business_snapshot + .primary + .as_mut() + .expect("monthly business snapshot primary window") + .window_duration_mins = Some(30 * 24 * 60); + let business_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + business_request_id, + vec![monthly_business_snapshot], + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Business with monthly window:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + assert_chatwidget_snapshot!( + "usage_limit_reset_confirmation_monthly", + states.join("\n---\n") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_selects_cancel_by_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -193,6 +352,7 @@ async fn rate_limit_reset_confirmation_can_use_reset() { let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -226,12 +386,13 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { } #[tokio::test] -async fn no_credit_outcome_allows_reset_availability_recheck() { +async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -244,10 +405,16 @@ async fn no_credit_outcome_allows_reset_availability_recheck() { dismiss_popup(&mut chat); chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 2 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); } #[tokio::test] @@ -270,6 +437,7 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); dismiss_popup(&mut chat); @@ -300,11 +468,12 @@ async fn already_redeemed_is_an_idempotent_success() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 0 rate-limit resets left.") + .contains("Usage reset. You have 0 usage limit resets left.") ); } @@ -315,6 +484,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -327,6 +497,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_post_consume_reset_credits_refresh( consume_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); dismiss_popup(&mut chat); @@ -334,7 +505,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let rendered = render_bottom_popup(&chat, /*width*/ 80); assert!(rendered.contains("Check reset availability.")); - assert!(!rendered.contains("You have 2 rate-limit resets available.")); + assert!(!rendered.contains("You have 2 usage limit resets available.")); } #[tokio::test] @@ -350,6 +521,7 @@ async fn account_change_invalidates_pending_reset_requests() { assert!(!chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -363,6 +535,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -385,6 +558,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert_eq!( @@ -395,7 +569,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("You have 2 rate-limit resets available.") + .contains("You have 2 usage limit resets available.") ); } @@ -413,6 +587,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); assert_eq!( @@ -423,7 +598,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 1 rate-limit reset left.") + .contains("Usage reset. You have 1 usage limit reset left.") ); } @@ -455,6 +630,7 @@ async fn startup_check_shows_available_reset_hint_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let rendered = lines_to_single_string( @@ -477,6 +653,7 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -507,6 +684,7 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { assert!(!chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); @@ -519,6 +697,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -536,13 +715,14 @@ async fn startup_check_omits_reset_hint_when_none_are_available() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } #[tokio::test] -async fn startup_check_omits_reset_hint_for_workspace_accounts() { +async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -550,10 +730,11 @@ async fn startup_check_omits_reset_hint_for_workspace_accounts() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); - assert!(chat.pending_rate_limit_reset_hint().is_none()); - assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.pending_rate_limit_reset_hint().is_some()); + assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); } fn consume_response( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 4cee18af11..53925e86d9 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; +use super::rate_limits::get_limits_duration; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -11,8 +12,21 @@ const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { self.clear_pending_rate_limit_reset_hint(); - let reset_eligible = - self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account); + let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0); + self.bottom_pane + .show_selection_view(self.usage_menu_params()); + if should_refresh_reset_availability { + let request_id = self.take_next_rate_limit_reset_request_id(); + self.pending_usage_menu_rate_limit_request_id = Some(request_id); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id }, + }); + } + self.request_redraw(); + } + + fn usage_menu_params(&self) -> SelectionViewParams { + let reset_eligible = self.has_chatgpt_account; let (reset_action_enabled, reset_description) = match (reset_eligible, self.available_rate_limit_reset_credits) { (true, Some(available_count)) if available_count > 0 => ( @@ -22,10 +36,12 @@ impl ChatWidget { reset_label(available_count) ), ), - (true, _) => (true, "Check reset availability.".to_string()), - (false, _) => (false, "No rate-limit resets available.".to_string()), + (true, None) => (true, "Check reset availability.".to_string()), + (true, Some(_)) | (false, _) => { + (false, "No usage limit resets available.".to_string()) + } }; - self.bottom_pane.show_selection_view(SelectionViewParams { + SelectionViewParams { view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -41,7 +57,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Redeem rate limit reset".to_string(), + name: "Redeem usage limit reset".to_string(), description: Some(reset_description), is_disabled: !reset_action_enabled, actions: vec![Box::new(|tx| { @@ -52,8 +68,32 @@ impl ChatWidget { }, ], ..Default::default() - }); - self.request_redraw(); + } + } + + pub(crate) fn finish_usage_menu_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + result: Result, + ) { + if self.pending_usage_menu_rate_limit_request_id != Some(request_id) { + return; + } + self.pending_usage_menu_rate_limit_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + if let Ok(response) = result { + self.available_rate_limit_reset_credits = Some(response.available_count); + } + let params = self.usage_menu_params(); + if self + .bottom_pane + .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) + { + self.request_redraw(); + } } pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 { @@ -62,7 +102,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), items: vec![SelectionItem { name: "Loading...".to_string(), @@ -78,26 +118,30 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let params = match result { Ok(response) => { self.available_rate_limit_reset_credits = Some(response.available_count); if response.available_count > 0 { - Self::rate_limit_reset_confirmation_params(response.available_count) + self.rate_limit_reset_confirmation_params(response.available_count) } else { Self::rate_limit_reset_message_params( - "You don't have any rate-limit resets available.", + "You don't have any usage limit resets available.", ) } } Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load rate-limit resets. Please try again.", + "Couldn't load usage limit resets. Please try again.", ), }; let replaced = self @@ -109,11 +153,32 @@ impl ChatWidget { replaced } - fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams { + fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams { let idempotency_key = Uuid::new_v4().to_string(); + let has_monthly_window = self + .rate_limit_snapshots_by_limit_id + .iter() + .find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex")) + .into_iter() + .flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()]) + .flatten() + .any(|window| { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some("monthly") + }); + let reset_description = if has_monthly_window + || matches!(self.plan_type, Some(PlanType::Free | PlanType::Go)) + { + "Reset your current monthly usage limit." + } else { + "Reset your current 5-hour and weekly usage limits." + }; SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(format!( "You have {available_count} {} available.", reset_label(available_count) @@ -122,7 +187,7 @@ impl ChatWidget { items: vec![ SelectionItem { name: "Use a reset".to_string(), - description: Some("Reset your current Codex usage windows.".to_string()), + description: Some(reset_description.to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), @@ -145,7 +210,7 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), items: vec![SelectionItem { name: "Close".to_string(), @@ -162,7 +227,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), items: vec![SelectionItem { name: "Using a reset...".to_string(), @@ -206,7 +271,7 @@ impl ChatWidget { } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); - "No rate-limit resets are available." + "No usage limit resets are available." } ConsumeAccountRateLimitResetCreditOutcome::Reset | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(), @@ -218,7 +283,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.replace_rate_limit_reset_popup(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), items: vec![ SelectionItem { @@ -247,12 +312,16 @@ impl ChatWidget { pub(crate) fn finish_post_consume_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let message = match result { Ok(response) => { @@ -272,7 +341,7 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), items: vec![SelectionItem { name: "Refreshing...".to_string(), @@ -303,18 +372,19 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_hint_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_hint_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_hint_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } if !self.has_codex_backend_auth { return false; } - if self.plan_type.is_some_and(PlanType::is_workspace_account) { - return true; - } if let Ok(response) = result { self.available_rate_limit_reset_credits = Some(response.available_count); self.set_rate_limit_reset_available_hint(response.available_count); @@ -324,8 +394,11 @@ impl ChatWidget { pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { self.pending_rate_limit_reset_request_id = None; + self.pending_usage_menu_rate_limit_request_id = None; self.available_rate_limit_reset_credits = None; + self.rate_limit_snapshots_by_limit_id.clear(); self.clear_pending_rate_limit_reset_hint(); + self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); } @@ -375,8 +448,8 @@ impl ChatWidget { fn reset_label(count: i64) -> &'static str { if count == 1 { - "rate-limit reset" + "usage limit reset" } else { - "rate-limit resets" + "usage limit resets" } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5e3e906bae..646380a2fa 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -103,7 +103,7 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Usage => "view account usage or use a rate-limit reset", + SlashCommand::Usage => "view account usage or use a usage limit reset", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", From 15f448d8b06c25c9ad04b2abeb3e4e2f07e4c327 Mon Sep 17 00:00:00 2001 From: jgershen-oai Date: Mon, 22 Jun 2026 11:54:44 -0700 Subject: [PATCH 09/10] [codex] Start the guardian child session when parent session is started (#27982) ## Why The first auto-review currently creates its Guardian child session on demand, adding avoidable latency before the review can begin. Creating the ordinary Guardian child during parent-session initialization lets that child use the existing session startup WebSocket prewarm before the first escalation. This does not introduce a Guardian-specific prewarm mechanism. ## What changed - initialize the existing Guardian review-session manager owned by `Session` when a thread starts with auto-review enabled and an approval policy that routes to Guardian - use the standard Guardian child-session construction and the existing session startup WebSocket prewarm - preserve the existing reuse-key invalidation and lazy creation fallback when startup initialization fails or the effective review configuration changes - add an integration test that verifies normal root-session startup emits a Guardian `generate=false` prewarm request ## Benchmark I compared release builds against main. Each prompt first ran a non-escalated `sleep 3`, then requested an escalated marker command. | binary | count | avg Guardian duration | median Guardian duration | avg Guardian TTFT | |---|---:|---:|---:|---:| | origin-main | 10 | 4008.7 ms | 3949.5 ms | 3746.5 ms | | session-fix | 10 | 2865.0 ms | 2594.0 ms | 2492.7 ms | Guardian duration fell by 28.5% and Guardian TTFT fell by 33.5%. These measurements cover Guardian review latency; they do not measure parent thread-start latency. --- codex-rs/core/src/guardian/review.rs | 108 +++++++++++-------- codex-rs/core/src/guardian/review_session.rs | 62 +++++++++-- codex-rs/core/src/session/session.rs | 1 - codex-rs/core/src/session_startup_prewarm.rs | 15 ++- codex-rs/core/tests/suite/guardian_review.rs | 80 ++++++++++++++ 5 files changed, 210 insertions(+), 56 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 92223083d8..4756cc1fc4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -665,40 +665,23 @@ pub(crate) fn spawn_approval_request_review( rx } -/// Runs the guardian in a locked-down reusable review session. -/// -/// The guardian itself should not mutate state or trigger further approvals, so -/// it is pinned to a read-only sandbox with `approval_policy = never` and -/// nonessential agent features disabled. When the cached trunk session is idle, -/// later approvals append onto that same guardian conversation to preserve a -/// stable prompt-cache key. If the trunk is already busy, the review runs in an -/// ephemeral fork from the last committed trunk rollout so parallel approvals -/// do not block each other or mutate the cached thread. The trunk is recreated -/// when the effective review-session config changes, and any future compaction -/// must continue to preserve the guardian policy as exact top-level developer -/// context. It may still reuse the parent's managed-network allowlist for -/// read-only checks, but it intentionally runs without inherited exec-policy -/// rules. -async fn run_guardian_review_session_before_deadline( - session: Arc, - turn: Arc, - request: GuardianApprovalRequest, - retry_reason: Option, - schema: serde_json::Value, - external_cancel: Option, - deadline: Instant, -) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { +pub(super) struct GuardianReviewSessionConfig { + pub(super) spawn_config: crate::config::Config, + model: String, + reasoning_effort: Option, + default_review_model_id: String, + catalog_contains_auto_review: bool, + model_overridden: bool, + model_override: Option, +} + +pub(super) async fn guardian_review_session_config( + session: &Session, + turn: &TurnContext, +) -> anyhow::Result { let network_proxy = session.services.network_proxy.load_full(); let live_network_config = match network_proxy.as_ref() { - Some(network_proxy) => match network_proxy.proxy().current_cfg().await { - Ok(config) => Some(config), - Err(err) => { - return ( - GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), - GuardianReviewAnalyticsResult::without_session(), - ); - } - }, + Some(network_proxy) => Some(network_proxy.proxy().current_cfg().await?), None => None, }; let available_models = session @@ -750,14 +733,50 @@ async fn run_guardian_review_session_before_deadline( reasoning_effort, ) }; - let guardian_config = build_guardian_review_session_config( + + let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), - live_network_config.clone(), + live_network_config, guardian_model.as_str(), guardian_reasoning_effort.clone(), - ); - let guardian_config = match guardian_config { - Ok(config) => config, + )?; + Ok(GuardianReviewSessionConfig { + spawn_config, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + default_review_model_id: default_review_model_id.to_string(), + catalog_contains_auto_review: guardian_catalog_contains_auto_review, + model_overridden: guardian_review_model_overridden, + model_override: guardian_review_model_override, + }) +} + +/// Runs the guardian in a locked-down reusable review session. +/// +/// The guardian itself should not mutate state or trigger further approvals, so +/// it is pinned to a read-only sandbox with `approval_policy = never` and +/// nonessential agent features disabled. When the cached trunk session is idle, +/// later approvals append onto that same guardian conversation to preserve a +/// stable prompt-cache key. If the trunk is already busy, the review runs in an +/// ephemeral fork from the last committed trunk rollout so parallel approvals +/// do not block each other or mutate the cached thread. The trunk is recreated +/// when the effective review-session config changes, and any future compaction +/// must continue to preserve the guardian policy as exact top-level developer +/// context. It may still reuse the parent's managed-network allowlist for +/// read-only checks, but it intentionally runs without inherited exec-policy +/// rules. +async fn run_guardian_review_session_before_deadline( + session: Arc, + turn: Arc, + request: GuardianApprovalRequest, + retry_reason: Option, + schema: serde_json::Value, + external_cancel: Option, + deadline: Instant, +) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { + let session_config = match guardian_review_session_config(session.as_ref(), turn.as_ref()).await + { + Ok(session_config) => session_config, Err(err) => { return ( GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), @@ -765,23 +784,22 @@ async fn run_guardian_review_session_before_deadline( ); } }; - let (session_outcome, session_analytics_result) = Box::pin( session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), parent_turn: turn.clone(), - spawn_config: guardian_config, + spawn_config: session_config.spawn_config, request, retry_reason, schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - guardian_default_review_model_id: default_review_model_id.to_string(), - guardian_catalog_contains_auto_review, - guardian_review_model_overridden, - guardian_review_model_override, + model: session_config.model, + reasoning_effort: session_config.reasoning_effort, + guardian_default_review_model_id: session_config.default_review_model_id, + guardian_catalog_contains_auto_review: session_config.catalog_contains_auto_review, + guardian_review_model_overridden: session_config.model_overridden, + guardian_review_model_override: session_config.model_override, reasoning_summary: turn.reasoning_summary, personality: turn.personality, external_cancel, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aa007891f..de56de41a6 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -27,6 +27,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use futures::future::BoxFuture; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -56,6 +57,7 @@ use super::prompt::GuardianTranscriptCursor; use super::prompt::build_guardian_prompt_items_with_parent_turn; use super::prompt::guardian_policy_prompt; use super::prompt::guardian_policy_prompt_with_config; +use super::review::guardian_review_session_config; const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug)] @@ -92,6 +94,7 @@ pub(crate) struct GuardianReviewSessionParams { #[derive(Default)] pub(crate) struct GuardianReviewSessionManager { state: Arc>, + cancellation_token: CancellationToken, } #[derive(Default)] @@ -289,6 +292,42 @@ impl Drop for EphemeralReviewCleanup { } impl GuardianReviewSessionManager { + pub(crate) fn initialize( + &self, + parent_session: Arc, + parent_turn: Arc, + ) -> BoxFuture<'_, anyhow::Result<()>> { + // Boxing breaks the Session::new -> Guardian -> Session::new future recursion. + Box::pin(async move { + let spawn_config = guardian_review_session_config(&parent_session, &parent_turn) + .await? + .spawn_config; + let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &spawn_config, + parent_session.user_instructions().await, + ); + let spawn_cancel_token = self.cancellation_token.child_token(); + let spawn_cancel_guard = spawn_cancel_token.clone().drop_guard(); + let review_session = spawn_guardian_review_session( + &parent_session, + &parent_turn, + spawn_config, + reuse_key, + spawn_cancel_token.clone(), + /*fork_snapshot*/ None, + ) + .await?; + // A first review or shutdown may win while eager initialization is in flight; + // install only if neither has happened. + let mut state = self.state.lock().await; + if !spawn_cancel_token.is_cancelled() && state.trunk.is_none() { + state.trunk = Some(Arc::new(review_session)); + drop(spawn_cancel_guard.disarm()); + } + Ok(()) + }) + } + pub(crate) async fn trunk_rollout_path(&self) -> Option { let trunk = self.state.lock().await.trunk.clone()?; trunk.codex.session.ensure_rollout_materialized().await; @@ -302,6 +341,7 @@ impl GuardianReviewSessionManager { } pub(crate) async fn shutdown(&self) { + self.cancellation_token.cancel(); let (review_session, ephemeral_reviews) = { let mut state = self.state.lock().await; ( @@ -348,13 +388,14 @@ impl GuardianReviewSessionManager { } if state.trunk.is_none() { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let review_session = match run_before_review_deadline_with_cancel( deadline, params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, params.spawn_config.clone(), next_reuse_key.clone(), spawn_cancel_token.clone(), @@ -556,7 +597,7 @@ impl GuardianReviewSessionManager { deadline: tokio::time::Instant, fork_snapshot: Option, ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; let review_session = match run_before_review_deadline_with_cancel( @@ -564,7 +605,8 @@ impl GuardianReviewSessionManager { params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, fork_config, reuse_key, spawn_cancel_token.clone(), @@ -605,7 +647,8 @@ impl GuardianReviewSessionManager { } async fn spawn_guardian_review_session( - params: &GuardianReviewSessionParams, + parent_session: &Arc, + parent_turn: &Arc, spawn_config: Config, reuse_key: GuardianReviewSessionReuseKey, cancel_token: CancellationToken, @@ -621,10 +664,10 @@ async fn spawn_guardian_review_session( }; let codex = Box::pin(run_codex_thread_interactive( spawn_config, - params.parent_session.services.auth_manager.clone(), - params.parent_session.services.models_manager.clone(), - Arc::clone(¶ms.parent_session), - Arc::clone(¶ms.parent_turn), + parent_session.services.auth_manager.clone(), + parent_session.services.models_manager.clone(), + Arc::clone(parent_session), + Arc::clone(parent_turn), cancel_token.clone(), SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string()), initial_history, @@ -1546,6 +1589,7 @@ mod tests { trunk: Some(Arc::new(review_session)), ephemeral_reviews: Vec::new(), })), + ..Default::default() }; drop(tx_event); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 759d159cc2..b4740bea99 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1217,7 +1217,6 @@ impl Session { let mut state = sess.state.lock().await; state.queue_pending_session_start_source(session_start_source); } - Ok(sess) } .await; diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0474eb8704..270134981b 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -10,6 +10,7 @@ use tracing::instrument; use tracing::warn; use crate::client::ModelClientSession; +use crate::guardian::routes_approval_to_guardian; use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::INITIAL_SUBMIT_ID; use crate::session::session::Session; @@ -242,6 +243,19 @@ async fn schedule_startup_prewarm_inner( prewarm_started_at.elapsed(), /*status*/ None, ); + if routes_approval_to_guardian(&startup_turn_context) { + let guardian_session = Arc::clone(&session); + let guardian_parent_turn = Arc::clone(&startup_turn_context); + drop(tokio::spawn(async move { + if let Err(err) = guardian_session + .guardian_review_session + .initialize(Arc::clone(&guardian_session), guardian_parent_turn) + .await + { + warn!("failed to initialize guardian review session: {err:#}"); + } + })); + } let startup_cancellation_token = CancellationToken::new(); let built_tools_started_at = Instant::now(); let startup_router = built_tools( @@ -295,6 +309,5 @@ async fn schedule_startup_prewarm_inner( websocket_warmup_started_at.elapsed(), /*status*/ None, ); - Ok(client_session) } diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index ae87350c39..c1da262033 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -17,6 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::local_selections; @@ -30,6 +31,85 @@ use std::os::unix::fs::PermissionsExt; use std::time::Duration; use tempfile::TempDir; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tool_args = json!({ + "cmd": "true", + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }) + .to_string(); + let server = start_websocket_server(vec![ + vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]], + vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]], + vec![vec![ + ev_response_created("approval-request"), + ev_function_call("approval-call", "exec_command", &tool_args), + ev_completed("approval-request"), + ]], + vec![vec![ + ev_response_created("guardian-review"), + ev_completed("guardian-review"), + ]], + ]) + .await; + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }); + + let test = builder.build_with_websocket_server(&server).await?; + let (first, second) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!( + server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0), + server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0) + ) + }) + .await?; + let prewarm_requests = [first.body_json(), second.body_json()]; + let guardian_prewarm = prewarm_requests + .iter() + .find(|request| { + request["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") + }) + .expect("guardian startup prewarm request"); + assert_eq!(guardian_prewarm["generate"].as_bool(), Some(false)); + let guardian_thread_id = guardian_prewarm["client_metadata"]["thread_id"] + .as_str() + .expect("guardian thread id"); + + test.codex + .submit( + vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }] + .into(), + ) + .await?; + let guardian_review = tokio::time::timeout( + Duration::from_secs(5), + server.wait_for_request(/*connection_index*/ 3, /*request_index*/ 0), + ) + .await? + .body_json(); + assert_eq!( + guardian_review["client_metadata"]["x-openai-subagent"].as_str(), + Some("guardian") + ); + assert_eq!( + guardian_review["client_metadata"]["thread_id"].as_str(), + Some(guardian_thread_id) + ); + assert_eq!(guardian_review.get("generate"), None); + + test.codex.shutdown_and_wait().await?; + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { skip_if_no_network!(Ok(())); From b8b9816102e064dae4488ec130cf560f63c1ab78 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Jun 2026 12:15:06 -0700 Subject: [PATCH 10/10] core: remove unused permissions cwd plumbing --- codex-rs/core/src/config/mod.rs | 1 - codex-rs/core/src/config/permissions.rs | 11 ++--------- codex-rs/core/src/config/permissions_tests.rs | 2 -- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dba933b111..17161e1a3c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3207,7 +3207,6 @@ impl Config { effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, - resolved_cwd.as_path(), &mut startup_warnings, )?; let mut configured_workspace_roots = compile_permission_profile_workspace_roots( diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index f683d9c7eb..9c6d3ed725 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -346,7 +346,6 @@ pub(crate) fn network_proxy_config_for_profile_selection( pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; @@ -383,7 +382,6 @@ pub(crate) fn compile_permission_profile( .extend(compile_filesystem_permission( path, permission, - policy_cwd, startup_warnings, )?); } @@ -412,7 +410,6 @@ pub(crate) fn compile_permission_profile_selection( permissions: Option<&PermissionsToml>, profile_name: &str, workspace_write: Option<&SandboxWorkspaceWrite>, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { if let Some(permission_profile) = builtin_permission_profile(profile_name, workspace_write) { @@ -426,7 +423,7 @@ pub(crate) fn compile_permission_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) + compile_permission_profile(permissions, profile_name, startup_warnings) } pub(crate) fn compile_permission_profile_workspace_roots( @@ -524,7 +521,6 @@ fn compile_network_sandbox_policy( fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result> { let mut entries = Vec::new(); @@ -548,9 +544,7 @@ fn compile_filesystem_permission( // exact-path parser so existing path semantics stay intact. let entry = FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: compile_scoped_filesystem_pattern( - path, subpath, *access, policy_cwd, - )?, + pattern: compile_scoped_filesystem_pattern(path, subpath, *access)?, }, access: *access, }; @@ -643,7 +637,6 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 88a757181e..2c1f649042 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -543,7 +543,6 @@ fn glob_scan_max_depth_must_be_positive() { #[test] fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> { - let cwd = TempDir::new()?; let mut startup_warnings = Vec::new(); let (file_system_policy, _) = compile_permission_profile( &PermissionsToml { @@ -568,7 +567,6 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> )]), }, "workspace", - cwd.path(), &mut startup_warnings, )?;