mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
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:
@@ -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(¤t_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(¤t_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 } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user