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

+64
View File
@@ -3,6 +3,7 @@ use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_core::CodexThread;
@@ -76,6 +77,7 @@ pub(crate) struct ThreadState {
pub(crate) cancel_tx: Option<oneshot::Sender<()>>,
pub(crate) experimental_raw_events: bool,
pub(crate) listener_generation: u64,
last_thread_settings: Option<ThreadSettings>,
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
listener_thread: Option<Weak<CodexThread>>,
@@ -95,11 +97,13 @@ impl ThreadState {
cancel_tx: oneshot::Sender<()>,
conversation: &Arc<CodexThread>,
watch_registration: WatchRegistration,
thread_settings_baseline: ThreadSettings,
) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {
if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
let _ = previous.send(());
}
self.listener_generation = self.listener_generation.wrapping_add(1);
self.last_thread_settings = Some(thread_settings_baseline);
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
self.listener_command_tx = Some(listener_command_tx);
self.listener_thread = Some(Arc::downgrade(conversation));
@@ -143,6 +147,12 @@ impl ThreadState {
self.current_turn_history.reset();
}
}
pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool {
let changed = self.last_thread_settings.as_ref() != Some(&thread_settings);
self.last_thread_settings = Some(thread_settings);
changed
}
}
pub(crate) async fn resolve_server_request_on_thread_listener(
@@ -177,6 +187,60 @@ pub(crate) async fn resolve_server_request_on_thread_listener(
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::SandboxPolicy;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use pretty_assertions::assert_eq;
#[test]
fn note_thread_settings_reports_only_effective_changes() {
let mut state = ThreadState::default();
let initial = thread_settings("mock-model");
let updated = thread_settings("mock-model-2");
let results = vec![
state.note_thread_settings(initial.clone()),
state.note_thread_settings(initial),
state.note_thread_settings(updated.clone()),
state.note_thread_settings(updated),
];
assert_eq!(results, vec![true, false, true, false]);
}
fn thread_settings(model: &str) -> ThreadSettings {
ThreadSettings {
cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: None,
model: model.to_string(),
model_provider: "mock_provider".to_string(),
service_tier: None,
effort: None,
summary: None,
collaboration_mode: CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: model.to_string(),
reasoning_effort: None,
developer_instructions: None,
},
},
personality: None,
}
}
}
struct ThreadEntry {
state: Arc<Mutex<ThreadState>>,
connection_ids: HashSet<ConnectionId>,