Support environment updates in app-server settings APIs (#60655)

## What changed

Add experimental `environments` fields to `turn/settings/update` and `thread/settings/update`. Turn updates apply to later steps of the running turn; thread updates apply to future turns. Omission or `null` preserves the selection, while `[]` clears it. Providing `cwd` in a thread update without an environment list selects the defaults for that directory.

Include the ordered environment selection in `thread/settings/updated`. Wait for Core to accept thread settings updates before returning success, and return rejections as request errors.

## Testing

Add integration coverage for attaching, replacing, preserving, and clearing environments; rejected updates; and the distinction between later steps and future turns.

GitOrigin-RevId: bfebeda57c676be94427aeb0c567c0b94fd9c387
This commit is contained in:
sayan-oai
2026-11-10 20:35:28 +00:00
committed by copyberry
parent 82e6bacb9c
commit c9bbed2dae
15 changed files with 327 additions and 7 deletions
@@ -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": {
@@ -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,
@@ -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<PathBuf>,
/// 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<Vec<TurnEnvironmentParams>>,
/// 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<String>,
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<Vec<ThreadEnvironment>>,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub sandbox_policy: SandboxPolicy,
@@ -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<ApprovalsReviewer>,
/// 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<Vec<TurnEnvironmentParams>>,
/// Omission or `null` leaves the model unchanged.
#[ts(optional = nullable)]
pub model: Option<String>,
+6
View File
@@ -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
@@ -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(),
@@ -257,6 +257,8 @@ impl TurnRequestProcessor {
let (_, thread) = self.load_thread(&params.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 {})
+1
View File
@@ -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,
@@ -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;
@@ -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!("<cwd>{}</cwd>", original_cwd.inferred_native_path_string());
let new_cwd = format!("<cwd>{}</cwd>", 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::<Vec<_>>();
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(())
}
@@ -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,
+1
View File
@@ -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,
@@ -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,