diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index df128d115e..ee890d3273 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -99,6 +99,9 @@ ], "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -300,6 +303,29 @@ ], "type": "object" }, + "ThreadEnvironment": { + "description": "An environment selected by a loaded thread, independent of connection status.", + "properties": { + "cwd": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "environmentId": { + "type": "string" + }, + "runtimeWorkspaceRoots": { + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "environmentId", + "runtimeWorkspaceRoots" + ], + "type": "object" + }, "ThreadSettings": { "properties": { "activePermissionProfile": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 4bd6d16f44..d4060b44db 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 912742c14d..a75004d9b7 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b1e0d12b22..5015ea9a60 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1113,6 +1113,7 @@ client_request_definitions! { #[experimental("turn/settings/update")] TurnSettingsUpdate => "turn/settings/update" { params: v2::TurnSettingsUpdateParams, + inspect_params: true, serialization: thread_id(params.thread_id), response: v2::TurnSettingsUpdateResponse, }, @@ -4623,6 +4624,7 @@ mod tests { thread_id: "thr_123".to_string(), thread_settings: v2::ThreadSettings { disabled_plugin_ids: Vec::new(), + environments: Some(Vec::new()), cwd: absolute_path("/tmp/repo"), approval_policy: v2::AskForApproval::Never, approvals_reviewer: v2::ApprovalsReviewer::User, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 84b61dfb20..047915303e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -4,6 +4,7 @@ use super::AskForApproval; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; +use super::ThreadEnvironment; use super::ThreadHistoryMode; use super::ThreadItem; use super::ThreadRealtimeItem; @@ -242,6 +243,12 @@ pub struct ThreadSettingsUpdateParams { /// Override the working directory for subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the environments for future turns without changing the running turn. + /// Omitted/null preserves the selection unless `cwd` is also provided, which selects + /// the default environments for that directory. [] clears the selection. + #[experimental("thread/settings/update.environments")] + #[ts(optional = nullable)] + pub environments: Option>, /// Override the approval policy for subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -306,6 +313,11 @@ pub struct ThreadSettings { #[serde(default)] pub disabled_plugin_ids: Vec, pub cwd: AbsolutePathBuf, + /// Environments configured for future turns, in priority order, primary first. + /// `null` means the server does not expose the selection; [] means none are selected. + #[experimental("thread/settings.environments")] + #[serde(default)] + pub environments: Option>, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub sandbox_policy: SandboxPolicy, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index c3b3fa7493..28c00a3e61 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -43,7 +43,9 @@ pub enum TurnStatus { /// Unsupported fields are rejected rather than silently ignored. /// Any live task kind may accept publication. Child sessions and consumers of /// frozen initial settings are unchanged. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export_to = "v2/")] pub struct TurnSettingsUpdateParams { @@ -53,6 +55,11 @@ pub struct TurnSettingsUpdateParams { /// Already captured steps and pending approvals retain their original reviewer. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Replace the environments for subsequent steps of this turn, not future turns. + /// Omitted/null preserves the selection; [] clears it. An already-started step is unchanged. + #[experimental("turn/settings/update.environments")] + #[ts(optional = nullable)] + pub environments: Option>, /// Omission or `null` leaves the model unchanged. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0508b37567..87d4f2c150 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -516,6 +516,12 @@ Read the selection from `threadSettings.disabledPluginIds` in across resume. Forks restore the selection from the history retained at the requested fork boundary. +# Environment settings + +`turn/settings/update` can replace the environments used by later steps of the running turn; an already-started step and future turns are unchanged. `thread/settings/update` changes the selection for future turns without affecting the running turn. Both accept the same `environments` list as `turn/start`: omission or `null` keeps the selection, and `[]` clears it. A thread settings request that provides `cwd` without an explicit environment list selects the default environments for that directory. Selected environments inherit the thread's configuration. + +`thread/settings/update` returns success after Core accepts the update, including an unchanged selection. Rejections are returned as request errors. `thread/settings/updated` reports effective changes, including the ordered `environments` selection, and is not a per-request acknowledgment. Updating both scopes requires two separate requests; they are not atomic. + # Deprecated thread personality setting `thread/start`, `thread/resume`, `thread/settings/update`, and `turn/start` still diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index b88c911829..07826f26cc 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -178,6 +178,13 @@ pub(crate) fn thread_settings_from_config_snapshot( ThreadSettings { disabled_plugin_ids: config_snapshot.disabled_plugin_ids.clone(), cwd: config_snapshot.cwd().clone(), + environments: Some( + config_snapshot + .environment_selections() + .iter() + .map(Into::into) + .collect(), + ), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: config_snapshot.sandbox_policy().into(), diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 121a3f87d3..a86d84a432 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -257,6 +257,8 @@ impl TurnRequestProcessor { let (_, thread) = self.load_thread(¶ms.thread_id).await?; self.ensure_direct_input_allowed(request_id, thread.as_ref()) .await?; + let environments = + resolve_turn_environment_requests(self.thread_manager.as_ref(), params.environments)?; let (reply, outcome) = oneshot::channel(); self.submit_core_op( request_id, @@ -267,7 +269,7 @@ impl TurnRequestProcessor { approvals_reviewer: params .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core), - environments: None, + environments, model: params.model, // Match thread/settings/update: public null does not clear effort. effort: params.effort.map(Some), @@ -861,9 +863,6 @@ impl TurnRequestProcessor { let collaboration_mode = collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); let has_environment_override = environment_requests.is_some(); - // `thread/settings/update` only acknowledges that the update was queued. - // Clients that send dependent partial updates should wait for - // `thread/settings/updated` or combine the fields in one request. let snapshot = if permissions.is_some() { Some(thread.config_snapshot().await) } else { @@ -982,12 +981,14 @@ impl TurnRequestProcessor { self.ensure_direct_input_allowed(request_id, thread.as_ref()) .await?; let cwd = resolve_request_cwd(params.cwd)?; + let environments = + resolve_turn_environment_requests(self.thread_manager.as_ref(), params.environments)?; let environment_override = self .build_environment_override( thread.as_ref(), cwd, /*workspace_roots*/ None, - /*environment_requests*/ None, + environments, ) .await; let thread_settings = self @@ -1012,16 +1013,24 @@ impl TurnRequestProcessor { .await?; if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() { + let (reply, outcome) = oneshot::channel(); self.submit_core_op( request_id, thread.as_ref(), Op::ThreadSettings { thread_settings, - reply: None, + reply: Some(reply), }, ) .await .map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?; + outcome + .await + .map_err(|_| internal_error("thread settings operation ended before replying"))? + .map_err(|err| match err.details() { + CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()), + _ => internal_error(format!("failed to update thread settings: {err}")), + })?; } Ok(ThreadSettingsUpdateResponse {}) diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index ab0464f2f8..26d6b0e228 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -333,6 +333,7 @@ mod tests { fn thread_settings(model: &str) -> ThreadSettings { ThreadSettings { disabled_plugin_ids: Vec::new(), + environments: Some(Vec::new()), cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::User, diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index ab688b12e9..0a83f8133d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadEnvironment; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; @@ -49,6 +50,127 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +/// Selection-only changes are observable after acknowledgment, including attaching and clearing. +#[tokio::test] +async fn environments_attach_replace_preserve_and_clear_without_inference() -> Result<()> { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + std::fs::write( + codex_home.path().join("environments.toml"), + r#" +[[environments]] +id = "remote-a" +url = "ws://127.0.0.1:1" +[[environments]] +id = "remote-b" +url = "ws://127.0.0.1:2" +"#, + )?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allowed_approval_policies = [\"never\"]\n", + )?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + // This test owns environments.toml and starts with no selected executor. + .without_auto_env() + .build_initialized() + .await?; + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(codex_home.path().to_string_lossy().into_owned()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let started: ThreadStartResponse = mcp.read_response(request_id).await?; + let environment_a = ThreadEnvironment { + environment_id: "remote-a".to_string(), + cwd: started.cwd.clone().into(), + runtime_workspace_roots: vec![started.cwd.clone().into()], + }; + let environment_b = ThreadEnvironment { + environment_id: "remote-b".to_string(), + ..environment_a.clone() + }; + let rejected_id = mcp + .send_raw_request( + "thread/settings/update", + Some(json!({ + "threadId": started.thread.id, + "environments": [environment_a], + "approvalPolicy": "on-request", + })), + ) + .await?; + let rejected = mcp + .read_stream_until_error_message(RequestId::Integer(rejected_id)) + .await?; + assert_eq!(rejected.error.code, -32600); + assert!(rejected.error.message.contains("approval_policy")); + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: started.thread.id.clone(), + include_turns: false, + }) + .await?; + let read: ThreadReadResponse = mcp.read_response(read_id).await?; + assert_eq!(read.thread.environments, Some(Vec::new())); + + for (mut params, expected, changed) in [ + ( + json!({"environments": [environment_a]}), + vec![environment_a], + true, + ), + ( + json!({"environments": [environment_b]}), + vec![environment_b.clone()], + true, + ), + ( + json!({"environments": [environment_b]}), + vec![environment_b.clone()], + false, + ), + (json!({}), vec![environment_b.clone()], false), + (json!({"environments": null}), vec![environment_b], false), + (json!({"environments": []}), vec![], true), + ] { + params["threadId"] = json!(started.thread.id); + let request_id = mcp + .send_raw_request("thread/settings/update", Some(params)) + .await?; + let _: ThreadSettingsUpdateResponse = mcp.read_response(request_id).await?; + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: started.thread.id.clone(), + include_turns: false, + }) + .await?; + let read: ThreadReadResponse = mcp.read_response(read_id).await?; + assert_eq!(read.thread.environments, Some(expected.clone())); + if changed { + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!( + ( + updated.thread_id, + updated.thread_settings.cwd, + updated.thread_settings.environments, + ), + ( + started.thread.id.clone(), + started.cwd.clone(), + Some(expected.clone()), + ) + ); + } + } + assert!(received_response_bodies(&server).await?.is_empty()); + Ok(()) +} + #[tokio::test] async fn disabled_plugin_ids_replace_preserve_and_clear_without_inference() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs b/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs index 1bab64b98a..9c3c3acfcd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_settings_update.rs @@ -2,12 +2,15 @@ use anyhow::Context; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_request_user_input_sse_response; use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadSettingsUpdatedNotification; @@ -21,6 +24,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ReasoningSummary; @@ -48,6 +52,118 @@ enum UpdateScenario { Rejected, } +/// Environment updates take effect at their requested scope without enabling model switching. +#[test_case("turn/settings/update", [false, true, false]; "later steps only")] +#[test_case("thread/settings/update", [false, false, true]; "future turns only")] +#[tokio::test] +async fn environment_settings_updates_use_the_requested_scope( + method: &str, + expected_new_cwd: [bool; 3], +) -> Result<()> { + let server = responses::start_mock_server().await; + let requests = responses::mount_sse_sequence( + &server, + vec![ + create_request_user_input_sse_response("pause")?, + responses::sse_completed("after-update"), + responses::sse_completed("next-turn"), + ], + ) + .await; + let codex_home = TempDir::new()?; + mock_config(codex_home.path(), &server.uri())?.write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let original_cwd = app.auto_env()?.selection().cwd.clone(); + let new_cwd = original_cwd.join("settings-environment")?; + app.auto_env()? + .environment() + .get_filesystem() + .create_directory( + &new_cwd, + CreateDirectoryOptions { + recursive: false, + follow_symlinks: true, + }, + /*sandbox*/ None, + ) + .await?; + let mut environment = app.auto_env_params()?; + environment.cwd = new_cwd.clone().into(); + let thread_request = app + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let ThreadStartResponse { thread, .. } = app.read_response(thread_request).await?; + let turn_id = start_turn(&mut app, &thread.id).await?; + let request = timeout(DEFAULT_TIMEOUT, app.read_stream_until_request_message()).await??; + let ServerRequest::ToolRequestUserInput { request_id, .. } = request else { + anyhow::bail!("expected paused turn, received {request:?}"); + }; + + let mut update = json!({"threadId": thread.id, "environments": [environment]}); + if method == "turn/settings/update" { + update["turnId"] = json!(turn_id); + } + let mut invalid_update = update.clone(); + invalid_update["environments"][0]["environmentId"] = json!("missing"); + let invalid_id = app.send_request(method, Some(invalid_update)).await?; + let rejected = app + .read_stream_until_error_message(RequestId::Integer(invalid_id)) + .await?; + assert_eq!( + rejected.error, + JSONRPCErrorError { + code: -32600, + message: "unknown turn environment id `missing`".to_string(), + data: None, + } + ); + let update_id = app.send_request(method, Some(update)).await?; + let result: Value = app.read_response(update_id).await?; + if method == "turn/settings/update" { + assert_eq!(result, json!({"status": "applied"})); + } else { + assert_eq!(result, json!({})); + let updated: ThreadSettingsUpdatedNotification = + app.read_notification("thread/settings/updated").await?; + assert_eq!( + serde_json::to_value(updated.thread_settings.environments)?, + json!([{ + "environmentId": environment.environment_id, + "cwd": environment.cwd, + "runtimeWorkspaceRoots": [new_cwd.inferred_native_path_string()], + }]) + ); + } + app.send_response( + request_id, + json!({"answers": {"confirm_path": {"answers": ["Yes (Recommended)"]}}}), + ) + .await?; + let _: TurnCompletedNotification = app.read_notification("turn/completed").await?; + start_turn(&mut app, &thread.id).await?; + let _: TurnCompletedNotification = app.read_notification("turn/completed").await?; + + let original_cwd = format!("{}", original_cwd.inferred_native_path_string()); + let new_cwd = format!("{}", new_cwd.inferred_native_path_string()); + let visible_cwds = requests + .requests() + .iter() + .map(|request| { + request + .message_input_texts("user") + .into_iter() + .rfind(|text| text.contains(&new_cwd) || text.contains(&original_cwd)) + .expect("model request should include a working directory") + .contains(&new_cwd) + }) + .collect::>(); + assert_eq!(visible_cwds, expected_new_cwd); + Ok(()) +} + #[test_case(UpdateScenario::Future; "future-only A A B")] #[test_case(UpdateScenario::Current; "turn-only A B A")] #[test_case(UpdateScenario::Rejected; "rejected turn update changes neither owner")] @@ -311,6 +427,7 @@ async fn turn_settings_update_reports_unavailable_without_starting_or_changing_a let update = TurnSettingsUpdateParams { thread_id: thread.id.clone(), turn_id: "never-started".to_string(), + environments: Some(Vec::new()), model: Some(MODEL_B.to_string()), ..Default::default() }; @@ -353,6 +470,14 @@ async fn turn_settings_update_reports_unavailable_without_starting_or_changing_a requests.single_request().body_json()["model"], Value::String(MODEL_A.to_string()) ); + let read_id = app + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: false, + }) + .await?; + let read: ThreadReadResponse = app.read_response(read_id).await?; + assert_eq!(read.thread.environments, thread.environments); Ok(()) } diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 8e7b3f7b8a..cc5d3d8fe2 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -262,6 +262,7 @@ mod tests { fn test_thread_settings() -> ThreadSettings { ThreadSettings { disabled_plugin_ids: Vec::new(), + environments: None, cwd: test_path_buf("/tmp/thread-settings").abs(), approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 1ac3901ca3..b752794592 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -12869,6 +12869,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode thread_id: inactive_thread_id.to_string(), thread_settings: ThreadSettings { disabled_plugin_ids: Vec::new(), + environments: None, cwd: test_absolute_path("/tmp/thread-settings"), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 745ebe52b2..f617e1032d 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -16,6 +16,7 @@ fn thread_settings_for_test( thread_id: thread_id.to_string(), thread_settings: codex_app_server_protocol::ThreadSettings { disabled_plugin_ids: Vec::new(), + environments: None, cwd: test_path_buf("/tmp/thread-settings").abs(), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,