Add thread/settings/update app-server API (#23502)

## Why

App-server clients need a way to update a thread's next-turn settings
without starting a turn, adding transcript content, or waiting for turn
lifecycle events. This gives settings UI a direct path for durable
thread settings while clients observe the eventual effective state
through a notification.

This is a simplified rework of PR
https://github.com/openai/codex/pull/22509. In particular, it changes
the `thread/settings/update` api to return immediately rather than
waiting and returning the effective (updated) thread settings. This
makes the new api consistent with `turn/start` and greatly reduces the
complexity of the implementation relative to the earlier attempt.

## What Changed

- Adds experimental `thread/settings/update` with partial-update request
fields and an empty acknowledgment response.
- Adds experimental `thread/settings/updated`, carrying full effective
`ThreadSettings` and scoped by `threadId` to subscribed clients for the
affected thread.
- Shares durable settings validation with `turn/start`, including
`sandboxPolicy` plus `permissions` rejection and `serviceTier: null`
clearing.
- Emits the same settings notification when `turn/start` overrides
change the stored effective thread settings.
- Regenerates app-server protocol schema fixtures and updates
`app-server/README.md`.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-20 11:03:20 -07:00
1 parent 2b4898cc47
commit 771a4e74ac
27 files changed
+2092 -159

No files matched your search

@@ -237,12 +237,19 @@ pub(super) async fn ensure_listener_task_running(
&environments,
)
.await;
let thread_settings_baseline =
thread_settings_from_config_snapshot(&conversation.config_snapshot().await);
let (mut listener_command_rx, listener_generation) = {
let mut thread_state = thread_state.lock().await;
if thread_state.listener_matches(&conversation) {
return Ok(());
}
thread_state.set_listener(cancel_tx, &conversation, watch_registration)
thread_state.set_listener(
cancel_tx,
&conversation,
watch_registration,
thread_settings_baseline,
)
};
let ListenerTaskContext {
outgoing,
@@ -169,13 +169,13 @@ pub(super) fn with_thread_spawn_agent_metadata(
}
}
pub(super) fn thread_response_active_permission_profile(
pub(crate) fn thread_response_active_permission_profile(
active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,
) -> Option<codex_app_server_protocol::ActivePermissionProfile> {
active_permission_profile.map(Into::into)
}
pub(super) fn thread_response_sandbox_policy(
pub(crate) fn thread_response_sandbox_policy(
permission_profile: &codex_protocol::models::PermissionProfile,
cwd: &Path,
) -> codex_app_server_protocol::SandboxPolicy {
@@ -189,6 +189,54 @@ pub(super) fn thread_response_sandbox_policy(
sandbox_policy.into()
}
pub(crate) fn thread_settings_from_config_snapshot(
config_snapshot: &ThreadConfigSnapshot,
) -> ThreadSettings {
ThreadSettings {
cwd: config_snapshot.cwd.clone(),
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox_policy: thread_response_sandbox_policy(
&config_snapshot.permission_profile,
config_snapshot.cwd.as_path(),
),
active_permission_profile: thread_response_active_permission_profile(
config_snapshot.active_permission_profile.clone(),
),
model: config_snapshot.model.clone(),
model_provider: config_snapshot.model_provider_id.clone(),
service_tier: config_snapshot.service_tier.clone(),
effort: config_snapshot.reasoning_effort,
summary: config_snapshot.reasoning_summary,
collaboration_mode: config_snapshot.collaboration_mode.clone(),
personality: config_snapshot.personality,
}
}
pub(crate) fn thread_settings_from_core_snapshot(
snapshot: codex_protocol::protocol::ThreadSettingsSnapshot,
) -> ThreadSettings {
ThreadSettings {
sandbox_policy: thread_response_sandbox_policy(
&snapshot.permission_profile,
snapshot.cwd.as_path(),
),
cwd: snapshot.cwd,
approval_policy: snapshot.approval_policy.into(),
approvals_reviewer: snapshot.approvals_reviewer.into(),
active_permission_profile: thread_response_active_permission_profile(
snapshot.active_permission_profile,
),
model: snapshot.model,
model_provider: snapshot.model_provider_id,
service_tier: snapshot.service_tier,
effort: snapshot.reasoning_effort,
summary: snapshot.reasoning_summary,
collaboration_mode: snapshot.collaboration_mode,
personality: snapshot.personality,
}
}
#[cfg(test)]
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
@@ -30,6 +30,22 @@ fn resolve_runtime_workspace_roots(
resolved_roots
}
struct ThreadSettingsBuildParams {
method: &'static str,
cwd: Option<PathBuf>,
runtime_workspace_roots: Option<Vec<PathBuf>>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>,
permissions: Option<String>,
model: Option<String>,
service_tier: Option<Option<String>>,
effort: Option<ReasoningEffort>,
summary: Option<ReasoningSummary>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
}
impl TurnRequestProcessor {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
@@ -88,6 +104,16 @@ impl TurnRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_settings_update(
&self,
request_id: &ConnectionRequestId,
params: ThreadSettingsUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_settings_update_inner(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn turn_steer(
&self,
request_id: &ConnectionRequestId,
@@ -199,7 +225,7 @@ impl TurnRequestProcessor {
Ok((thread_id, thread))
}
fn normalize_turn_start_collaboration_mode(
fn normalize_collaboration_mode(
&self,
mut collaboration_mode: CollaborationMode,
) -> CollaborationMode {
@@ -357,9 +383,6 @@ impl TurnRequestProcessor {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
let collaboration_mode = params
.collaboration_mode
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
let environment_selections = self.parse_environment_selections(params.environments)?;
// Map v2 input items to core input items.
@@ -369,156 +392,26 @@ impl TurnRequestProcessor {
.map(V2UserInput::into_core)
.collect();
let turn_has_input = !mapped_items.is_empty();
let runtime_workspace_roots_request = params.runtime_workspace_roots.clone();
let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some()
{
Some(thread.config_snapshot().await)
} else {
None
};
let has_any_overrides = params.cwd.is_some()
|| runtime_workspace_roots_request.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
|| params.permissions.is_some()
|| params.model.is_some()
|| params.service_tier.is_some()
|| params.effort.is_some()
|| params.summary.is_some()
|| collaboration_mode.is_some()
|| params.personality.is_some();
if params.sandbox_policy.is_some() && params.permissions.is_some() {
return Err(invalid_request(
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
let cwd = params.cwd;
let runtime_workspace_roots = if let Some(workspace_roots) =
runtime_workspace_roots_request.clone()
{
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(
"turn/start runtime workspace roots missing thread snapshot",
));
};
let base_cwd = cwd
.as_ref()
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path()))
.unwrap_or_else(|| snapshot.cwd.clone());
Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd))
} else {
None
};
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
let approvals_reviewer = params
.approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
let sandbox_policy = params.sandbox_policy.map(|p| p.to_core());
let (permission_profile, active_permission_profile, profile_workspace_roots) =
if let Some(permissions) = params.permissions {
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(
"turn/start permission selection missing thread snapshot",
));
};
let overrides = ConfigOverrides {
cwd: cwd.clone(),
workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else(
|| {
snapshot
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect()
},
)),
default_permissions: Some(permissions),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
};
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit turn request
// is different: reject it before accepting user input.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid thread settings override: {warning}"
)));
}
(
Some(config.permissions.permission_profile().clone()),
config.permissions.active_permission_profile(),
Some(config.permissions.profile_workspace_roots().to_vec()),
)
} else {
(None, None, None)
};
let model = params.model;
let effort = params.effort.map(Some);
let summary = params.summary;
let service_tier = params.service_tier;
let personality = params.personality;
// If any overrides are provided, validate them synchronously so the
// request can fail before accepting user input. The actual update is
// still queued together with the input below to preserve submission order.
if has_any_overrides {
thread
.preview_thread_settings_overrides(CodexThreadSettingsOverrides {
cwd: cwd.clone(),
workspace_roots: runtime_workspace_roots.clone(),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
permission_profile: permission_profile.clone(),
active_permission_profile: active_permission_profile.clone(),
profile_workspace_roots: profile_workspace_roots.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality,
})
.await
.map_err(|err| {
invalid_request(format!("invalid thread settings override: {err}"))
})?;
}
let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides {
cwd,
workspace_roots: runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level: None,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
};
let thread_settings = self
.build_thread_settings_overrides(
thread.as_ref(),
ThreadSettingsBuildParams {
method: "turn/start",
cwd: params.cwd,
runtime_workspace_roots: params.runtime_workspace_roots,
approval_policy: params.approval_policy,
approvals_reviewer: params.approvals_reviewer,
sandbox_policy: params.sandbox_policy,
permissions: params.permissions,
model: params.model,
service_tier: params.service_tier,
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
personality: params.personality,
},
)
.await?;
// Start the turn by submitting the user input. Return its submission id as turn_id.
let turn_op = Op::UserInput {
@@ -566,6 +459,215 @@ impl TurnRequestProcessor {
Ok(TurnStartResponse { turn })
}
async fn build_thread_settings_overrides(
&self,
thread: &CodexThread,
params: ThreadSettingsBuildParams,
) -> Result<codex_protocol::protocol::ThreadSettingsOverrides, JSONRPCErrorError> {
let ThreadSettingsBuildParams {
method,
cwd,
runtime_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permissions,
model,
service_tier,
effort,
summary,
collaboration_mode,
personality,
} = params;
if sandbox_policy.is_some() && permissions.is_some() {
return Err(invalid_request(
"`permissions` cannot be combined with `sandboxPolicy`",
));
}
let collaboration_mode =
collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode));
let runtime_workspace_roots_request = runtime_workspace_roots;
// `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() || runtime_workspace_roots_request.is_some() {
Some(thread.config_snapshot().await)
} else {
None
};
let has_any_overrides = cwd.is_some()
|| runtime_workspace_roots_request.is_some()
|| approval_policy.is_some()
|| approvals_reviewer.is_some()
|| sandbox_policy.is_some()
|| permissions.is_some()
|| model.is_some()
|| service_tier.is_some()
|| effort.is_some()
|| summary.is_some()
|| collaboration_mode.is_some()
|| personality.is_some();
let runtime_workspace_roots = if let Some(workspace_roots) =
runtime_workspace_roots_request.clone()
{
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(format!(
"{method} runtime workspace roots missing thread snapshot"
)));
};
let base_cwd = cwd
.as_ref()
.map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path()))
.unwrap_or_else(|| snapshot.cwd.clone());
Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd))
} else {
None
};
let approval_policy =
approval_policy.map(codex_app_server_protocol::AskForApproval::to_core);
let approvals_reviewer =
approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
let sandbox_policy = sandbox_policy.map(|policy| policy.to_core());
let (permission_profile, active_permission_profile, profile_workspace_roots) =
if let Some(permissions) = permissions {
let Some(snapshot) = snapshot.as_ref() else {
return Err(internal_error(format!(
"{method} permission selection missing thread snapshot"
)));
};
let overrides = ConfigOverrides {
cwd: cwd.clone(),
workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else(
|| {
snapshot
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect()
},
)),
default_permissions: Some(permissions),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
};
let config = self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
overrides,
Some(snapshot.cwd.to_path_buf()),
)
.await
.map_err(|err| config_load_error(&err))?;
// Startup config is allowed to fall back when requirements
// disallow a configured profile. An explicit settings update
// is different: reject it before accepting the request.
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
warning.contains("Configured value for `permission_profile` is disallowed")
}) {
return Err(invalid_request(format!(
"invalid thread settings override: {warning}"
)));
}
(
Some(config.permissions.permission_profile().clone()),
config.permissions.active_permission_profile(),
Some(config.permissions.profile_workspace_roots().to_vec()),
)
} else {
(None, None, None)
};
let effort = effort.map(Some);
if has_any_overrides {
thread
.preview_thread_settings_overrides(CodexThreadSettingsOverrides {
cwd: cwd.clone(),
workspace_roots: runtime_workspace_roots.clone(),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
permission_profile: permission_profile.clone(),
active_permission_profile: active_permission_profile.clone(),
profile_workspace_roots: profile_workspace_roots.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
personality,
})
.await
.map_err(|err| {
invalid_request(format!("invalid thread settings override: {err}"))
})?;
}
Ok(codex_protocol::protocol::ThreadSettingsOverrides {
cwd,
workspace_roots: runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level: None,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
})
}
async fn thread_settings_update_inner(
&self,
request_id: &ConnectionRequestId,
params: ThreadSettingsUpdateParams,
) -> Result<ThreadSettingsUpdateResponse, JSONRPCErrorError> {
let (_, thread) = self.load_thread(&params.thread_id).await?;
let thread_settings = self
.build_thread_settings_overrides(
thread.as_ref(),
ThreadSettingsBuildParams {
method: "thread/settings/update",
cwd: params.cwd,
runtime_workspace_roots: None,
approval_policy: params.approval_policy,
approvals_reviewer: params.approvals_reviewer,
sandbox_policy: params.sandbox_policy,
permissions: params.permissions,
model: params.model,
service_tier: params.service_tier,
effort: params.effort,
summary: params.summary,
collaboration_mode: params.collaboration_mode,
personality: params.personality,
},
)
.await?;
if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() {
self.submit_core_op(
request_id,
thread.as_ref(),
Op::ThreadSettings { thread_settings },
)
.await
.map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?;
}
Ok(ThreadSettingsUpdateResponse {})
}
async fn thread_inject_items_response_inner(
&self,
params: ThreadInjectItemsParams,