[5 of 7] Replace OverrideTurnContext with ThreadSettings (#22508)

**Stack position:** [5 of 7]

## Summary

This PR adds `Op::ThreadSettings`, a queued settings-only update
mechanism for changing stored thread settings without starting a new
turn. It also removes the legacy `Op::OverrideTurnContext` in the same
layer, so reviewers can see the replacement and deletion together.

## Changes

- Add `Op::ThreadSettings` for settings-only queued updates.
- Emit `ThreadSettingsApplied` with the effective thread settings
snapshot after core applies an update.
- Route settings-only updates through the same submission queue as user
input.
- Migrate remaining `OverrideTurnContext` tests and callers to the
queued `Op::ThreadSettings` path.
- Delete `Op::OverrideTurnContext` from the core protocol and submission
loop.

This stack addresses #20656 and #22090.

## Stack

1. [1 of 7] [Add thread settings to
UserInput](https://github.com/openai/codex/pull/23080)
2. [2 of 7] [Remove
UserInputWithTurnContext](https://github.com/openai/codex/pull/23081)
3. [3 of 7] [Remove
UserTurn](https://github.com/openai/codex/pull/23075)
4. [4 of 7] [Placeholder for OverrideTurnContext
cleanup](https://github.com/openai/codex/pull/23087)
5. [5 of 7] [Replace OverrideTurnContext with
ThreadSettings](https://github.com/openai/codex/pull/22508) (this PR)
6. [6 of 7] [Add app-server thread settings
API](https://github.com/openai/codex/pull/22509)
7. [7 of 7] [Sync TUI thread
settings](https://github.com/openai/codex/pull/22510)
This commit is contained in:
Eric Traut
2026-05-18 21:03:51 -07:00
committed by GitHub
parent d3d38159ed
commit a668379abf
29 changed files with 553 additions and 869 deletions
+120 -125
View File
@@ -42,7 +42,9 @@ use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::ThreadSettingsSnapshot;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
@@ -81,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
.await;
}
pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) {
if let Err(err) = sess.update_settings(updates).await {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: err.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
user_input_or_turn_inner(
sess,
@@ -104,36 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
.await;
}
pub async fn update_thread_settings(
sess: &Arc<Session>,
sub_id: String,
thread_settings: ThreadSettingsOverrides,
) {
let updates = thread_settings_update(sess, thread_settings).await;
let msg = match sess.update_settings(updates).await {
Ok(()) => thread_settings_applied_event(sess).await,
Err(err) => EventMsg::Error(ErrorEvent {
message: format!("invalid thread settings override: {err}"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
};
sess.send_event_raw(Event { id: sub_id, msg }).await;
}
async fn thread_settings_update(
sess: &Session,
thread_settings: ThreadSettingsOverrides,
) -> SessionSettingsUpdate {
let ThreadSettingsOverrides {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} = thread_settings;
let collaboration_mode = match collaboration_mode {
Some(collaboration_mode) => collaboration_mode,
None => {
let state = sess.state.lock().await;
// Model and reasoning effort live in CollaborationMode settings today, so
// partial thread-settings updates refresh those fields on the active mode.
state
.session_configuration
.collaboration_mode
.with_updates(model, effort, /*developer_instructions*/ None)
}
};
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
reasoning_summary: summary,
service_tier,
personality,
..Default::default()
}
}
async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
let snapshot = {
let state = sess.state.lock().await;
state.session_configuration.thread_config_snapshot()
};
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
thread_settings: ThreadSettingsSnapshot {
model: snapshot.model,
model_provider_id: snapshot.model_provider_id,
service_tier: snapshot.service_tier,
approval_policy: snapshot.approval_policy,
approvals_reviewer: snapshot.approvals_reviewer,
permission_profile: snapshot.permission_profile,
active_permission_profile: snapshot.active_permission_profile,
cwd: snapshot.cwd,
reasoning_effort: snapshot.reasoning_effort,
reasoning_summary: snapshot.reasoning_summary,
personality: snapshot.personality,
collaboration_mode: snapshot.collaboration_mode,
},
})
}
pub(super) async fn user_input_or_turn_inner(
sess: &Arc<Session>,
sub_id: String,
op: Op,
mirror_user_text_to_realtime: Option<()>,
) {
let (items, updates, responsesapi_client_metadata) = match op {
Op::UserInput {
items,
environments,
final_output_json_schema,
responsesapi_client_metadata,
thread_settings,
} => {
let mut updates = if thread_settings == ThreadSettingsOverrides::default() {
SessionSettingsUpdate::default()
} else {
thread_settings_update(sess, thread_settings).await
};
updates.final_output_json_schema = Some(final_output_json_schema);
updates.environments = environments;
(items, updates, responsesapi_client_metadata)
}
_ => unreachable!(),
let Op::UserInput {
items,
environments,
final_output_json_schema,
responsesapi_client_metadata,
thread_settings,
} = op
else {
unreachable!();
};
let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default();
let mut updates = if emit_thread_settings_applied {
thread_settings_update(sess, thread_settings).await
} else {
SessionSettingsUpdate::default()
};
updates.final_output_json_schema = Some(final_output_json_schema);
updates.environments = environments;
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
// new_turn_with_sub_id already emits the error event.
return;
};
if emit_thread_settings_applied {
sess.send_event_raw(Event {
id: sub_id.clone(),
msg: thread_settings_applied_event(sess).await,
})
.await;
}
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
let accepted_items = match sess
@@ -183,56 +268,6 @@ pub(super) async fn user_input_or_turn_inner(
}
}
async fn thread_settings_update(
sess: &Session,
thread_settings: ThreadSettingsOverrides,
) -> SessionSettingsUpdate {
let ThreadSettingsOverrides {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} = thread_settings;
let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode {
collaboration_mode
} else {
let state = sess.state.lock().await;
// Model and reasoning effort live in CollaborationMode settings today, so
// partial thread-settings updates refresh those fields on the active mode.
state
.session_configuration
.collaboration_mode
.with_updates(model, effort, /*developer_instructions*/ None)
};
SessionSettingsUpdate {
cwd,
workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
reasoning_summary: summary,
service_tier,
personality,
..Default::default()
}
}
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
let text = UserMessageItem::new(items).message();
if text.is_empty() {
@@ -729,54 +764,14 @@ pub(super) async fn submission_loop(
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
false
}
Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => {
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
collab_mode
} else {
let state = sess.state.lock().await;
state.session_configuration.collaboration_mode.with_updates(
model.clone(),
effort,
/*developer_instructions*/ None,
)
};
override_turn_context(
&sess,
sub.id.clone(),
SessionSettingsUpdate {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
windows_sandbox_level,
collaboration_mode: Some(collaboration_mode),
reasoning_summary: summary,
service_tier,
personality,
..Default::default()
},
)
.await;
false
}
Op::UserInput { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
false
}
Op::ThreadSettings { thread_settings } => {
update_thread_settings(&sess, sub.id.clone(), thread_settings).await;
false
}
Op::InterAgentCommunication { communication } => {
inter_agent_communication(&sess, sub.id.clone(), communication).await;
false
+6 -3
View File
@@ -1384,12 +1384,15 @@ impl Session {
Ok(())
}
pub(crate) async fn validate_settings(
pub(crate) async fn preview_settings(
&self,
updates: &SessionSettingsUpdate,
) -> ConstraintResult<()> {
) -> ConstraintResult<ThreadConfigSnapshot> {
let state = self.state.lock().await;
state.session_configuration.apply(updates).map(|_| ())
state
.session_configuration
.apply(updates)
.map(|configuration| configuration.thread_config_snapshot())
}
pub(crate) async fn set_session_startup_prewarm(
+2
View File
@@ -178,7 +178,9 @@ impl SessionConfiguration {
profile_workspace_roots: self.profile_workspace_roots().to_vec(),
ephemeral: self.original_config_do_not_use.ephemeral,
reasoning_effort: self.collaboration_mode.reasoning_effort(),
reasoning_summary: self.model_reasoning_summary,
personality: self.personality,
collaboration_mode: self.collaboration_mode.clone(),
session_source: self.session_source.clone(),
thread_source: self.thread_source,
}
+23 -47
View File
@@ -118,6 +118,7 @@ use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
@@ -2257,24 +2258,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
developer_instructions: Some("Fork turn collaboration instructions.".to_string()),
},
};
forked
.thread
.submit(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(AskForApproval::Never),
approvals_reviewer: None,
sandbox_policy: None,
permission_profile: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
forked
.thread
.submit(Op::UserInput {
@@ -2285,7 +2268,11 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
thread_settings: Default::default(),
thread_settings: ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
collaboration_mode: Some(collaboration_mode),
..Default::default()
},
})
.await?;
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
@@ -2338,7 +2325,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let turn_id = previous_context_item
.turn_id
.clone()
.expect("turn context should have turn_id");
.expect("thread settings should have turn_id");
let rollout_items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
@@ -2521,14 +2508,14 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
let first_turn_id = first_context_item
.turn_id
.clone()
.expect("turn context should have turn_id");
.expect("thread settings should have turn_id");
let mut rolled_back_context_item = first_context_item.clone();
rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string());
rolled_back_context_item.model = "rolled-back-model".to_string();
let rolled_back_turn_id = rolled_back_context_item
.turn_id
.clone()
.expect("turn context should have turn_id");
.expect("thread settings should have turn_id");
let turn_one_user = user_message("turn 1 user");
let turn_one_assistant = assistant_message("turn 1 assistant");
let turn_two_user = user_message("turn 2 user");
@@ -2637,7 +2624,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
let first_turn_id = first_context_item
.turn_id
.clone()
.expect("turn context should have turn_id");
.expect("thread settings should have turn_id");
let compact_turn_id = "compact-turn".to_string();
let rolled_back_turn_id = "rolled-back-turn".to_string();
let compacted_history = vec![
@@ -4833,7 +4820,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.expect("single thread settings ref")
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
@@ -4911,7 +4898,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.expect("single thread settings ref")
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
@@ -5008,7 +4995,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.expect("single thread settings ref")
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
@@ -5198,24 +5185,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() {
#[test]
fn op_kind_for_input_and_context_ops() {
assert_eq!(
Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,
permission_profile: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}
.kind(),
"override_turn_context"
);
assert_eq!(
Op::UserInput {
environments: None,
@@ -5227,6 +5196,13 @@ fn op_kind_for_input_and_context_ops() {
.kind(),
"user_input"
);
assert_eq!(
Op::ThreadSettings {
thread_settings: ThreadSettingsOverrides::default(),
}
.kind(),
"thread_settings"
);
}
#[tokio::test]
@@ -6798,7 +6774,7 @@ async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_develo
.session_configuration
.session_source = session_source.clone();
Arc::get_mut(&mut turn_context)
.expect("turn context should not be shared")
.expect("thread settings should not be shared")
.session_source = session_source;
let initial_context = session.build_initial_context(turn_context.as_ref()).await;
@@ -7064,7 +7040,7 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o
#[tokio::test]
async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_builds() {
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
let mut turn_context = Arc::into_inner(turn_context).expect("sole turn context owner");
let mut turn_context = Arc::into_inner(turn_context).expect("sole thread settings owner");
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![
SkillMetadata {
@@ -9890,7 +9866,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
// The rejection should not poison the non-escalated path for the same
// command. Force DangerFullAccess so this check stays focused on approval
// policy rather than platform-specific sandbox behavior.
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc");
turn_context_mut.permission_profile = PermissionProfile::Disabled;
let file_system_sandbox_policy = turn_context.file_system_sandbox_policy();
+1
View File
@@ -1476,6 +1476,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::TurnStarted(_)
| EventMsg::ThreadSettingsApplied(_)
| EventMsg::TurnComplete(_)
| EventMsg::TokenCount(_)
| EventMsg::UserMessage(_)