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

+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,