Unify turn input submission and routing (#38275)

## What changed

- Add `TurnInputRequest` and typed submission results for atomically starting a turn, steering the active turn, or declining input with a specific reason.
- Expose `start_or_steer_turn`, `start_turn_if_idle`, and `steer_turn` on `CodexThread`, and migrate Core consumers to these APIs.
- Make app-server `turn/start` steer an active regular turn and return that turn's ID. Reject incompatible output schemas and non-steerable turns without applying settings or enqueueing input.

## Testing

- Cover concurrent start-or-steer submissions, accepted and rejected settings updates, output-schema compatibility, idle-start rejection, and app-server steering.

GitOrigin-RevId: dd9b5528d76ec650c019e97af420bc13190ea86a
This commit is contained in:
Owen Lin authored and copyberry committed 2026-08-13 00:00:16 +00:00
1 parent 4b07886d59
commit cbb7e82a8b
137 files changed
+5217 -6812

No files matched your search

+10 -244
View File
@@ -10,10 +10,9 @@ use tracing::Instrument;
use tracing::debug_span;
use tracing::info_span;
use crate::session::SteerInputError;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::session::SessionSettingsUpdate;
use crate::session::thread_settings;
use crate::session::turn_input;
use crate::config::Config;
use crate::review_prompts::resolve_review_request;
@@ -22,10 +21,7 @@ use crate::tasks::CompactTask;
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use crate::user_message_admission::UserMessageAdmission;
use codex_history::RolloutItem;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -43,8 +39,6 @@ use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_permissions::RequestPermissionsResponse;
@@ -82,231 +76,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
.await;
}
pub async fn user_input_or_turn(
sess: &Arc<Session>,
sub_id: String,
op: Op,
client_user_message_id: Option<String>,
parent_turn_id: Option<String>,
root_turn_id: Option<String>,
) {
let admission = user_input_or_turn_inner(
sess,
sub_id.clone(),
op,
client_user_message_id,
parent_turn_id,
root_turn_id,
)
.await;
sess.pending_user_message_admissions
.complete(&sub_id, admission);
}
pub async fn update_thread_settings(
sess: &Arc<Session>,
sub_id: String,
thread_settings: ThreadSettingsOverrides,
) {
let updates = thread_settings_update(sess, thread_settings).await;
match sess.update_settings(updates).await {
Ok(()) => {
sess.send_event_raw_without_materializing_rollout(Event {
id: sub_id,
msg: thread_settings_applied_event(sess).await,
})
.await;
}
Err(err) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("invalid thread settings override: {err}"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
}
async fn thread_settings_update(
sess: &Session,
thread_settings: ThreadSettingsOverrides,
) -> SessionSettingsUpdate {
let ThreadSettingsOverrides {
environments,
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 {
environments,
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()
}
}
pub(super) 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: snapshot.into_thread_settings_snapshot(),
})
}
pub(super) async fn user_input_or_turn_inner(
sess: &Arc<Session>,
sub_id: String,
op: Op,
client_user_message_id: Option<String>,
parent_turn_id: Option<String>,
root_turn_id: Option<String>,
) -> CodexResult<UserMessageAdmission> {
let Op::UserInput {
items,
final_output_json_schema,
responsesapi_client_metadata,
additional_context,
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);
// new_turn_with_sub_id already emits an error event when settings are invalid.
let current_context = sess.new_turn_with_sub_id(sub_id.clone(), updates).await?;
if emit_thread_settings_applied {
sess.send_event_raw_without_materializing_rollout(Event {
id: sub_id.clone(),
msg: thread_settings_applied_event(sess).await,
})
.await;
}
sess.maybe_emit_model_warnings_for_turn(current_context.as_ref())
.await;
if let Some(id) = root_turn_id.as_ref() {
current_context
.turn_metadata_state
.set_root_turn_id(id.clone());
}
let incoming_turn_metadata = parent_turn_id
.as_ref()
.map(|_| current_context.turn_metadata_state.as_ref());
match sess
.steer_input(
items.clone(),
additional_context.clone(),
/*expected_turn_id*/ None,
client_user_message_id.clone(),
responsesapi_client_metadata.clone(),
incoming_turn_metadata,
)
.await
{
Ok(turn_id) => {
current_context.session_telemetry.user_prompt(&items);
Ok(UserMessageAdmission::Steered { turn_id })
}
Err(SteerInputError::NoActiveTurn(items)) => {
if root_turn_id.is_none()
&& parent_turn_id.is_none()
&& !items.is_empty()
&& current_context
.turn_metadata_state
.can_start_root_turn(&current_context.session_source)
{
current_context
.turn_metadata_state
.set_root_turn_id(sub_id.clone());
}
if let Some(id) = parent_turn_id {
current_context.turn_metadata_state.set_parent_turn_id(id);
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
current_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
current_context.session_telemetry.user_prompt(&items);
let additional_context_input = {
let mut state = sess.state.lock().await;
state.additional_context.merge(additional_context)
};
let mut task_input = additional_context_input
.into_iter()
.map(ResponseItem::from)
.map(|item| sess.annotate_client_response_item(item))
.map(TurnInput::ResponseItem)
.collect::<Vec<_>>();
if !items.is_empty() {
task_input.push(TurnInput::UserInput {
content: items,
client_id: client_user_message_id,
});
}
sess.spawn_task(
Arc::clone(&current_context),
task_input,
crate::tasks::RegularTask::new(),
)
.await;
Ok(UserMessageAdmission::Started { turn_id: sub_id })
}
Err(err) => {
sess.send_event_raw(Event {
id: sub_id.clone(),
msg: EventMsg::Error(err.to_error_event()),
})
.await;
Err(CodexErr::InvalidRequest(format!(
"failed to admit user message: {err:?}"
)))
}
}
}
/// Queues an inter-agent message, then lets the shared pending-work scheduler
/// decide whether an idle session should start a regular turn.
pub async fn inter_agent_communication(
@@ -787,20 +556,17 @@ pub(super) async fn submission_loop(
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
false
}
op @ Op::UserInput { .. } => {
user_input_or_turn(
&sess,
sub.id.clone(),
op,
sub.client_user_message_id,
sub.parent_turn_id,
sub.root_turn_id,
)
.await;
Op::TurnInput {
request,
mode,
reply,
} => {
let result = turn_input::handle(&sess, *request, mode, sub.id.clone()).await;
let _ = reply.send(result);
false
}
Op::ThreadSettings { thread_settings } => {
update_thread_settings(&sess, sub.id.clone(), thread_settings).await;
thread_settings::update(&sess, sub.id.clone(), thread_settings).await;
false
}
Op::InterAgentCommunication { communication } => {