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
2026-08-13 00:00:16 +00:00
committed by copyberry
parent 4b07886d59
commit cbb7e82a8b
137 changed files with 5217 additions and 6812 deletions
+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 } => {
+6 -141
View File
@@ -1,18 +1,10 @@
use super::input_queue::TurnInput;
use super::TurnInput as PendingTurnInput;
use super::session::Session;
use super::turn_context::TurnContext;
use crate::codex_thread::TryStartTurnIfIdleError;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::state::ActiveTurn;
use crate::state::TurnState;
use crate::tasks::MailboxParentProvenance;
use crate::tasks::RegularTask;
use codex_features::Feature;
use codex_history::CodexHarnessMetadata;
use codex_history::ResponseItemEnvelope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseItem;
use std::sync::Arc;
impl Session {
/// Returns the input if there is no active turn to inject into.
@@ -33,7 +25,7 @@ impl Session {
input
.into_iter()
.map(ResponseItemEnvelope::new)
.map(TurnInput::ResponseItem)
.map(PendingTurnInput::ResponseItem)
.collect(),
)
.await;
@@ -62,7 +54,10 @@ impl Session {
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
items.into_iter().map(TurnInput::ResponseItem).collect(),
items
.into_iter()
.map(PendingTurnInput::ResponseItem)
.collect(),
)
.await;
return;
@@ -119,136 +114,6 @@ impl Session {
.await;
}
/// Starts a regular turn with the provided input only if automatic idle work
/// is allowed for the current session state.
///
/// This is the shared gate for extension-initiated idle work. It refuses to
/// start a turn when user/client-triggered work is queued or any task is
/// still active. Work without user input is also rejected in Plan mode.
/// Active Review tasks are covered by the active-task check because Review
/// turns are not steerable.
pub(crate) async fn try_start_turn_if_idle(
self: &Arc<Self>,
input: Vec<TurnInput>,
) -> Result<(), TryStartTurnIfIdleError> {
if input.is_empty() {
return Ok(());
}
let has_user_input = input.iter().any(
|item| matches!(item, TurnInput::UserInput { content, .. } if !content.is_empty()),
);
if self.input_queue.has_trigger_turn_mailbox_items().await {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
if !has_user_input && self.collaboration_mode().await.mode == ModeKind::Plan {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
let turn_state = {
let mut active_turn = self.active_turn.lock().await;
if active_turn.is_some() {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
let active_turn = active_turn.get_or_insert_with(ActiveTurn::default);
Arc::clone(&active_turn.turn_state)
};
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let turn_context = self
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
.await;
if !has_user_input && turn_context.mode == ModeKind::Plan {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
self.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let still_reserved = {
let active_turn = self.active_turn.lock().await;
active_turn.as_ref().is_some_and(|active_turn| {
active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state)
})
};
if !still_reserved {
self.clear_reserved_idle_turn(&turn_state).await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
if has_user_input
&& turn_context
.turn_metadata_state
.can_start_root_turn(&turn_context.session_source)
{
turn_context
.turn_metadata_state
.set_root_turn_id(turn_context.sub_id.clone());
}
let task_input = if has_user_input {
self.clear_connector_selection().await;
for item in &input {
if let TurnInput::UserInput { content, .. } = item {
turn_context.session_telemetry.user_prompt(content);
}
}
input
} else {
self.input_queue
.extend_pending_input_for_turn_state(turn_state.as_ref(), input)
.await;
Vec::new()
};
self.start_task(
turn_context,
task_input,
RegularTask::new(),
MailboxParentProvenance::Ignore,
)
.await;
Ok(())
}
async fn clear_reserved_idle_turn(&self, turn_state: &Arc<tokio::sync::Mutex<TurnState>>) {
let mut active_turn_guard = self.active_turn.lock().await;
if let Some(active_turn) = active_turn_guard.as_ref()
&& active_turn.task.is_none()
&& Arc::ptr_eq(&active_turn.turn_state, turn_state)
{
*active_turn_guard = None;
}
}
/// Injects items into active work, or records them without starting a turn.
pub(crate) async fn inject_no_new_turn(
&self,
+1
View File
@@ -376,6 +376,7 @@ mod tests {
use super::*;
use codex_history::CodexHarnessMetadata;
use codex_protocol::AgentPath;
use codex_protocol::user_input::UserInput;
use pretty_assertions::assert_eq;
#[test]
+36 -182
View File
@@ -1,5 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Debug;
@@ -92,9 +91,7 @@ use codex_protocol::approvals::NetworkPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyRuleAction;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
@@ -111,7 +108,6 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::HistoryPosition;
@@ -221,17 +217,19 @@ mod rollout_reconstruction;
#[allow(clippy::module_inception)]
pub(crate) mod session;
pub(crate) mod step_context;
mod thread_settings;
pub(crate) mod time_reminder;
mod token_budget;
pub(crate) mod turn;
pub(crate) mod turn_context;
mod turn_input;
mod world_state;
use self::code_mode_warning::unsupported_code_mode_warning;
#[cfg(test)]
use self::handlers::submission_dispatch_span;
use self::handlers::submission_loop;
pub(crate) use self::input_queue::InputQueueActivity;
pub use self::input_queue::TurnInput;
pub(crate) use self::input_queue::TurnInput;
pub(crate) use self::input_queue::TurnInputQueue;
use self::review::spawn_review_thread;
use self::session::AppServerClientMetadata;
@@ -248,45 +246,6 @@ use self::turn_context::TurnContext;
#[cfg(test)]
mod rollout_reconstruction_tests;
#[derive(Debug, PartialEq)]
pub enum SteerInputError {
NoActiveTurn(Vec<UserInput>),
ExpectedTurnMismatch { expected: String, actual: String },
ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
EmptyInput,
}
impl SteerInputError {
fn to_error_event(&self) -> ErrorEvent {
match self {
Self::NoActiveTurn(_) => ErrorEvent {
message: "no active turn to steer".to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent {
message: format!("expected active turn id `{expected}` but found `{actual}`"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
Self::ActiveTurnNotSteerable { turn_kind } => {
let turn_kind_label = match turn_kind {
NonSteerableTurnKind::Review => "review",
NonSteerableTurnKind::Compact => "compact",
};
ErrorEvent {
message: format!("cannot steer a {turn_kind_label} turn"),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: *turn_kind,
}),
}
}
Self::EmptyInput => ErrorEvent {
message: "input must not be empty".to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
},
}
}
}
/// Notes from the previous real user turn.
///
/// Conceptually this is the same role that `previous_model` used to fill, but
@@ -344,8 +303,10 @@ use codex_otel::THREAD_STARTED_METRIC;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ResponseItemId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::models::LocalImagePreparation;
@@ -356,7 +317,6 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::DeprecationNoticeEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
@@ -365,7 +325,6 @@ use codex_protocol::protocol::ModelRerouteReason;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::ModelVerificationEvent;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RequestUserInputEvent;
@@ -381,6 +340,9 @@ use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::turn_input::TurnInputMode;
use codex_protocol::turn_input::TurnInputRequest;
use codex_protocol::turn_input::TurnInputSubmission;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::HostSkillsService;
use codex_tools::UnifiedExecShellMode;
@@ -847,7 +809,6 @@ impl SessionIo {
let sub = Submission {
id: id.clone(),
op,
client_user_message_id: None,
trace,
parent_turn_id,
root_turn_id,
@@ -856,26 +817,6 @@ impl SessionIo {
Ok(id)
}
pub(crate) async fn submit_user_input_with_client_user_message_id(
&self,
op: Op,
trace: Option<W3cTraceContext>,
client_user_message_id: Option<String>,
) -> CodexResult<String> {
debug_assert!(matches!(op, Op::UserInput { .. }));
let id = new_submission_id();
let sub = Submission {
id: id.clone(),
op,
client_user_message_id,
trace,
parent_turn_id: None,
root_turn_id: None,
};
self.submit_with_id(sub).await?;
Ok(id)
}
/// Use sparingly: prefer `submit()` so submission IDs are generated consistently.
pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
if sub.trace.is_none() {
@@ -888,6 +829,33 @@ impl SessionIo {
Ok(())
}
/// Submits an ordered turn-input call and waits only for Core's routing decision.
///
/// Once queued, dropping the waiter does not retract the call. If the
/// session loop exits before replying, the caller gets `InternalAgentDied`.
pub(crate) async fn submit_turn_input(
&self,
mut request: TurnInputRequest,
mode: TurnInputMode,
) -> CodexResult<TurnInputSubmission> {
let id = new_submission_id();
let (reply_tx, reply_rx) = oneshot::channel();
let trace = request.trace.take();
self.submit_with_id(Submission {
id,
op: Op::TurnInput {
request: Box::new(request),
mode,
reply: reply_tx,
},
trace,
parent_turn_id: None,
root_turn_id: None,
})
.await?;
reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
}
pub(crate) async fn shutdown_and_wait(&self) -> CodexResult<()> {
let session_loop_termination = self.session_loop_termination.clone();
match self.submit(Op::Shutdown).await {
@@ -1248,27 +1216,6 @@ impl Session {
format!("auto-compact-{id}")
}
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
handlers::user_input_or_turn(
self,
Uuid::now_v7().to_string(),
Op::UserInput {
items: vec![UserInput::Text {
text,
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
/*client_user_message_id*/ None,
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await;
}
pub(crate) async fn get_total_token_usage(&self) -> i64 {
let state = self.state.lock().await;
state.get_total_token_usage(state.server_reasoning_included())
@@ -1434,7 +1381,7 @@ impl Session {
}
let thread_settings_applied =
RolloutItem::EventMsg(handlers::thread_settings_applied_event(self).await);
RolloutItem::EventMsg(thread_settings::applied_event(self).await);
match &self.fork_persistence {
ForkPersistence::Referenced {
inherited_item_count,
@@ -4043,99 +3990,6 @@ impl Session {
self.send_event(turn_context, event).await;
}
/// Inject additional user input into the currently active turn.
///
/// Returns the active turn id when accepted.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
pub async fn steer_input(
&self,
input: Vec<UserInput>,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
client_user_message_id: Option<String>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
incoming_turn_metadata: Option<&TurnMetadataState>,
) -> Result<String, SteerInputError> {
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(SteerInputError::NoActiveTurn(input));
};
let Some(active_task) = active_turn.task.as_ref() else {
return Err(SteerInputError::NoActiveTurn(input));
};
let active_turn_id = &active_task.turn_context.sub_id;
if let Some(expected_turn_id) = expected_turn_id
&& expected_turn_id != active_turn_id
{
return Err(SteerInputError::ExpectedTurnMismatch {
expected: expected_turn_id.to_string(),
actual: active_turn_id.clone(),
});
}
match active_task.kind {
crate::state::TaskKind::Regular => {}
crate::state::TaskKind::Review => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
});
}
crate::state::TaskKind::Compact => {
return Err(SteerInputError::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
});
}
}
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
}
let additional_context_input = {
let mut state = self.state.lock().await;
state.additional_context.merge(additional_context)
};
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
active_task
.turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
let mut pending_input = additional_context_input
.into_iter()
.map(ResponseItem::from)
.map(|item| self.annotate_client_response_item(item))
.map(TurnInput::ResponseItem)
.collect::<Vec<_>>();
pending_input.push(TurnInput::UserInput {
content: input,
client_id: client_user_message_id.clone(),
});
if let Some(incoming_turn_metadata) = incoming_turn_metadata
&& active_task.turn_context.turn_metadata_state.root_turn_id()
!= incoming_turn_metadata.root_turn_id()
{
active_task
.turn_context
.turn_metadata_state
.mark_root_turn_ambiguous();
}
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
pending_input,
)
.await;
Ok(active_turn_id.clone())
}
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
let turn_state = self
.input_queue
-3
View File
@@ -57,8 +57,6 @@ pub(crate) struct Session {
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) async_hook_results: async_channel::Receiver<HookCompletedEvent>,
pub(crate) pending_user_message_admissions:
crate::user_message_admission::PendingUserMessageAdmissions,
pub(crate) input_queue: InputQueue,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
@@ -1312,7 +1310,6 @@ impl Session {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
async_hook_results,
pending_user_message_admissions: Default::default(),
input_queue: InputQueue::new(),
guardian_review_session: GuardianReviewSessionManager::default(),
services,
+62 -482
View File
@@ -2,7 +2,6 @@ use super::mcp_refresh::McpRefresh;
use super::turn_context::TurnEnvironment;
use super::*;
use crate::agents_md_manager::AgentsMdManager;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::test_config;
@@ -30,7 +29,7 @@ use codex_config::loader::project_trust_key;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::ToolSuggestDisabledTool;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::TurnInputRequest as ExternalTurnInputRequest;
use codex_features::Feature;
use codex_http_client::ClientRouteClass;
@@ -67,13 +66,18 @@ use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::turn_input::TurnInput as SubmittedTurnInput;
use codex_protocol::turn_input::TurnInputMode;
use codex_protocol::turn_input::TurnInputRequest;
use codex_protocol::turn_input::TurnInputSubmission;
use codex_tools::ToolSpec;
use codex_utils_path_uri::PathUri;
use std::collections::BTreeMap;
use tracing::Span;
use crate::connectors::AppInfo;
@@ -3388,16 +3392,12 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
initial
.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
.start_or_steer_turn(ExternalTurnInputRequest::user_input(vec![
UserInput::Text {
text: "fork seed".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
},
]))
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
// Forking reads the persisted rollout JSONL, so force the completed source turn to disk
@@ -3433,20 +3433,17 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
};
forked
.thread
.submit(Op::UserInput {
items: vec![UserInput::Text {
.start_or_steer_turn(
ExternalTurnInputRequest::user_input(vec![UserInput::Text {
text: "after fork".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides {
}])
.with_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;
@@ -5981,7 +5978,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
async_hook_results,
pending_user_message_admissions: Default::default(),
input_queue: super::input_queue::InputQueue::new(),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
@@ -6929,7 +6925,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
}
#[tokio::test]
async fn submit_with_id_captures_current_span_trace_context() {
async fn submit_with_trace_captures_current_span_trace_context() {
let (_session, _turn_context) = make_session_and_context().await;
let (tx_sub, rx_sub) = async_channel::bounded(1);
let (_tx_event, rx_event) = async_channel::unbounded();
@@ -6955,14 +6951,12 @@ async fn submit_with_id_captures_current_span_trace_context() {
let expected_trace = async {
let expected_trace =
current_span_w3c_trace_context().expect("current span should have trace context");
io.submit_with_id(Submission {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
parent_turn_id: None,
root_turn_id: None,
trace: None,
})
io.submit_with_trace(
Op::Interrupt,
/*trace*/ None,
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await
.expect("submit should succeed");
expected_trace
@@ -7032,7 +7026,6 @@ fn submission_dispatch_span_prefers_submission_trace_context() {
submission_dispatch_span(&Submission {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
parent_turn_id: None,
root_turn_id: None,
trace: Some(submission_trace),
@@ -7061,7 +7054,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() {
item_id: None,
},
}),
client_user_message_id: None,
parent_turn_id: None,
root_turn_id: None,
trace: None,
@@ -7073,78 +7065,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() {
);
}
#[test]
fn op_kind_for_input_and_context_ops() {
assert_eq!(
Op::UserInput {
items: vec![],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
}
.kind(),
"user_input"
);
assert_eq!(
Op::ThreadSettings {
thread_settings: ThreadSettingsOverrides::default(),
}
.kind(),
"thread_settings"
);
}
#[tokio::test]
async fn user_turn_updates_approvals_reviewer() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
let config = session.get_config().await;
handlers::user_input_or_turn(
&session,
"sub-1".to_string(),
Op::UserInput {
items: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(local_selections(config.cwd.clone())),
approval_policy: Some(config.permissions.approval_policy.value()),
approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview),
sandbox_policy: Some(config.legacy_sandbox_policy()),
summary: config.model_reasoning_summary,
personality: config.personality,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: turn_context.model_info.slug.clone(),
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
}),
..Default::default()
},
},
/*client_user_message_id*/ None,
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await;
let state = session.state.lock().await;
assert_eq!(
state.session_configuration.approvals_reviewer,
codex_config::types::ApprovalsReviewer::AutoReview
);
assert!(
session.mcp_refresh.is_pending(),
"server elicitation authority changes must refresh MCP state"
);
}
#[tokio::test]
async fn turn_environments_set_primary_environment() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
@@ -7426,7 +7346,6 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
let dispatch_span = submission_dispatch_span(&Submission {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
parent_turn_id: None,
root_turn_id: None,
trace: Some(submission_trace.clone()),
@@ -7749,10 +7668,10 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
#[tokio::test]
async fn shutdown_and_wait_allows_multiple_waiters() {
let (_session, _turn_context) = make_session_and_context().await;
let (tx_sub, rx_sub) = async_channel::bounded(4);
let (tx_sub, rx_sub) = async_channel::bounded::<Submission>(4);
let (_tx_event, rx_event) = async_channel::unbounded();
let session_loop_handle = tokio::spawn(async move {
let shutdown: Submission = rx_sub.recv().await.expect("shutdown submission");
let shutdown = rx_sub.recv().await.expect("shutdown submission");
assert!(matches!(shutdown.op, Op::Shutdown));
tokio::time::sleep(StdDuration::from_millis(50)).await;
});
@@ -7836,11 +7755,11 @@ async fn shutdown_and_wait_shuts_down_cached_guardian_subagent() {
};
let (child_session, _child_turn_context) = make_session_and_context().await;
let (child_tx_sub, child_rx_sub) = async_channel::bounded(4);
let (child_tx_sub, child_rx_sub) = async_channel::bounded::<Submission>(4);
let (_child_tx_event, child_rx_event) = async_channel::unbounded();
let (child_shutdown_tx, child_shutdown_rx) = tokio::sync::oneshot::channel();
let child_session_loop_handle = tokio::spawn(async move {
let shutdown: Submission = child_rx_sub
let shutdown = child_rx_sub
.recv()
.await
.expect("child shutdown submission");
@@ -7921,11 +7840,11 @@ async fn shutdown_and_wait_shuts_down_tracked_ephemeral_guardian_review() {
};
let (child_session, _child_turn_context) = make_session_and_context().await;
let (child_tx_sub, child_rx_sub) = async_channel::bounded(4);
let (child_tx_sub, child_rx_sub) = async_channel::bounded::<Submission>(4);
let (_child_tx_event, child_rx_event) = async_channel::unbounded();
let (child_shutdown_tx, child_shutdown_rx) = tokio::sync::oneshot::channel();
let child_session_loop_handle = tokio::spawn(async move {
let shutdown: Submission = child_rx_sub
let shutdown = child_rx_sub
.recv()
.await
.expect("child shutdown submission");
@@ -8242,7 +8161,6 @@ where
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
async_hook_results,
pending_user_message_admissions: Default::default(),
input_queue: super::input_queue::InputQueue::new(),
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
@@ -10425,6 +10343,26 @@ async fn abort_gracefully_emits_marker_before_turn_aborted() {
assert!(rx.try_recv().is_err());
}
async fn submit_steer_only(
sess: &Arc<Session>,
input: Vec<UserInput>,
expected_turn_id: &str,
) -> TurnInputSubmission {
super::turn_input::handle(
sess,
TurnInputRequest::new(SubmittedTurnInput::UserInput {
content: input,
client_id: None,
}),
TurnInputMode::Steer {
expected_turn_id: expected_turn_id.to_string(),
},
"test-submission".to_string(),
)
.await
.expect("steer-only submission should be valid")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
@@ -10455,16 +10393,8 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
text: "late pending input".to_string(),
text_elements: vec![text_element.clone()],
}];
sess.steer_input(
pending_user_input.clone(),
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
/*incoming_turn_metadata*/ None,
)
.await
.expect("steer pending input into active turn");
let submission = submit_steer_only(&sess, pending_user_input.clone(), &tc.sub_id).await;
assert!(matches!(submission, TurnInputSubmission::Steered { .. }));
sess.on_task_finished(Arc::clone(&tc), /*task_result*/ Ok(None))
.await;
@@ -10644,350 +10574,6 @@ async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
assert_eq!(0, calls.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
let item = TurnInput::ResponseItem(user_message("synthetic idle input").into());
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("active turn should reject idle-only input");
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
(Vec::<TurnInput>::new(), None, None),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = sess.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = sess.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let item = TurnInput::ResponseItem(user_message("synthetic idle input").into());
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("plan mode should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason());
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert_eq!(
(Vec::<TurnInput>::new(), None, None),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
}
#[tokio::test]
async fn try_start_turn_if_idle_accepts_user_input_in_plan_mode() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = sess.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = sess.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
state.merge_connector_selection(["calendar".to_string()]);
}
sess.try_start_turn_if_idle(vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "queued user input".to_string(),
text_elements: Vec::new(),
}],
client_id: Some("queued-user-message".to_string()),
}])
.await
.expect("plan mode should accept user-authored idle input");
assert!(sess.state.lock().await.get_connector_selection().is_empty());
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_empty_user_input_in_plan_mode() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = sess.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = sess.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let input = vec![
TurnInput::UserInput {
content: Vec::new(),
client_id: Some("empty-queued-user-message".to_string()),
},
TurnInput::ResponseItem(user_message("automatic idle input").into()),
];
let error = sess
.try_start_turn_if_idle(input.clone())
.await
.expect_err("empty user input should not bypass plan mode");
assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, error.reason());
assert_eq!(input, error.into_input());
assert!(sess.active_turn.lock().await.is_none());
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
sess.input_queue
.enqueue_mailbox_communication(
InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root(),
Vec::new(),
"pending trigger".to_string(),
/*trigger_turn*/ true,
),
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await;
let item = TurnInput::ResponseItem(user_message("synthetic idle input").into());
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("pending trigger-turn mail should reject automatic idle input");
assert_eq!(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
err.reason()
);
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert!(sess.input_queue.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Review,
listen_to_cancellation_token: true,
},
)
.await;
let item = TurnInput::ResponseItem(user_message("synthetic idle input").into());
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("active review turn should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
(Vec::<TurnInput>::new(), None, None),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn steer_input_requires_active_turn() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(
input,
/*additional_context*/ Default::default(),
/*expected_turn_id*/ None,
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
/*incoming_turn_metadata*/ None,
)
.await
.expect_err("steering without active turn should fail");
assert!(matches!(err, SteerInputError::NoActiveTurn(_)));
}
#[tokio::test]
async fn steer_input_enforces_expected_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
client_id: None,
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
Some("different-turn-id"),
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
/*incoming_turn_metadata*/ None,
)
.await
.expect_err("mismatched expected turn id should fail");
match err {
SteerInputError::ExpectedTurnMismatch { expected, actual } => {
assert_eq!(
(expected, actual),
("different-turn-id".to_string(), tc.sub_id.clone())
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn steer_input_rejects_non_regular_turns() {
for (task_kind, turn_kind) in [
(TaskKind::Review, NonSteerableTurnKind::Review),
(TaskKind::Compact, NonSteerableTurnKind::Compact),
] {
let (sess, incoming_turn_context, _rx) = make_session_and_context_with_rx().await;
incoming_turn_context
.turn_metadata_state
.set_root_turn_id("incoming-root".to_string());
let input = vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
client_id: None,
}];
let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await;
turn_context
.turn_metadata_state
.set_root_turn_id("active-root".to_string());
sess.spawn_task(
Arc::clone(&turn_context),
input,
NeverEndingTask {
kind: task_kind,
listen_to_cancellation_token: true,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
/*expected_turn_id*/ None,
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
Some(incoming_turn_context.turn_metadata_state.as_ref()),
)
.await
.expect_err("steering a non-regular turn should fail");
assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind });
assert_eq!(
turn_context.turn_metadata_state.root_turn_id().as_deref(),
Some("active-root")
);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
}
#[tokio::test]
async fn steer_input_returns_active_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
tc.turn_metadata_state
.set_root_turn_id("active-root".to_string());
let incoming_turn_context = sess
.new_default_turn_with_sub_id("incoming".to_string())
.await;
incoming_turn_context
.turn_metadata_state
.set_root_turn_id("incoming-root".to_string());
let input = vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
client_id: None,
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let turn_id = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
Some(incoming_turn_context.turn_metadata_state.as_ref()),
)
.await
.expect("steering with matching expected turn id should succeed");
assert_eq!(turn_id, tc.sub_id);
assert_eq!(tc.turn_metadata_state.root_turn_id(), None);
assert!(sess.input_queue.has_pending_input(&sess.active_turn).await);
}
#[tokio::test]
async fn abort_empty_active_turn_preserves_pending_input() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
@@ -11150,19 +10736,16 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
/*root_turn_id*/ None,
)
.await;
sess.steer_input(
let submission = submit_steer_only(
&sess,
vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
/*incoming_turn_metadata*/ None,
&tc.sub_id,
)
.await
.expect("steered input should be accepted");
.await;
assert!(matches!(submission, TurnInputSubmission::Steered { .. }));
assert_eq!(
(sess.input_queue.get_pending_input(&sess.active_turn).await).0,
@@ -11209,19 +10792,16 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
/*root_turn_id*/ None,
)
.await;
sess.steer_input(
let submission = submit_steer_only(
&sess,
vec![UserInput::Text {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*client_user_message_id*/ None,
/*responsesapi_client_metadata*/ None,
/*incoming_turn_metadata*/ None,
&tc.sub_id,
)
.await
.expect("steered input should be accepted");
.await;
assert!(matches!(submission, TurnInputSubmission::Steered { .. }));
sess.input_queue
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
@@ -0,0 +1,118 @@
//! Handles persistent thread-settings updates shared by standalone settings
//! submissions and turn-input submission.
use super::session::Session;
use super::session::SessionSettingsUpdate;
use crate::config::ConstraintResult;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use std::sync::Arc;
/// Applies standalone thread settings and reports invalid overrides through the
/// normal event stream.
pub(super) async fn update(
session: &Arc<Session>,
submission_id: String,
overrides: ThreadSettingsOverrides,
) {
let updates = prepare_update(session, overrides).await;
if let Err(error) = apply_update(session, submission_id.clone(), updates).await {
session
.send_event_raw(Event {
id: submission_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("invalid thread settings override: {error}"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
/// Converts protocol overrides into the internal settings update shape.
pub(super) async fn prepare_update(
session: &Session,
overrides: 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,
} = overrides;
let collaboration_mode = match collaboration_mode {
Some(collaboration_mode) => collaboration_mode,
None => {
let state = session.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()
}
}
/// Applies persistent settings and emits the resulting effective snapshot.
pub(super) async fn apply_update(
session: &Session,
submission_id: String,
updates: SessionSettingsUpdate,
) -> ConstraintResult<()> {
session.update_settings(updates).await?;
emit_applied(session, submission_id).await;
Ok(())
}
/// Emits the effective thread settings after a successful update.
pub(super) async fn emit_applied(session: &Session, submission_id: String) {
let msg = applied_event(session).await;
session
.send_event_raw_without_materializing_rollout(Event {
id: submission_id,
msg,
})
.await;
}
/// Builds the effective thread-settings event used by live updates and
/// synthesized fork history.
pub(super) async fn applied_event(session: &Session) -> EventMsg {
let snapshot = {
let state = session.state.lock().await;
state.session_configuration.thread_config_snapshot()
};
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
thread_settings: snapshot.into_thread_settings_snapshot(),
})
}
+581
View File
@@ -0,0 +1,581 @@
//! Handles reply-bearing turn-input operations.
//!
//! This is the one place Core decides whether submitted input starts a turn,
//! steers an active turn, or is rejected. It replies after that decision; it
//! does not wait for user-prompt hooks, updating the in-memory model context,
//! rollout persistence, or sampling.
//!
//! Persistent thread settings apply on Started and Steered. Turn start
//! options only apply on Started.
use super::TurnInput;
use super::session::Session;
use super::session::SessionSettingsUpdate;
use super::thread_settings;
use super::turn_context::TurnContext;
use crate::state::ActiveTurn;
use crate::state::TurnState;
use crate::tasks::MailboxParentProvenance;
use crate::tasks::RegularTask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::NonSteerableTurnKind;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::turn_input::NotSubmittedReason;
use codex_protocol::turn_input::TurnInput as SubmittedTurnInput;
use codex_protocol::turn_input::TurnInputMode;
use codex_protocol::turn_input::TurnInputRequest;
use codex_protocol::turn_input::TurnInputSubmission;
use codex_protocol::turn_input::TurnStartOptions;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[cfg(test)]
#[path = "turn_input_tests.rs"]
mod tests;
/// Thread settings and start-only options prepared before Core knows whether
/// turn input starts or steers.
///
/// Thread settings are validated up front but only applied after Core accepts
/// the input. Start-only options are only consumed by `apply_started`.
struct PreparedTurnInputSettings {
thread_settings_update: Option<SessionSettingsUpdate>,
start_options: TurnStartOptions,
}
impl PreparedTurnInputSettings {
/// Validates turn-input settings without applying them so rejected input
/// leaves the thread unchanged.
async fn prepare(
session: &Session,
thread_settings: ThreadSettingsOverrides,
start_options: TurnStartOptions,
) -> CodexResult<Self> {
let thread_settings_update = if thread_settings == ThreadSettingsOverrides::default() {
None
} else {
let updates = thread_settings::prepare_update(session, thread_settings).await;
session
.preview_settings(&updates)
.await
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))?;
Some(updates)
};
Ok(Self {
thread_settings_update,
start_options,
})
}
fn required_active_final_output_json_schema(&self) -> Option<&Value> {
self.start_options.final_output_json_schema.as_ref()
}
fn would_enter_plan_mode(&self) -> bool {
self.thread_settings_update
.as_ref()
.and_then(|updates| updates.collaboration_mode.as_ref())
.is_some_and(|collaboration_mode| collaboration_mode.mode == ModeKind::Plan)
}
/// Applies persistent settings and start-only options before creating a
/// new turn context.
async fn apply_started(
self,
session: &Arc<Session>,
submission_id: String,
) -> CodexResult<Arc<TurnContext>> {
let TurnStartOptions {
final_output_json_schema,
parent_turn_id,
root_turn_id,
} = self.start_options;
let emit_thread_settings_applied = self.thread_settings_update.is_some();
let mut updates = self.thread_settings_update.unwrap_or_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 turn_context = session
.new_turn_with_sub_id(submission_id.clone(), updates)
.await?;
if emit_thread_settings_applied {
thread_settings::emit_applied(session, submission_id).await;
}
if let Some(parent_turn_id) = parent_turn_id {
turn_context
.turn_metadata_state
.set_parent_turn_id(parent_turn_id);
}
if let Some(root_turn_id) = root_turn_id {
turn_context
.turn_metadata_state
.set_root_turn_id(root_turn_id);
}
Ok(turn_context)
}
/// Applies only persistent settings after steering succeeds. The active
/// turn keeps its existing context; subsequent turns see the update.
async fn apply_steered(self, session: &Session, submission_id: String) -> CodexResult<()> {
let Some(thread_settings_update) = self.thread_settings_update else {
return Ok(());
};
thread_settings::apply_update(session, submission_id, thread_settings_update)
.await
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))
}
}
pub(super) async fn handle(
session: &Arc<Session>,
request: TurnInputRequest,
mode: TurnInputMode,
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
match mode {
TurnInputMode::StartOrSteer => start_or_steer(session, request, submission_id).await,
TurnInputMode::StartIfIdle => start_if_idle(session, request, submission_id).await,
TurnInputMode::Steer { expected_turn_id } => {
steer(session, request, expected_turn_id, submission_id).await
}
}
}
async fn start_or_steer(
session: &Arc<Session>,
request: TurnInputRequest,
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
thread_settings,
start,
additional_context,
responsesapi_client_metadata,
..
} = request;
let SubmittedTurnInput::UserInput {
content: mut items,
client_id,
} = input
else {
return Err(CodexErr::InvalidRequest(
"only user input can steer a turn".to_string(),
));
};
let can_start_root_turn = start.parent_turn_id.is_none() && start.root_turn_id.is_none();
let incoming_root_turn_id = start
.parent_turn_id
.as_ref()
.map(|_| start.root_turn_id.clone());
let settings = PreparedTurnInputSettings::prepare(session, thread_settings, start).await?;
match session
.steer_input(
&mut items,
additional_context.clone(),
/*expected_turn_id*/ None,
settings.required_active_final_output_json_schema(),
client_id.clone(),
responsesapi_client_metadata.clone(),
incoming_root_turn_id,
)
.await
{
Ok(turn_id) => {
settings.apply_steered(session, submission_id).await?;
Ok(TurnInputSubmission::Steered { turn_id })
}
Err(NotSubmittedReason::NoActiveTurn) => {
let turn_context = settings
.apply_started(session, submission_id.clone())
.await?;
if can_start_root_turn
&& !items.is_empty()
&& turn_context
.turn_metadata_state
.can_start_root_turn(&turn_context.session_source)
{
turn_context
.turn_metadata_state
.set_root_turn_id(submission_id.clone());
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
session
.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
turn_context.session_telemetry.user_prompt(&items);
let mut task_input = merge_additional_context_input(session, additional_context).await;
if !items.is_empty() {
task_input.push(TurnInput::UserInput {
content: items,
client_id,
});
}
session
.spawn_task(turn_context, task_input, RegularTask::new())
.await;
Ok(TurnInputSubmission::Started {
turn_id: submission_id,
})
}
Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
}
}
async fn start_if_idle(
session: &Arc<Session>,
request: TurnInputRequest,
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
thread_settings,
start,
additional_context,
responsesapi_client_metadata,
..
} = request;
let has_user_input = has_nonempty_user_input(&input);
let can_start_root_turn = start.parent_turn_id.is_none() && start.root_turn_id.is_none();
if session.input_queue.has_trigger_turn_mailbox_items().await {
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PendingTriggerTurn,
});
}
if !has_user_input && session.collaboration_mode().await.mode == ModeKind::Plan {
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
});
}
let turn_state = {
let mut active_turn = session.active_turn.lock().await;
if active_turn.is_some() {
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::NotIdle,
});
}
let active_turn = active_turn.get_or_insert_with(ActiveTurn::default);
Arc::clone(&active_turn.turn_state)
};
if session.input_queue.has_trigger_turn_mailbox_items().await {
session.clear_reserved_idle_turn(&turn_state).await;
session.maybe_start_turn_for_pending_work().await;
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PendingTriggerTurn,
});
}
let settings = match PreparedTurnInputSettings::prepare(session, thread_settings, start).await {
Ok(settings) => settings,
Err(error) => {
session.clear_reserved_idle_turn(&turn_state).await;
return Err(error);
}
};
// Automatic work must not use persistent settings to start a turn
// whose effective collaboration mode is Plan.
if !has_user_input && settings.would_enter_plan_mode() {
session.clear_reserved_idle_turn(&turn_state).await;
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
});
}
let turn_context = match settings.apply_started(session, submission_id.clone()).await {
Ok(turn_context) => turn_context,
Err(error) => {
session.clear_reserved_idle_turn(&turn_state).await;
return Err(error);
}
};
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
if has_user_input
&& can_start_root_turn
&& turn_context
.turn_metadata_state
.can_start_root_turn(&turn_context.session_source)
{
turn_context
.turn_metadata_state
.set_root_turn_id(submission_id.clone());
}
session
.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
let mut task_input = merge_additional_context_input(session, additional_context).await;
if has_user_input {
session.clear_connector_selection().await;
if let SubmittedTurnInput::UserInput { content, .. } = &input {
turn_context.session_telemetry.user_prompt(content);
}
task_input.push(pending_turn_input(input));
} else {
session
.input_queue
.extend_pending_input_for_turn_state(
turn_state.as_ref(),
vec![pending_turn_input(input)],
)
.await;
}
session
.start_task(
turn_context,
task_input,
RegularTask::new(),
MailboxParentProvenance::Ignore,
)
.await;
Ok(TurnInputSubmission::Started {
turn_id: submission_id,
})
}
async fn steer(
session: &Arc<Session>,
request: TurnInputRequest,
expected_turn_id: String,
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
thread_settings,
start,
additional_context,
responsesapi_client_metadata,
..
} = request;
let SubmittedTurnInput::UserInput {
content: mut items,
client_id,
} = input
else {
return Err(CodexErr::InvalidRequest(
"only user input can steer a turn".to_string(),
));
};
let incoming_root_turn_id = start
.parent_turn_id
.as_ref()
.map(|_| start.root_turn_id.clone());
let settings = PreparedTurnInputSettings::prepare(session, thread_settings, start).await?;
match session
.steer_input(
&mut items,
additional_context,
Some(expected_turn_id.as_str()),
settings.required_active_final_output_json_schema(),
client_id,
responsesapi_client_metadata,
incoming_root_turn_id,
)
.await
{
Ok(turn_id) => {
settings.apply_steered(session, submission_id).await?;
Ok(TurnInputSubmission::Steered { turn_id })
}
Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
}
}
impl Session {
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
let submission_id = Uuid::now_v7().to_string();
let submission = handle(
self,
TurnInputRequest::user_input(vec![UserInput::Text {
text,
text_elements: Vec::new(),
}]),
TurnInputMode::StartOrSteer,
submission_id.clone(),
)
.await;
match submission {
Ok(TurnInputSubmission::Started { .. } | TurnInputSubmission::Steered { .. }) => {}
Ok(TurnInputSubmission::NotSubmitted { reason }) => {
self.send_event_raw(Event {
id: submission_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("failed to submit turn input: {reason:?}"),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
Err(error) => {
self.send_event_raw(Event {
id: submission_id,
msg: EventMsg::Error(error.to_error_event(/*message_prefix*/ None)),
})
.await;
}
}
}
async fn clear_reserved_idle_turn(&self, turn_state: &Arc<tokio::sync::Mutex<TurnState>>) {
let mut active_turn_guard = self.active_turn.lock().await;
if let Some(active_turn) = active_turn_guard.as_ref()
&& active_turn.task.is_none()
&& Arc::ptr_eq(&active_turn.turn_state, turn_state)
{
*active_turn_guard = None;
}
}
/// Inject additional user input into the currently active turn.
///
/// Returns the active turn id when accepted.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
)]
#[expect(
clippy::too_many_arguments,
reason = "steering carries the accepted input plus its turn-scoped metadata"
)]
async fn steer_input(
&self,
input: &mut Vec<UserInput>,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
required_final_output_json_schema: Option<&Value>,
client_user_message_id: Option<String>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
incoming_root_turn_id: Option<Option<String>>,
) -> Result<String, NotSubmittedReason> {
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(NotSubmittedReason::NoActiveTurn);
};
let Some(active_task) = active_turn.task.as_ref() else {
return Err(NotSubmittedReason::NoActiveTurn);
};
let active_turn_id = &active_task.turn_context.sub_id;
if let Some(expected_turn_id) = expected_turn_id
&& expected_turn_id != active_turn_id
{
return Err(NotSubmittedReason::ExpectedTurnMismatch {
expected: expected_turn_id.to_string(),
actual: active_turn_id.clone(),
});
}
match active_task.kind {
crate::state::TaskKind::Regular => {}
crate::state::TaskKind::Review => {
return Err(NotSubmittedReason::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
});
}
crate::state::TaskKind::Compact => {
return Err(NotSubmittedReason::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
});
}
}
if input.is_empty() {
return Err(NotSubmittedReason::EmptyInput);
}
// Compare JSON values directly instead of serialized schema text.
// Value equality ignores object key order while preserving array and
// scalar distinctions; broader JSON Schema equivalence is out of scope.
if let Some(required_schema) = required_final_output_json_schema
&& active_task.turn_context.final_output_json_schema.as_ref() != Some(required_schema)
{
return Err(NotSubmittedReason::ActiveTurnOutputSchemaMismatch);
}
active_task
.turn_context
.session_telemetry
.user_prompt(input);
let mut pending_input = merge_additional_context_input(self, additional_context).await;
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
active_task
.turn_context
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
pending_input.push(TurnInput::UserInput {
content: std::mem::take(input),
client_id: client_user_message_id,
});
if let Some(incoming_root_turn_id) = incoming_root_turn_id
&& active_task.turn_context.turn_metadata_state.root_turn_id() != incoming_root_turn_id
{
active_task
.turn_context
.turn_metadata_state
.mark_root_turn_ambiguous();
}
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
pending_input,
)
.await;
Ok(active_turn_id.clone())
}
}
fn has_nonempty_user_input(input: &SubmittedTurnInput) -> bool {
matches!(input, SubmittedTurnInput::UserInput { content, .. } if !content.is_empty())
}
async fn merge_additional_context_input(
session: &Session,
additional_context: BTreeMap<String, AdditionalContextEntry>,
) -> Vec<TurnInput> {
let additional_context_input = {
let mut state = session.state.lock().await;
state.additional_context.merge(additional_context)
};
additional_context_input
.into_iter()
.map(ResponseItem::from)
.map(|item| session.annotate_client_response_item(item))
.map(TurnInput::ResponseItem)
.collect()
}
fn pending_turn_input(input: SubmittedTurnInput) -> TurnInput {
match input {
SubmittedTurnInput::UserInput { content, client_id } => {
TurnInput::UserInput { content, client_id }
}
SubmittedTurnInput::ResponseItem(item) => TurnInput::ResponseItem(item.into()),
SubmittedTurnInput::InterAgentCommunication(communication) => {
TurnInput::InterAgentCommunication(communication)
}
}
}
@@ -0,0 +1,433 @@
use super::*;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskResult;
use codex_protocol::AgentPath;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::turn_input::TurnInput as SubmittedTurnInput;
use codex_protocol::user_input::UserInput;
use core_test_support::test_codex::local_selections;
use pretty_assertions::assert_eq;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Copy)]
struct NeverEndingTask {
kind: TaskKind,
listen_to_cancellation_token: bool,
}
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
self.kind
}
fn span_name(&self) -> &'static str {
"session_task.turn_input_test"
}
async fn run(
self: Arc<Self>,
_session: Arc<Session>,
_ctx: Arc<TurnContext>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> SessionTaskResult {
if self.listen_to_cancellation_token {
cancellation_token.cancelled().await;
return Ok(None);
}
loop {
sleep(std::time::Duration::from_secs(60)).await;
}
}
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
async fn submit_start_only(
session: &Arc<Session>,
input: SubmittedTurnInput,
) -> TurnInputSubmission {
handle(
session,
TurnInputRequest::new(input),
TurnInputMode::StartIfIdle,
"test-submission".to_string(),
)
.await
.expect("start-only submission should be valid")
}
async fn submit_steer_only(
session: &Arc<Session>,
input: Vec<UserInput>,
expected_turn_id: &str,
) -> TurnInputSubmission {
handle(
session,
TurnInputRequest::new(SubmittedTurnInput::UserInput {
content: input,
client_id: None,
}),
TurnInputMode::Steer {
expected_turn_id: expected_turn_id.to_string(),
},
"test-submission".to_string(),
)
.await
.expect("steer-only submission should be valid")
}
#[tokio::test]
async fn accepted_input_applies_thread_settings() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
let config = session.get_config().await;
handle(
&session,
TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_selections(config.cwd.clone())),
approval_policy: Some(config.permissions.approval_policy.value()),
approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview),
sandbox_policy: Some(config.legacy_sandbox_policy()),
summary: config.model_reasoning_summary,
personality: config.personality,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: turn_context.model_info.slug.clone(),
reasoning_effort: config.model_reasoning_effort.clone(),
developer_instructions: None,
},
}),
..Default::default()
}),
TurnInputMode::StartOrSteer,
"sub-1".to_string(),
)
.await
.expect("submit user turn");
let state = session.state.lock().await;
assert_eq!(
state.session_configuration.approvals_reviewer,
codex_config::types::ApprovalsReviewer::AutoReview
);
assert!(
session.mcp_refresh.is_pending(),
"server elicitation authority changes must refresh MCP state"
);
}
#[tokio::test]
async fn start_only_rejects_active_turn_without_injecting() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
session
.spawn_task(
Arc::clone(&turn_context),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
let input = SubmittedTurnInput::ResponseItem(user_message("synthetic idle input"));
let submission = submit_start_only(&session, input).await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::NotIdle,
},
submission
);
assert_eq!(
(Vec::<TurnInput>::new(), None, None),
session
.input_queue
.get_pending_input(&session.active_turn)
.await
);
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn start_only_rejects_plan_mode_without_injecting() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = session.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = session.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let submission = submit_start_only(
&session,
SubmittedTurnInput::ResponseItem(user_message("synthetic idle input")),
)
.await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
},
submission
);
assert!(session.active_turn.lock().await.is_none());
assert_eq!(
(Vec::<TurnInput>::new(), None, None),
session
.input_queue
.get_pending_input(&session.active_turn)
.await
);
}
#[tokio::test]
async fn start_only_accepts_user_input_in_plan_mode() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = session.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = session.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
state.merge_connector_selection(["calendar".to_string()]);
}
let submission = submit_start_only(
&session,
SubmittedTurnInput::UserInput {
content: vec![UserInput::Text {
text: "queued user input".to_string(),
text_elements: Vec::new(),
}],
client_id: Some("queued-user-message".to_string()),
},
)
.await;
assert!(matches!(submission, TurnInputSubmission::Started { .. }));
assert!(
session
.state
.lock()
.await
.get_connector_selection()
.is_empty()
);
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn start_only_rejects_empty_user_input_in_plan_mode() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = session.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = session.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let submission = submit_start_only(
&session,
SubmittedTurnInput::UserInput {
content: Vec::new(),
client_id: Some("empty-queued-user-message".to_string()),
},
)
.await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
},
submission
);
assert!(session.active_turn.lock().await.is_none());
}
#[tokio::test]
async fn start_only_rejects_pending_trigger_turn_without_injecting() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
session
.input_queue
.enqueue_mailbox_communication(
InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root(),
Vec::new(),
"pending trigger".to_string(),
/*trigger_turn*/ true,
),
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await;
let submission = submit_start_only(
&session,
SubmittedTurnInput::ResponseItem(user_message("synthetic idle input")),
)
.await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PendingTriggerTurn,
},
submission
);
assert!(session.active_turn.lock().await.is_none());
assert!(session.input_queue.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn steer_only_requires_active_turn() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
let submission = submit_steer_only(
&session,
vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
"missing-turn-id",
)
.await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::NoActiveTurn,
},
submission
);
}
#[tokio::test]
async fn steer_only_enforces_expected_turn_id() {
let (session, turn_context, _rx) = make_session_and_context_with_rx().await;
session
.spawn_task(
Arc::clone(&turn_context),
vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
client_id: None,
}],
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let submission = submit_steer_only(
&session,
vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
"different-turn-id",
)
.await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::ExpectedTurnMismatch {
expected: "different-turn-id".to_string(),
actual: turn_context.sub_id.clone(),
},
},
submission
);
}
#[tokio::test]
async fn rejects_non_regular_turns() {
for (task_kind, turn_kind) in [
(TaskKind::Review, NonSteerableTurnKind::Review),
(TaskKind::Compact, NonSteerableTurnKind::Compact),
] {
let (session, incoming_turn_context, _rx) = make_session_and_context_with_rx().await;
incoming_turn_context
.turn_metadata_state
.set_root_turn_id("incoming-root".to_string());
let turn_context = session
.new_default_turn_with_sub_id("turn".to_string())
.await;
turn_context
.turn_metadata_state
.set_root_turn_id("active-root".to_string());
session
.spawn_task(
Arc::clone(&turn_context),
vec![TurnInput::UserInput {
content: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
client_id: None,
}],
NeverEndingTask {
kind: task_kind,
listen_to_cancellation_token: true,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let steer_submission = submit_steer_only(&session, steer_input.clone(), "turn").await;
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::ActiveTurnNotSteerable { turn_kind },
},
steer_submission
);
let start_or_steer_submission = handle(
&session,
TurnInputRequest::user_input(steer_input),
TurnInputMode::StartOrSteer,
"test-submission".to_string(),
)
.await
.expect("start-or-steer submission should be valid");
assert_eq!(
TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::ActiveTurnNotSteerable { turn_kind },
},
start_or_steer_submission
);
assert_eq!(
turn_context.turn_metadata_state.root_turn_id().as_deref(),
Some("active-root")
);
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
}