diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 352cf0211..3ddced86c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3706,6 +3706,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnInterruptParams": { "properties": { "threadId": { 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 d1518ce28..a41221a78 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 @@ -16260,6 +16260,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { 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 82c990533..2991a2c3c 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 @@ -14154,6 +14154,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5b..071bc2ac3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -377,6 +377,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts new file mode 100644 index 000000000..bb981b0ac --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -0,0 +1,6 @@ +// 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. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7bddd0f9d..0b4c13efe 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -372,6 +372,7 @@ export type { ToolsV2 } from "./ToolsV2"; export type { Turn } from "./Turn"; export type { TurnCompletedNotification } from "./TurnCompletedNotification"; export type { TurnDiffUpdatedNotification } from "./TurnDiffUpdatedNotification"; +export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; diff --git a/codex-rs/app-server-protocol/src/experimental_api.rs b/codex-rs/app-server-protocol/src/experimental_api.rs index 63c3dafce..af7a1efbe 100644 --- a/codex-rs/app-server-protocol/src/experimental_api.rs +++ b/codex-rs/app-server-protocol/src/experimental_api.rs @@ -98,6 +98,13 @@ mod tests { inners: HashMap, } + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct ExperimentalFieldShape { + #[experimental("field/optionalCollection")] + optional_collection: Option>, + } + #[test] fn derive_supports_all_enum_variant_shapes() { assert_eq!( @@ -169,4 +176,20 @@ mod tests { None ); } + + #[test] + fn derive_marks_optional_experimental_fields_when_some() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: Some(Vec::new()), + }), + Some("field/optionalCollection") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: None, + }), + None + ); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7162eb4d..2ecaa733e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4641,6 +4641,14 @@ pub enum TurnStatus { } // Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -4653,6 +4661,10 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environment selections. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, @@ -9759,6 +9771,7 @@ mod tests { thread_id: "thread_123".to_string(), input: vec![], responsesapi_client_metadata: None, + environments: None, cwd: None, approval_policy: None, approvals_reviewer: None, @@ -9775,4 +9788,109 @@ mod tests { serde_json::to_value(&without_override).expect("params should serialize"); assert_eq!(serialized_without_override.get("serviceTier"), None); } + + #[test] + fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); + } + + #[test] + fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); + } + + #[test] + fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); + } + + #[test] + fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7e23cc24a..91ada0ab8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -533,6 +533,10 @@ You can optionally specify config overrides on the new turn. If specified, these "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", + // Experimental: turn-scoped environment selection. + "environments": [ + { "environmentId": "local", "cwd": "/Users/me/project" } + ], "approvalPolicy": "unlessTrusted", "sandboxPolicy": { "type": "workspaceWrite", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8f31288cd..5c161305c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -318,6 +318,7 @@ use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -7162,6 +7163,15 @@ impl CodexMessageProcessor { let collaboration_mode = params.collaboration_mode.map(|mode| { self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) }); + let environments = params.environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect() + }); // Map v2 input items to core input items. let mapped_items: Vec = params @@ -7213,6 +7223,7 @@ impl CodexMessageProcessor { thread.as_ref(), Op::UserInput { items: mapped_items, + environments, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, }, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index e42dd5afb..04178c70b 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -707,6 +707,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { ClientRequest::TurnStart { request_id: RequestId::Integer(3), params: TurnStartParams { + environments: None, thread_id, input: vec![UserInput::Text { text: "hello".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 65f8442b0..b2b1ac1d8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1733,6 +1733,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // first turn with workspace-write sandbox and first_cwd let first_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "first turn".to_string(), @@ -1773,6 +1774,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd let second_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "second turn".to_string(), diff --git a/codex-rs/codex-experimental-api-macros/src/lib.rs b/codex-rs/codex-experimental-api-macros/src/lib.rs index c5099e40a..2bca0190e 100644 --- a/codex-rs/codex-experimental-api-macros/src/lib.rs +++ b/codex-rs/codex-experimental-api-macros/src/lib.rs @@ -261,11 +261,8 @@ fn presence_expr_for_access( access: proc_macro2::TokenStream, ty: &Type, ) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; + if option_inner(ty).is_some() { + return quote! { #access.is_some() }; } if is_vec_like(ty) || is_map_like(ty) { return quote! { !#access.is_empty() }; @@ -276,22 +273,6 @@ fn presence_expr_for_access( quote! { true } } -fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; - } - if is_vec_like(ty) || is_map_like(ty) { - return quote! { !#access.is_empty() }; - } - if is_bool(ty) { - return quote! { *#access }; - } - quote! { true } -} - fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(type_path) = ty else { return None; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 1a5c38723..3aa7d6044 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -428,6 +428,7 @@ async fn send_input_submits_user_message() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), @@ -575,6 +576,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "spawned".to_string(), text_elements: Vec::new(), @@ -688,6 +690,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected = ( child_thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "child task".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4f4ced410..858154eb7 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -182,6 +182,7 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { + environments: None, items: input, final_output_json_schema, responsesapi_client_metadata: None, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 79d833231..921c82ca6 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -585,6 +585,7 @@ async fn run_review_on_session( review_session .codex .submit(Op::UserTurn { + environments: None, items: prompt_items.items, cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 921d528cd..5d262564c 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -125,7 +125,7 @@ pub(super) async fn user_input_or_turn_inner( op: Op, mirror_user_text_to_realtime: Option<()>, ) { - let (items, updates, responsesapi_client_metadata) = match op { + let (items, updates, responsesapi_client_metadata, environments) = match op { Op::UserTurn { cwd, approval_policy, @@ -139,6 +139,7 @@ pub(super) async fn user_input_or_turn_inner( items, collaboration_mode, personality, + environments, } => { let collaboration_mode = collaboration_mode.or_else(|| { Some(CollaborationMode { @@ -167,10 +168,12 @@ pub(super) async fn user_input_or_turn_inner( app_server_client_version: None, }, None, + environments, ) } Op::UserInput { items, + environments, final_output_json_schema, responsesapi_client_metadata, } => ( @@ -180,11 +183,15 @@ pub(super) async fn user_input_or_turn_inner( ..Default::default() }, responsesapi_client_metadata, + environments, ), _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { + let Ok(current_context) = sess + .new_turn_with_sub_id(sub_id.clone(), updates, environments) + .await + else { // new_turn_with_sub_id already emits the error event. return; }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1aa15a2ca..d45357e3e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1027,6 +1027,7 @@ impl Session { self, self.next_internal_sub_id(), Op::UserInput { + environments: None, items: vec![UserInput::Text { text, text_elements: Vec::new(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index af1028686..62f4c9a87 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -110,6 +110,7 @@ pub(super) async fn spawn_review_thread( reasoning_summary, session_source, environment: parent_turn_context.environment.clone(), + environments: parent_turn_context.environments.clone(), tools_config, features: parent_turn_context.features.clone(), ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0b936195f..e3c452b58 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -779,6 +779,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow sandbox_policy: Some(SandboxPolicy::DangerFullAccess), ..Default::default() }, + /*environment_selections*/ None, ) .await?; @@ -1495,6 +1496,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "fork seed".into(), text_elements: Vec::new(), @@ -1555,6 +1557,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -3021,7 +3024,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { inherited_shell_snapshot: None, user_shell_override: None, }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); + let per_turn_config = + Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -3137,6 +3141,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &models_manager, /*network*/ None, Some(environment), + /*environments*/ None, + session_configuration.cwd.clone(), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, @@ -3707,6 +3713,7 @@ fn op_kind_distinguishes_turn_ops() { ); assert_eq!( Op::UserInput { + environments: None, items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -3725,6 +3732,7 @@ async fn user_turn_updates_approvals_reviewer() { &session, "sub-1".to_string(), Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -3751,6 +3759,133 @@ async fn user_turn_updates_approvals_reviewer() { ); } +#[tokio::test] +async fn turn_environment_selection_sets_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let selected_cwd = + AbsolutePathBuf::try_from(session.get_config().await.cwd.as_path().join("selected")) + .expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: selected_cwd.clone(), + }]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 1); + assert_eq!(turn_environments[0].environment_id, "local"); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); +} + +#[tokio::test] +async fn multiple_turn_environment_selections_use_first_as_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + let session_cwd = session.get_config().await.cwd.clone(); + let first_cwd = + AbsolutePathBuf::try_from(session_cwd.as_path().join("first")).expect("absolute path"); + let second_cwd = + AbsolutePathBuf::try_from(session_cwd.as_path().join("second")).expect("absolute path"); + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![ + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: first_cwd.clone(), + }, + codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: second_cwd.clone(), + }, + ]), + ) + .await + .expect("turn should start"); + + let turn_environments = turn_context + .environments + .as_ref() + .expect("turn environments should be recorded"); + assert_eq!(turn_environments.len(), 2); + assert_eq!(turn_environments[0].cwd, first_cwd); + assert_eq!(turn_environments[1].cwd, second_cwd); + assert!(std::sync::Arc::ptr_eq( + turn_context + .environment + .as_ref() + .expect("primary environment should be set"), + &turn_environments[0].environment + )); + assert_eq!(turn_context.cwd, first_cwd); + assert_eq!(turn_context.config.cwd, first_cwd); +} + +#[tokio::test] +async fn empty_turn_environment_selection_clears_primary_environment() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + let turn_context = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![]), + ) + .await + .expect("turn should start"); + + assert!(turn_context.environment.is_none()); + assert_eq!(turn_context.cwd, session.get_config().await.cwd); + assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); + assert_eq!( + turn_context + .environments + .as_ref() + .expect("turn environments should be recorded") + .len(), + 0 + ); +} + +#[tokio::test] +async fn unknown_turn_environment_selection_returns_error() { + let (session, _turn_context, _rx) = make_session_and_context_with_rx().await; + + let err = session + .new_turn_with_sub_id( + "sub-1".to_string(), + SessionSettingsUpdate::default(), + Some(vec![codex_protocol::protocol::TurnEnvironmentSelection { + environment_id: "missing".to_string(), + cwd: session.get_config().await.cwd.clone(), + }]), + ) + .await + .expect_err("unknown environment should fail"); + + assert!(matches!(err, CodexErr::InvalidRequest(_))); + assert!(err.to_string().contains("missing")); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { @@ -4107,7 +4242,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( inherited_shell_snapshot: None, user_shell_override: None, }; - let per_turn_config = Session::build_per_turn_config(&session_configuration); + let per_turn_config = + Session::build_per_turn_config(&session_configuration, session_configuration.cwd.clone()); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), &per_turn_config.to_models_manager_config(), @@ -4223,6 +4359,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( &models_manager, /*network*/ None, Some(environment), + /*environments*/ None, + session_configuration.cwd.clone(), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index ce6758c44..998f016c9 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,6 +1,7 @@ use super::*; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::protocol::TurnEnvironmentSelection; pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { matches!( @@ -24,6 +25,14 @@ impl TurnSkillsContext { } } +#[derive(Clone, Debug)] +pub(crate) struct TurnEnvironment { + #[allow(dead_code)] + pub(crate) environment_id: String, + pub(crate) environment: Arc, + pub(crate) cwd: AbsolutePathBuf, +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -39,6 +48,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Option>, + pub(crate) environments: Option>, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -168,6 +178,7 @@ impl TurnContext { reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), environment: self.environment.clone(), + environments: self.environments.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -300,11 +311,14 @@ fn local_time_context() -> (String, String) { impl Session { /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. - pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + pub(crate) fn build_per_turn_config( + session_configuration: &SessionConfiguration, + cwd: AbsolutePathBuf, + ) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); - per_turn_config.cwd = session_configuration.cwd.clone(); + per_turn_config.cwd = cwd; per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -346,6 +360,8 @@ impl Session { models_manager: &ModelsManager, network: Option, environment: Option>, + environments: Option>, + cwd: AbsolutePathBuf, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -389,8 +405,6 @@ impl Session { &per_turn_config.agent_roles, )); - let cwd = session_configuration.cwd.clone(); - let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), @@ -414,6 +428,7 @@ impl Session { reasoning_summary, session_source, environment, + environments, cwd, current_date: Some(current_date), timezone: Some(timezone), @@ -450,7 +465,22 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, - ) -> ConstraintResult> { + environment_selections: Option>, + ) -> CodexResult> { + let turn_environments = match self.resolve_turn_environments(environment_selections) { + Ok(turn_environments) => turn_environments, + Err(err) => { + self.send_event_raw(Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + return Err(err); + } + }; let update_result = { let mut state = self.state.lock().await; match state.session_configuration.clone().apply(&updates) { @@ -482,15 +512,16 @@ impl Session { ) = match update_result { Ok(update) => update, Err(err) => { + let message = err.to_string(); self.send_event_raw(Event { id: sub_id.clone(), msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), + message: message.clone(), codex_error_info: Some(CodexErrorInfo::BadRequest), }), }) .await; - return Err(err); + return Err(CodexErr::InvalidRequest(message)); } }; @@ -511,17 +542,63 @@ impl Session { sub_id, session_configuration, updates.final_output_json_schema, + turn_environments, ) .await) } + fn resolve_turn_environments( + &self, + environment_selections: Option>, + ) -> CodexResult>> { + let Some(environment_selections) = environment_selections else { + return Ok(None); + }; + + let mut turn_environments = Vec::with_capacity(environment_selections.len()); + for environment_selection in environment_selections { + let environment = self + .services + .environment_manager + .get_environment(&environment_selection.environment_id) + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + environment_selection.environment_id + )) + })?; + let cwd = environment_selection.cwd; + turn_environments.push(TurnEnvironment { + environment_id: environment_selection.environment_id, + environment, + cwd, + }); + } + + Ok(Some(turn_environments)) + } + async fn new_turn_from_configuration( &self, sub_id: String, session_configuration: SessionConfiguration, final_output_json_schema: Option>, + turn_environments: Option>, ) -> Arc { - let per_turn_config = Self::build_per_turn_config(&session_configuration); + // `None` means use the thread's default environment. `Some([])` is an + // explicit no-environment turn, so do not fall back in that case. + let primary_turn_environment = turn_environments + .as_ref() + .and_then(|turn_environments| turn_environments.first()); + let environment = match primary_turn_environment { + Some(turn_environment) => Some(Arc::clone(&turn_environment.environment)), + None if turn_environments.is_some() => None, + None => self.services.environment_manager.default_environment(), + }; + let cwd = primary_turn_environment + .map(|turn_environment| turn_environment.cwd.clone()) + .unwrap_or_else(|| session_configuration.cwd.clone()); + let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy); @@ -544,7 +621,6 @@ impl Session { .await; let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); - let environment = self.services.environment_manager.default_environment(); let fs = environment .as_ref() .map(|environment| environment.get_filesystem()); @@ -576,6 +652,8 @@ impl Session { .then(|| started_proxy.proxy()) }), environment, + turn_environments, + cwd, sub_id, Arc::clone(&self.js_repl), skills_outcome, @@ -619,6 +697,7 @@ impl Session { sub_id, session_configuration, /*final_output_json_schema*/ None, + /*turn_environments*/ None, ) .await } 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 199b1d037..a51913a6b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2239,6 +2239,7 @@ async fn send_input_accepts_structured_items() { .expect("send_input should succeed"); let expected = Op::UserInput { + environments: None, items: vec![ UserInput::Mention { name: "drive".to_string(), diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73219423b..74b05f6e1 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -35,6 +35,7 @@ use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; @@ -605,6 +606,7 @@ impl TestCodex { AskForApproval::Never, SandboxPolicy::DangerFullAccess, Some(service_tier), + /*environments*/ None, ) .await } @@ -620,6 +622,22 @@ impl TestCodex { approval_policy, sandbox_policy, /*service_tier*/ None, + /*environments*/ None, + ) + .await + } + + pub async fn submit_turn_with_environments( + &self, + prompt: &str, + environments: Option>, + ) -> Result<()> { + self.submit_turn_with_context( + prompt, + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + /*service_tier*/ None, + environments, ) .await } @@ -630,10 +648,12 @@ impl TestCodex { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, service_tier: Option>, + environments: Option>, ) -> Result<()> { let session_model = self.session_configured.model.clone(); self.codex .submit(Op::UserTurn { + environments, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index a181c1123..c81a1c2f6 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -46,6 +46,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start sleep".into(), text_elements: Vec::new(), @@ -101,6 +102,7 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start history recording".into(), text_elements: Vec::new(), @@ -120,6 +122,7 @@ async fn interrupt_tool_records_history_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), @@ -201,6 +204,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start interrupt marker".into(), text_elements: Vec::new(), @@ -220,6 +224,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 45c97d370..a789940b0 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -357,6 +357,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "rename without content change".into(), text_elements: Vec::new(), @@ -994,6 +995,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "create streamed file".into(), text_elements: Vec::new(), @@ -1091,6 +1093,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply via shell heredoc with cd".into(), text_elements: Vec::new(), @@ -1175,6 +1178,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via shell".into(), text_elements: Vec::new(), @@ -1330,6 +1334,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "emit diff".into(), text_elements: Vec::new(), @@ -1397,6 +1402,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "rename with change".into(), text_elements: Vec::new(), @@ -1473,6 +1479,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "aggregate diffs".into(), text_elements: Vec::new(), @@ -1549,6 +1556,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch twice with failure".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f189d5db6..3347213bf 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -584,6 +584,7 @@ async fn submit_turn( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 2ebd49d53..d6091f1e5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -383,6 +383,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -747,6 +748,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -947,6 +949,7 @@ async fn includes_base_instructions_override_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1001,6 +1004,7 @@ async fn chatgpt_auth_sends_correct_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1113,6 +1117,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1150,6 +1155,7 @@ async fn includes_user_instructions_message_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1236,6 +1242,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1297,6 +1304,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1354,6 +1362,7 @@ async fn omits_apps_guidance_when_configured_off() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1394,6 +1403,7 @@ async fn omits_environment_context_when_configured_off() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1449,6 +1459,7 @@ async fn skills_append_to_developer_message() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1501,6 +1512,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1541,6 +1553,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1582,6 +1595,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1636,6 +1650,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1692,6 +1707,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1755,6 +1771,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1808,6 +1825,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1865,6 +1883,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1902,6 +1921,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1948,6 +1968,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1993,6 +2014,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2043,6 +2065,7 @@ async fn includes_developer_instructions_message_in_request() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2335,6 +2358,7 @@ async fn token_count_includes_rate_limits_snapshot() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2504,6 +2528,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { let submission_id = codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2579,6 +2604,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed turn".into(), text_elements: Vec::new(), @@ -2592,6 +2618,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "trigger context window".into(), text_elements: Vec::new(), @@ -2675,6 +2702,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "trigger incomplete".into(), text_elements: Vec::new(), @@ -2784,6 +2812,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2871,6 +2900,7 @@ async fn env_var_overrides_loaded_auth() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -2933,6 +2963,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U1".into(), text_elements: Vec::new(), @@ -2947,6 +2978,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 2: user sends U2; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U2".into(), text_elements: Vec::new(), @@ -2961,6 +2993,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 3: user sends U3; wait for completion. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "U3".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index f6cfd0d91..56e9f353d 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -998,6 +998,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { let submission_id = test .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1085,6 +1086,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { let submission_id = test .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 3371ff45d..d52a1454e 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2609,6 +2609,7 @@ text( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 57ffb35e6..3f8d2378a 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -79,6 +79,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -139,6 +140,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -174,6 +176,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -238,6 +241,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -291,6 +295,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -362,6 +367,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -390,6 +396,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -447,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -475,6 +483,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -534,6 +543,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -565,6 +575,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -625,6 +636,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -656,6 +668,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -720,6 +733,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -734,6 +748,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -791,6 +806,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5468151b4..c37326ffc 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -240,6 +240,7 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), @@ -263,6 +264,7 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), @@ -440,6 +442,7 @@ async fn manual_compact_uses_custom_prompt() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -585,6 +588,7 @@ async fn manual_compact_emits_context_compaction_items() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual compact".into(), text_elements: Vec::new(), @@ -749,6 +753,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_message.into(), text_elements: Vec::new(), @@ -1249,6 +1254,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1263,6 +1269,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1277,6 +1284,7 @@ async fn auto_compact_runs_after_token_limit_hit() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1446,6 +1454,7 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -1525,6 +1534,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1538,6 +1548,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -1551,6 +1562,7 @@ async fn auto_compact_starts_after_turn_started() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -1665,6 +1677,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: follow_up_user.into(), text_elements: Vec::new(), @@ -1756,6 +1769,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before switch".into(), text_elements: Vec::new(), @@ -1781,6 +1795,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after switch".into(), text_elements: Vec::new(), @@ -1892,6 +1907,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { initial .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "before resume".into(), text_elements: Vec::new(), @@ -1941,6 +1957,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -2044,6 +2061,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2057,6 +2075,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), @@ -2070,6 +2089,7 @@ async fn auto_compact_persists_rollout_entries() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), @@ -2157,6 +2177,7 @@ async fn manual_compact_retries_after_context_window_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2269,6 +2290,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -2362,6 +2384,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -2378,6 +2401,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -2394,6 +2418,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: final_user_message.into(), text_elements: Vec::new(), @@ -2556,6 +2581,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2659,6 +2685,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), @@ -2858,6 +2885,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -2976,6 +3004,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.into(), text_elements: Vec::new(), @@ -3036,6 +3065,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -3067,6 +3097,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -3162,6 +3193,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3187,6 +3219,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".into(), text_elements: Vec::new(), @@ -3283,6 +3316,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -3296,6 +3330,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -3367,6 +3402,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 30e3c54c9..58ed42c28 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -246,6 +246,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -261,6 +262,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact".into(), text_elements: Vec::new(), @@ -392,6 +394,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello remote compact".into(), text_elements: Vec::new(), @@ -467,6 +470,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -479,6 +483,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -595,6 +600,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -607,6 +613,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -625,6 +632,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -723,6 +731,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), @@ -735,6 +744,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), @@ -827,6 +837,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -842,6 +853,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -931,6 +943,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: first_user_message.into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: second_user_message.into(), text_elements: Vec::new(), @@ -1015,6 +1029,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1094,6 +1109,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "manual remote compact".into(), text_elements: Vec::new(), @@ -1177,6 +1193,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "needs compaction".into(), text_elements: Vec::new(), @@ -1319,6 +1336,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1335,6 +1353,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1358,6 +1377,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -1453,6 +1473,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "start remote compact flow".into(), text_elements: Vec::new(), @@ -1468,6 +1489,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after compact in same session".into(), text_elements: Vec::new(), @@ -1538,6 +1560,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1550,6 +1573,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1615,6 +1639,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1674,6 +1699,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1688,6 +1714,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1761,6 +1788,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1776,6 +1804,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1857,6 +1886,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "SETUP_USER".to_string(), text_elements: Vec::new(), @@ -1871,6 +1901,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -1960,6 +1991,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -1988,6 +2020,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2082,6 +2115,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us } codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user.to_string(), text_elements: Vec::new(), @@ -2167,6 +2201,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2194,6 +2229,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), @@ -2311,6 +2347,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2323,6 +2360,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2406,6 +2444,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2482,6 +2521,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2566,6 +2606,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), @@ -2581,6 +2622,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_TWO".to_string(), text_elements: Vec::new(), @@ -2661,6 +2703,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "USER_ONE".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index efa39aaee..48d359529 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -803,6 +803,7 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3c80fc80d..8b2654a72 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -44,6 +44,7 @@ async fn submit_user_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -125,6 +126,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell command".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index dc7f2151f..bcb7864cf 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -48,6 +48,7 @@ async fn fork_thread_twice_drops_to_first_message() { for text in ["first", "second", "third"] { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index b2d8e07b6..5c71516f4 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1036,6 +1036,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "initial prompt".to_string(), text_elements: Vec::new(), @@ -1053,6 +1054,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index a7ec8318f..0e2d88237 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -111,6 +111,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::LocalImage { path: abs_path.clone(), @@ -198,6 +199,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 25eb21df9..6cf42b5ee 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -85,6 +85,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, @@ -139,6 +140,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please summarize results".into(), text_elements: Vec::new(), @@ -198,6 +200,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "explain your reasoning".into(), text_elements: Vec::new(), @@ -258,6 +261,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "find the weather".into(), text_elements: Vec::new(), @@ -323,6 +327,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate a tiny blue square".into(), text_elements: Vec::new(), @@ -386,6 +391,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -440,6 +446,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "please stream text".into(), text_elements: Vec::new(), @@ -523,6 +530,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -600,6 +608,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -709,6 +718,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan with citations".into(), text_elements: Vec::new(), @@ -896,6 +906,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1010,6 +1021,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please plan".into(), text_elements: Vec::new(), @@ -1088,6 +1100,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "reason through it".into(), text_elements: Vec::new(), @@ -1148,6 +1161,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "show raw reasoning".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 755b26947..d6728deb0 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -73,6 +73,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 6ab001383..cfafdea3f 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -48,6 +48,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Text { text: prompt.to_string(), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 139ee7a85..54c2d1035 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -121,6 +121,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch models".into(), text_elements: Vec::new(), @@ -218,6 +220,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -255,6 +258,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch model and personality".into(), text_elements: Vec::new(), @@ -392,6 +396,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Image { image_url: image_url.clone(), @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -526,6 +532,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -547,6 +554,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -658,6 +666,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -679,6 +688,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "describe the generated image".to_string(), text_elements: Vec::new(), @@ -792,6 +802,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "generate a lobster".to_string(), text_elements: Vec::new(), @@ -821,6 +832,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "after rollback".to_string(), text_elements: Vec::new(), @@ -978,6 +990,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use larger model".into(), text_elements: Vec::new(), @@ -1037,6 +1050,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "switch to smaller model".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 0f41637ea..c93294121 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -114,6 +114,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), @@ -138,6 +139,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn with context updates".into(), text_elements: Vec::new(), @@ -217,6 +219,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "first turn in agents_one".into(), text_elements: Vec::new(), @@ -241,6 +244,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "second turn in agents_two".into(), text_elements: Vec::new(), @@ -317,6 +321,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -352,6 +357,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul resumed .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "resume and change personality".into(), text_elements: Vec::new(), @@ -417,6 +423,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "seed resume history".into(), text_elements: Vec::new(), @@ -463,6 +470,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first resumed turn after model override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 2ffce5742..5728205b6 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -90,6 +90,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hi".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 34aaf86de..d46214d1f 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -101,6 +101,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please run a tool".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index d771ed19a..0ad23ccf5 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -102,6 +102,7 @@ async fn responses_api_emits_api_request_event() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -146,6 +147,7 @@ async fn process_sse_emits_tracing_for_output_item() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -190,6 +192,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -235,6 +238,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -300,6 +304,7 @@ async fn process_sse_failed_event_records_response_error_message() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -363,6 +368,7 @@ async fn process_sse_failed_event_logs_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -413,6 +419,7 @@ async fn process_sse_failed_event_logs_missing_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -472,6 +479,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -525,6 +533,7 @@ async fn process_sse_emits_completed_telemetry() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -598,6 +607,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -683,6 +693,7 @@ async fn record_responses_sets_span_fields_for_response_events() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -768,6 +779,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -842,6 +854,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -926,6 +939,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -995,6 +1009,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1104,6 +1119,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1156,6 +1172,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "approved".into(), text_elements: Vec::new(), @@ -1223,6 +1240,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1290,6 +1308,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "retry".into(), text_elements: Vec::new(), @@ -1357,6 +1376,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), @@ -1424,6 +1444,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "persist".into(), text_elements: Vec::new(), @@ -1492,6 +1513,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "deny".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 777103534..10907f026 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -95,6 +95,7 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -109,6 +110,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -272,6 +274,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".into(), text_elements: Vec::new(), @@ -289,6 +292,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "second prompt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index d7ceb25fe..fb0acf751 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -49,6 +49,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -87,6 +88,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -115,6 +117,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -159,6 +162,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -171,6 +175,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -215,6 +220,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -243,6 +249,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -300,6 +307,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -330,6 +338,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -344,6 +353,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -402,6 +412,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -432,6 +443,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -452,6 +464,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after resume".into(), text_elements: Vec::new(), @@ -485,6 +498,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { forked .thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "after fork".into(), text_elements: Vec::new(), @@ -538,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 251aad76c..7c72478ac 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -96,6 +96,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -147,6 +148,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -205,6 +207,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -269,6 +272,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -321,6 +325,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -359,6 +364,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -426,6 +432,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -544,6 +552,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -582,6 +591,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -701,6 +711,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -822,6 +833,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -860,6 +872,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 49be14a60..07602c1be 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -192,6 +192,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -268,6 +269,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), @@ -348,6 +350,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 63a21ce1d..cee43e982 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -146,6 +146,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -158,6 +159,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -244,6 +246,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -256,6 +259,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -331,6 +336,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -413,6 +419,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -450,6 +457,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -533,6 +541,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -685,6 +694,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -707,6 +717,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }; codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -820,6 +831,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -841,6 +853,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), @@ -946,6 +959,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), @@ -967,6 +981,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index c59f2d86b..4c0677e69 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -41,6 +41,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "quota?".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index e044a216f..c7d5097a3 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1896,6 +1896,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result let prefixed_user_text = format!("[USER] {user_text}"); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.to_string(), text_elements: Vec::new(), @@ -2025,6 +2026,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re ); test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: user_text.clone(), text_elements: Vec::new(), @@ -3183,6 +3185,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first prompt".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 9b157bde5..e62fa0c32 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -172,6 +172,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -323,6 +325,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -411,6 +414,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -474,6 +478,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -861,6 +867,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 44d37f311..fddb18f15 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -40,6 +40,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "compress me".into(), text_elements: Vec::new(), @@ -88,6 +89,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "do not compress".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 614cd10b9..866df1386 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -187,6 +187,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 0578441e9..d31b060c3 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -138,6 +138,7 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c2..4c59c8e7f 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -131,6 +131,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), @@ -249,6 +250,7 @@ where codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 7519822a3..de35b12a2 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -127,6 +127,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 55d7e45f0..febeb926b 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -86,6 +86,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record some messages".into(), text_elements: text_elements.clone(), @@ -172,6 +173,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record reasoning messages".into(), text_elements: Vec::new(), @@ -262,6 +264,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -303,6 +306,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Resume with different model".into(), text_elements: Vec::new(), @@ -319,6 +323,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Second turn after resume".into(), text_elements: Vec::new(), @@ -390,6 +395,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await; codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Record initial instructions".into(), text_elements: Vec::new(), @@ -434,6 +440,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu resumed .codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first turn after override".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f4da7c549..faf469c98 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -721,6 +721,7 @@ async fn review_history_surfaces_in_parent_session() { let followup = "back to parent".to_string(); codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 7875f0981..cb6553343 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -296,6 +296,7 @@ async fn call_cwd_tool( service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -429,6 +430,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -797,6 +799,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -929,6 +932,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1028,6 +1032,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1179,6 +1184,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1416,6 +1422,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1524,6 +1531,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1660,6 +1668,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1769,6 +1778,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -1893,6 +1903,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; @@ -2109,6 +2120,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { service_tier: None, collaboration_mode: None, personality: None, + environments: None, }) .await?; diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 51a88ef16..f3211e00d 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -38,6 +38,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger safety check".to_string(), text_elements: Vec::new(), @@ -137,6 +138,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger response model check".to_string(), text_elements: Vec::new(), @@ -223,6 +225,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger follow-up turn".to_string(), text_elements: Vec::new(), @@ -273,6 +276,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "trigger casing check".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 2f24fba33..1e37d5ae1 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -502,6 +502,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let test = builder.build(&server).await?; test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), @@ -778,6 +779,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "Use the automation tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 516bc049f..b3a53fde1 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -157,6 +157,7 @@ async fn run_snapshot_command_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), @@ -248,6 +249,7 @@ async fn run_shell_command_snapshot_with_options( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), @@ -319,6 +321,7 @@ async fn run_tool_turn_on_harness( let cwd = test.cwd_path().to_path_buf(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -554,6 +557,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let model = test.session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index d77d736f9..de4827d6b 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -44,6 +44,7 @@ async fn submit_turn_with_policies( ) -> Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 59d28b61f..015f4ef0f 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -99,6 +99,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![ UserInput::Text { text: "please use $demo".to_string(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 52f1f4648..6a3f9b792 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -399,6 +399,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "call the rmcp echo tool".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 950306e97..af8614126 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -94,6 +94,7 @@ async fn continue_after_stream_error() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "first message".into(), text_elements: Vec::new(), @@ -114,6 +115,7 @@ async fn continue_after_stream_error() { // error above, this submission would be rejected/queued indefinitely. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "follow up".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 2dd73e0f6..984220a08 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -78,6 +78,7 @@ async fn retries_on_early_close() { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 67a727569..ca86d3e9f 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -78,6 +78,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please run the shell command".into(), text_elements: Vec::new(), @@ -149,6 +150,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), @@ -230,6 +232,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), @@ -326,6 +329,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), @@ -430,6 +434,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 2628136a1..3158804fa 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -35,6 +35,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -352,6 +353,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let session_model = test.session_configured.model.clone(); test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "stream delayed completion".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index a995e5443..bc2bf2361 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -20,6 +20,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -75,6 +76,100 @@ fn ev_namespaced_function_call( }) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("unified exec should enable for test"); + config + .features + .enable(Feature::JsRepl) + .expect("js repl should enable for test"); + config.include_apply_patch_tool = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_environments("which tools are available?", Some(vec![])) + .await?; + + let tools = tool_names(&response_mock.single_request().body_json()); + assert!( + tools.contains(&"update_plan".to_string()), + "non-environment tool should remain available; got {tools:?}" + ); + for environment_tool in [ + "exec_command", + "write_stdin", + "js_repl", + "js_repl_reset", + "apply_patch", + "view_image", + ] { + assert!( + !tools.contains(&environment_tool.to_string()), + "{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}" + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("unified exec should enable for test"); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_environments( + "which tools are available?", + Some(vec![TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: test.config.cwd.clone(), + }]), + ) + .await?; + + let tools = tool_names(&response_mock.single_request().body_json()); + assert!( + tools.contains(&"exec_command".to_string()), + "environment tool should remain available with selected local environment; got {tools:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index c12dd5819..861ec1eed 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -500,6 +500,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { fixture .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "call the rmcp image tool".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index be6fa73a4..58dbad7b1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -166,6 +166,7 @@ async fn submit_unified_exec_turn( test.codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), @@ -250,6 +251,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "apply patch via unified exec".into(), text_elements: Vec::new(), @@ -1740,6 +1742,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), @@ -1833,6 +1836,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), @@ -2305,6 +2309,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "summarize large output".into(), text_elements: Vec::new(), @@ -2414,6 +2419,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "read the fixture files".into(), text_elements: Vec::new(), @@ -2542,6 +2548,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "start python under seatbelt".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 3d02c2004..5fe08789f 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -57,6 +57,7 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 807c74690..0aa52b7de 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -170,6 +170,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> fixture .codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "run model shell command".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 9f53e60d7..456740344 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -160,6 +160,7 @@ async fn assert_user_turn_local_image_resizes_to( codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], @@ -279,6 +280,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -410,6 +412,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the original screenshot".into(), text_elements: Vec::new(), @@ -509,6 +512,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image at low detail".into(), text_elements: Vec::new(), @@ -599,6 +603,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image with a null detail".into(), text_elements: Vec::new(), @@ -699,6 +704,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -803,6 +809,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please add the screenshot".into(), text_elements: Vec::new(), @@ -905,6 +912,7 @@ await codex.emitImage(out); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image and attach it".into(), text_elements: Vec::new(), @@ -1025,6 +1033,7 @@ console.log(out.type); let session_model = session_configured.model.clone(); codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "use js_repl to write an image but do not emit it".into(), text_elements: Vec::new(), @@ -1118,6 +1127,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the folder".into(), text_elements: Vec::new(), @@ -1199,6 +1209,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please use the view_image tool to read the json file".into(), text_elements: Vec::new(), @@ -1285,6 +1296,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the missing image".into(), text_elements: Vec::new(), @@ -1422,6 +1434,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "please attach the image".into(), text_elements: Vec::new(), @@ -1503,6 +1516,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::LocalImage { path: abs_path.clone(), }], diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index c55e72ec6..6611ebdf5 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -150,6 +150,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result codex .submit(Op::UserTurn { + environments: None, items: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index bfcdcf25e..de5282183 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -104,6 +104,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1279532da..11bf1b7a6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -745,6 +745,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { thread_id: primary_thread_id_for_span.clone(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(default_cwd), approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9c9680017..5ad6b160b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -108,6 +108,7 @@ pub async fn run_codex_tool_session( let submission = Submission { id: sub_id.clone(), op: Op::UserInput { + environments: None, items: vec![UserInput::Text { text: initial_prompt.clone(), // MCP tool prompts are plain text with no UI element ranges. @@ -156,6 +157,7 @@ pub async fn run_codex_tool_session_reply( .insert(request_id.clone(), thread_id); if let Err(e) = thread .submit(Op::UserInput { + environments: None, items: vec![UserInput::Text { text: prompt, // MCP tool prompts are plain text with no UI element ranges. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1c6046c7..bee437ef8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -103,6 +103,12 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +pub struct TurnEnvironmentSelection { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "string")] @@ -425,6 +431,9 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, + /// Optional turn-scoped environment selections. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, @@ -488,6 +497,10 @@ pub enum Op { /// Optional personality override for this turn. #[serde(skip_serializing_if = "Option::is_none")] personality: Option, + + /// Optional turn-scoped environment selections. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, }, /// Inter-agent communication that should be recorded as assistant history @@ -715,6 +728,7 @@ pub enum ThreadMemoryMode { impl From> for Op { fn from(value: Vec) -> Self { Op::UserInput { + environments: None, items: value, final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4883,6 +4897,7 @@ mod tests { #[test] fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4901,6 +4916,7 @@ mod tests { assert_eq!( op, Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, @@ -4921,6 +4937,7 @@ mod tests { "additionalProperties": false }); let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: Some(schema.clone()), responsesapi_client_metadata: None, @@ -4942,6 +4959,7 @@ mod tests { #[test] fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { let op = Op::UserInput { + environments: None, items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: Some(HashMap::from([( diff --git a/codex-rs/rmcp-client/src/program_resolver.rs b/codex-rs/rmcp-client/src/program_resolver.rs index c20cac374..ce286bdcd 100644 --- a/codex-rs/rmcp-client/src/program_resolver.rs +++ b/codex-rs/rmcp-client/src/program_resolver.rs @@ -70,7 +70,7 @@ mod tests { #[tokio::test] async fn test_unix_executes_script_without_extension() -> Result<()> { let env = TestExecutableEnv::new()?; - let mut cmd = Command::new(&env.program_name); + let mut cmd = Command::new(&env.executable_path); cmd.envs(&env.mcp_env); let output = cmd.output().await; @@ -138,6 +138,8 @@ mod tests { // Held to prevent the temporary directory from being deleted. _temp_dir: TempDir, program_name: String, + #[cfg(unix)] + executable_path: std::path::PathBuf, mcp_env: HashMap, } @@ -160,6 +162,8 @@ mod tests { let mcp_env = create_env_for_mcp_server(Some(extra_env), &[])?; Ok(Self { + #[cfg(unix)] + executable_path: Self::executable_path(dir_path), _temp_dir: temp_dir, program_name: Self::TEST_PROGRAM.to_string(), mcp_env, @@ -184,6 +188,11 @@ mod tests { Ok(()) } + #[cfg(unix)] + fn executable_path(dir: &Path) -> std::path::PathBuf { + dir.join(Self::TEST_PROGRAM) + } + #[cfg(unix)] fn set_executable(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index e94dced05..45425c349 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -150,6 +150,7 @@ impl AppCommand { ) -> Self { Self(Op::UserTurn { items, + environments: None, cwd, approval_policy, approvals_reviewer: None, @@ -296,6 +297,7 @@ impl AppCommand { final_output_json_schema, collaboration_mode, personality, + environments: _, } => AppCommandView::UserTurn { items, cwd, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4d8e213ef..655947a08 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -532,6 +532,7 @@ impl AppServerSession { thread_id: thread_id.to_string(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + environments: None, cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()),