diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4f1452c9d2..f8eaa6234d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3603,7 +3603,6 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "uuid", "wiremock", ] diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 55875c81f6..00a5383602 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -688,7 +688,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { && span_attr(span, "rpc.method") == Some("turn/start") && span.span_context.trace_id() == remote_trace_id }) && spans.iter().any(|span| { - span_attr(span, "codex.op") == Some("user_input") + span_attr(span, "codex.op") == Some("turn_input") && span.span_context.trace_id() == remote_trace_id }) }) @@ -697,8 +697,8 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { let server_request_span = find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id); let core_turn_span = - find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| { - span_attr(span, "codex.op") == Some("user_input") + find_span_with_trace(&spans, remote_trace_id, "codex.op=turn_input", |span| { + span_attr(span, "codex.op") == Some("turn_input") }); assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8e9e42563e..20037bfe60 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -333,12 +333,17 @@ use codex_core::CodexThreadSettingsOverrides; use codex_core::ForkSnapshot; use codex_core::McpManager; use codex_core::NewThread; +use codex_core::NotSubmittedReason; #[cfg(test)] use codex_core::SessionMeta; use codex_core::StartThreadOptions; -use codex_core::SteerInputError; +use codex_core::SteerSubmission; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; +use codex_core::TurnInput; +use codex_core::TurnInputRequest; +use codex_core::TurnInputSubmission; +use codex_core::TurnStartOptions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 198f0e333d..20e9e1bf6e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -554,28 +554,38 @@ impl TurnRequestProcessor { .map(PermissionProfile::from_legacy_sandbox_policy) }); - // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = Op::UserInput { - items: mapped_items, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - additional_context, - thread_settings, - }; - let turn_id = thread - .submit_user_input_with_client_user_message_id( - turn_op, - self.request_trace_context(&request_id).await, - client_user_message_id, + let submission = thread + .start_or_steer_turn( + TurnInputRequest::new(TurnInput::UserInput { + content: mapped_items, + client_id: client_user_message_id, + }) + .with_thread_settings(thread_settings) + .on_start(TurnStartOptions { + final_output_json_schema: params.output_schema, + ..Default::default() + }) + .with_additional_context(additional_context) + .with_responses_metadata(params.responsesapi_client_metadata) + .with_trace(self.request_trace_context(&request_id).await), ) .await .map_err(|err| { - let error = internal_error(format!("failed to start turn: {err}")); + let error = internal_error(format!("failed to submit turn input: {err}")); self.track_error_response(&request_id, &error, /*error_type*/ None); error })?; + let (turn_id, started) = match submission { + TurnInputSubmission::Started { turn_id } => (turn_id, true), + TurnInputSubmission::Steered { turn_id } => (turn_id, false), + TurnInputSubmission::NotSubmitted { reason } => { + let error = internal_error(format!("failed to submit turn input: {reason:?}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + return Err(error); + } + }; - if turn_has_input { + if turn_has_input && started { let config_snapshot = thread.config_snapshot().await; let parent_permission_profile = parent_permission_profile_override.unwrap_or(config_snapshot.permission_profile); @@ -943,32 +953,41 @@ impl TurnRequestProcessor { .collect(); let additional_context = map_additional_context(params.additional_context); - let turn_id = thread - .steer_input( - mapped_items, - additional_context, - Some(¶ms.expected_turn_id), - params.client_user_message_id, - params.responsesapi_client_metadata, + let submission = thread + .steer_turn( + TurnInputRequest::new(TurnInput::UserInput { + content: mapped_items, + client_id: params.client_user_message_id, + }) + .with_additional_context(additional_context) + .with_responses_metadata(params.responsesapi_client_metadata), + params.expected_turn_id, ) .await .map_err(|err| { - let (message, data, error_type) = match err { - SteerInputError::NoActiveTurn(_) => ( + let error = internal_error(format!("failed to steer turn: {err}")); + self.track_error_response(request_id, &error, /*error_type*/ None); + error + })?; + let turn_id = match submission { + SteerSubmission::Steered { turn_id } => turn_id, + SteerSubmission::NotSubmitted { reason } => { + let (message, data, error_type) = match reason { + NotSubmittedReason::NoActiveTurn | NotSubmittedReason::NotIdle => ( "no active turn to steer".to_string(), None, Some(AnalyticsJsonRpcError::TurnSteer( TurnSteerRequestError::NoActiveTurn, )), ), - SteerInputError::ExpectedTurnMismatch { expected, actual } => ( + NotSubmittedReason::ExpectedTurnMismatch { expected, actual } => ( format!("expected active turn id `{expected}` but found `{actual}`"), None, Some(AnalyticsJsonRpcError::TurnSteer( TurnSteerRequestError::ExpectedTurnMismatch, )), ), - SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + NotSubmittedReason::ActiveTurnNotSteerable { turn_kind } => { let (message, turn_steer_error) = match turn_kind { codex_protocol::protocol::NonSteerableTurnKind::Review => ( "cannot steer a review turn".to_string(), @@ -1002,17 +1021,30 @@ impl TurnRequestProcessor { Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), ) } - SteerInputError::EmptyInput => ( + NotSubmittedReason::EmptyInput => ( "input must not be empty".to_string(), None, Some(AnalyticsJsonRpcError::Input(InputError::Empty)), ), + NotSubmittedReason::ActiveTurnOutputSchemaMismatch => ( + "active turn uses a different output schema".to_string(), + None, + None, + ), + NotSubmittedReason::PendingTriggerTurn | NotSubmittedReason::PlanMode => ( + "no active turn to steer".to_string(), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), + ), }; let mut error = invalid_request(message); error.data = data; self.track_error_response(request_id, &error, error_type); - error - })?; + return Err(error); + } + }; Ok(TurnSteerResponse { turn_id }) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 1346b8ba51..c9709eef06 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -81,12 +81,15 @@ use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::skip_if_wine_exec; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; +use tokio::sync::oneshot; use tokio::time::timeout; use wiremock::ResponseTemplate; @@ -288,6 +291,89 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<()> { + let (release_response, response_gate) = oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![ + vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![responses::ev_response_created("resp-1")]), + }, + StreamingSseChunk { + gate: Some(response_gate), + body: responses::sse(vec![responses::ev_completed("resp-1")]), + }, + ], + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]), + }], + ]) + .await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(server.uri()).write(codex_home.path())?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + + let ThreadStartResponse { thread, .. } = mcp + .start_thread(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let TurnStartResponse { turn: active_turn } = mcp + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "start".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let TurnStartResponse { turn: steered_turn } = mcp + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + assert_eq!(steered_turn.id, active_turn.id); + + release_response + .send(()) + .expect("active response gate should remain open"); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + #[tokio::test] async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index b5f442e429..0c5f29fe94 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -32,11 +32,17 @@ pub use codex_core::ForkSnapshot; pub use codex_core::LoadedAgentsMd; pub use codex_core::McpManager; pub use codex_core::NewThread; +pub use codex_core::NotSubmittedReason; +pub use codex_core::StartIfIdleSubmission; pub use codex_core::StartThreadOptions; pub use codex_core::StateDbHandle; +pub use codex_core::SteerSubmission; pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; -pub use codex_core::UserMessageAdmission; +pub use codex_core::TurnInput; +pub use codex_core::TurnInputRequest; +pub use codex_core::TurnInputSubmission; +pub use codex_core::TurnStartOptions; pub use codex_core::WaitForEnvironmentToolConfig; pub use codex_core::build_models_manager; pub use codex_core::config::Config; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d8c2f1f271..900e2fa6ee 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,3 +1,6 @@ +use crate::TurnInputRequest; +use crate::TurnInputSubmission; +use crate::TurnStartOptions; use crate::agent::AgentStatus; use crate::agent::registry::AgentMetadata; use crate::agent::registry::AgentRegistry; @@ -51,6 +54,7 @@ use std::sync::Arc; use std::sync::Weak; use tokio::sync::watch; use tracing::warn; +use uuid::Uuid; pub(crate) use self::execution::AgentExecutionGuard; use self::execution::AgentExecutionLimiter; @@ -173,28 +177,32 @@ impl AgentControl { root_turn_id: Option, ) -> CodexResult { let state = self.upgrade()?; - self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true) - .await?; - self.send_input_after_capacity_check(agent_id, &state, input, parent_turn_id, root_turn_id) + let thread = state.get_thread(agent_id).await?; + let result = match thread + .start_or_steer_turn( + TurnInputRequest::user_input(input).on_start(TurnStartOptions { + parent_turn_id, + root_turn_id, + ..Default::default() + }), + ) + .await + { + Ok(TurnInputSubmission::Started { turn_id }) => Ok(turn_id), + Ok(TurnInputSubmission::Steered { .. }) => { + // MAv1 exposes an opaque `submission_id` to the model. The legacy + // `Op::UserInput` path returned a fresh ID for every steer, while the + // turn-input API returns the active turn ID. Keep the tool-visible ID + // unique without adding a submission receipt back to Core. + Ok(Uuid::now_v7().to_string()) + } + Ok(TurnInputSubmission::NotSubmitted { reason }) => Err(CodexErr::InvalidRequest( + format!("turn input was not submitted: {reason:?}"), + )), + Err(err) => Err(err), + }; + self.handle_thread_request_result(agent_id, &state, result) .await - } - - async fn send_input_after_capacity_check( - &self, - agent_id: ThreadId, - state: &Arc, - input: Vec, - parent_turn_id: Option, - root_turn_id: Option, - ) -> CodexResult { - self.handle_thread_request_result( - agent_id, - state, - state - .send_op(agent_id, input.into(), parent_turn_id, root_turn_id) - .await, - ) - .await } pub(crate) async fn send_inter_agent_communication( @@ -206,8 +214,11 @@ impl AgentControl { root_turn_id: Option, ) -> CodexResult { let state = self.upgrade()?; - self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn) - .await?; + if communication.trigger_turn { + let thread = state.get_thread(agent_id).await?; + self.ensure_execution_capacity_for_turn_start(&thread) + .await?; + } self.send_inter_agent_communication_after_capacity_check( agent_id, &state, diff --git a/codex-rs/core/src/agent/control/execution.rs b/codex-rs/core/src/agent/control/execution.rs index fe10868dbb..3a52e39fec 100644 --- a/codex-rs/core/src/agent/control/execution.rs +++ b/codex-rs/core/src/agent/control/execution.rs @@ -1,10 +1,9 @@ use super::AgentControl; -use codex_protocol::ThreadId; +use crate::codex_thread::CodexThread; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::MultiAgentVersion; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use std::sync::Arc; use std::sync::OnceLock; @@ -28,25 +27,10 @@ impl Drop for AgentExecutionGuard { } impl AgentControl { - pub(crate) async fn ensure_execution_capacity_for_op( + pub(crate) async fn ensure_execution_capacity_for_turn_start( &self, - thread_id: ThreadId, - op: &Op, + thread: &CodexThread, ) -> CodexResult<()> { - self.ensure_execution_capacity_for_turn_start(thread_id, op_starts_turn(op)) - .await - } - - pub(super) async fn ensure_execution_capacity_for_turn_start( - &self, - thread_id: ThreadId, - starts_turn: bool, - ) -> CodexResult<()> { - if !starts_turn { - return Ok(()); - } - let state = self.upgrade()?; - let thread = state.get_thread(thread_id).await?; if thread.session.active_turn.lock().await.is_some() { return Ok(()); } @@ -104,11 +88,6 @@ impl AgentExecutionLimiter { } } -fn op_starts_turn(op: &Op) -> bool { - matches!(op, Op::UserInput { .. }) - || matches!(op, Op::InterAgentCommunication { communication } if communication.trigger_turn) -} - fn is_execution_limited( multi_agent_version: MultiAgentVersion, session_source: &SessionSource, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 553f9977e8..9683c4eaf9 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -544,9 +544,8 @@ impl AgentControl { match initial_input { SpawnInitialInput::UserInput(input) => { - self.send_input_after_capacity_check( + self.send_input( new_thread.thread_id, - &state, input, options.parent_turn_id, options.root_turn_id, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 9ea2e4fa37..3ae17b49ad 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -98,28 +98,6 @@ fn captured_op_matches(actual: &(ThreadId, Op), expected: &(ThreadId, Op)) -> bo return false; } match (&actual.1, &expected.1) { - ( - Op::UserInput { - items: actual_items, - final_output_json_schema: actual_schema, - responsesapi_client_metadata: actual_metadata, - additional_context: actual_context, - thread_settings: actual_settings, - }, - Op::UserInput { - items: expected_items, - final_output_json_schema: expected_schema, - responsesapi_client_metadata: expected_metadata, - additional_context: expected_context, - thread_settings: expected_settings, - }, - ) => { - actual_items == expected_items - && actual_schema == expected_schema - && actual_metadata == expected_metadata - && actual_context == expected_context - && actual_settings == expected_settings - } ( Op::InterAgentCommunication { communication: actual, @@ -314,6 +292,29 @@ fn history_contains_text<'a>( }) } +async fn wait_for_recorded_user_message(thread: &CodexThread, needle: &str) { + timeout(Duration::from_secs(5), async { + loop { + let event = thread + .next_event() + .await + .expect("event stream should stay open"); + if let EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(item), + .. + }) = event.msg + && item.content.iter().any( + |input| matches!(input, UserInput::Text { text, .. } if text.contains(needle)), + ) + { + return; + } + } + }) + .await + .expect("timed out waiting for user message recording"); +} + fn history_contains_assistant_inter_agent_communication<'a>( history_items: impl IntoIterator, expected: &InterAgentCommunication, @@ -355,6 +356,13 @@ async fn wait_for_subagent_notification(parent_thread: &Arc) -> boo } async fn persist_thread_for_tree_resume(thread: &Arc, message: &str) { + // These tests only need a durable resume fixture. Stop the child prompt + // first so this marker records directly instead of waiting behind an + // unrelated active turn. + thread + .session + .abort_all_tasks(TurnAbortReason::Interrupted) + .await; thread .inject_user_message_without_turn(message.to_string()) .await; @@ -597,7 +605,7 @@ async fn subscribe_status_updates_on_shutdown() { #[tokio::test] async fn send_input_submits_user_message() { let harness = AgentControlHarness::new().await; - let (thread_id, _thread) = harness.start_thread().await; + let (thread_id, thread) = harness.start_thread().await; let submission_id = harness .control @@ -613,25 +621,7 @@ async fn send_input_submits_user_message() { .await .expect("send_input should succeed"); assert!(!submission_id.is_empty()); - let expected = ( - thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "hello from tests".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| captured_op_matches(entry, &expected)); - assert!(captured.is_some()); + wait_for_recorded_user_message(thread.as_ref(), "hello from tests").await; } #[tokio::test] @@ -968,30 +958,12 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { ) .await .expect("spawn_agent should succeed"); - let _thread = harness + let thread = harness .manager .get_thread(thread_id) .await .expect("thread should be registered"); - let expected = ( - thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "spawned".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| captured_op_matches(entry, &expected)); - assert!(captured.is_some()); + wait_for_recorded_user_message(thread.as_ref(), "spawned").await; } #[tokio::test] @@ -1587,25 +1559,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "empty child developer instructions should preserve unrelated developer fragments" ); - let expected = ( - child_thread_id, - Op::UserInput { - items: vec![UserInput::Text { - text: "child task".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }, - ); - let captured = harness - .manager - .captured_ops() - .into_iter() - .find(|entry| captured_op_matches(entry, &expected)); - assert!(captured.is_some()); + wait_for_recorded_user_message(child_thread.as_ref(), "child task").await; let _ = harness .control diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3f74617b82..3d7dd2e128 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -34,6 +34,10 @@ use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; use codex_protocol::error::CodexErr; use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::turn_input::TurnInputMode; +use codex_protocol::turn_input::TurnInputRequest; +use codex_protocol::turn_input::TurnInputSubmission; +use codex_protocol::turn_input::TurnStartOptions; #[cfg(test)] use crate::session::completed_session_loop_termination; @@ -190,19 +194,24 @@ pub(crate) async fn run_codex_thread_one_shot( .await?; // Send the initial input to kick off the one-shot turn. - io.submit_with_trace( - Op::UserInput { - items: input, - final_output_json_schema, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }, - /*trace*/ None, - Some(parent_turn_id), - root_turn_id, - ) - .await?; + let submission = io + .submit_turn_input( + TurnInputRequest::user_input(input).on_start(TurnStartOptions { + final_output_json_schema, + parent_turn_id: Some(parent_turn_id), + root_turn_id, + }), + TurnInputMode::StartIfIdle, + ) + .await?; + match submission { + TurnInputSubmission::Started { .. } => {} + submission => { + return Err(CodexErr::InvalidRequest(format!( + "delegate turn input was not started: {submission:?}" + ))); + } + } // Bridge events so we can observe completion and shut down automatically. let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -222,7 +231,6 @@ pub(crate) async fn run_codex_thread_one_shot( .send(Submission { id: "shutdown".to_string(), op: Op::Shutdown {}, - client_user_message_id: None, trace: None, parent_turn_id: None, root_turn_id: None, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index c8cbfde2f7..ce9ded2a68 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -128,7 +128,6 @@ async fn forward_ops_preserves_submission_trace_context() { let submission = Submission { id: "sub-1".to_string(), op: Op::Interrupt, - client_user_message_id: None, trace: Some(codex_protocol::protocol::W3cTraceContext { traceparent: Some( "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9be1bbac1e..c5e4289412 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -4,10 +4,7 @@ use crate::elicitation::ElicitationRegistration; use crate::environment_config::EnvironmentConfig; use crate::session::SessionIo; use crate::session::SessionSettingsUpdate; -use crate::session::SteerInputError; -use crate::session::TurnInput; use crate::session::session::Session; -use crate::user_message_admission::UserMessageAdmission; use codex_diagnostics::Gauge; use codex_diagnostics::GaugeGuard; use codex_exec_server::SelectedCapabilityRootsStatus; @@ -29,7 +26,6 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::MultiAgentVersion; @@ -37,7 +33,6 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadSettingsSnapshot; @@ -46,7 +41,11 @@ use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::protocol::W3cTraceContext; -use codex_protocol::user_input::UserInput; +use codex_protocol::turn_input::StartIfIdleSubmission; +use codex_protocol::turn_input::SteerSubmission; +use codex_protocol::turn_input::TurnInputMode; +use codex_protocol::turn_input::TurnInputRequest; +use codex_protocol::turn_input::TurnInputSubmission; use codex_thread_store::PersistContext; use codex_thread_store::StoredThread; use codex_thread_store::StoredThreadHistory; @@ -57,8 +56,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use rmcp::model::ReadResourceRequestParams; -use std::collections::BTreeMap; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -94,46 +91,6 @@ pub struct ThreadConfigSnapshot { pub originator: String, } -/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic -/// idle turn. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TryStartTurnIfIdleRejectionReason { - /// User/client-triggered mailbox work is already queued and must take - /// priority over extension-initiated idle work. - PendingTriggerTurn, - /// The thread is in Plan mode, where idle work without user input must not - /// start a new model turn. - PlanMode, - /// Another turn or task is active, or the idle reservation was lost before - /// the automatic turn could start. - Busy, -} - -/// Rejection returned when an extension asks to start automatic idle work but -/// the thread is not eligible to run it. -#[derive(Debug)] -pub struct TryStartTurnIfIdleError { - reason: TryStartTurnIfIdleRejectionReason, - input: Vec, -} - -impl TryStartTurnIfIdleError { - pub(crate) fn new(reason: TryStartTurnIfIdleRejectionReason, input: Vec) -> Self { - Self { reason, input } - } - - /// Returns the stable reason the automatic idle turn was rejected. - pub fn reason(&self) -> TryStartTurnIfIdleRejectionReason { - self.reason - } - - /// Consumes the rejection and returns the original turn input - /// unchanged, so callers can retry, drop, or log it explicitly. - pub fn into_input(self) -> Vec { - self.input - } -} - impl ThreadConfigSnapshot { pub fn cwd(&self) -> &AbsolutePathBuf { &self.environments.legacy_fallback_cwd @@ -300,88 +257,83 @@ impl CodexThread { .await } - pub async fn submit_user_input_with_client_user_message_id( + /// Submits turn input without requiring the caller to inspect thread state. + /// + /// The result describes whether Core started a turn, steered an active + /// turn, or declined it without recording or enqueueing the input. Only + /// user input is accepted. + pub async fn start_or_steer_turn( &self, - op: Op, - trace: Option, - client_user_message_id: Option, - ) -> CodexResult { - self.session - .services - .agent_control - .ensure_execution_capacity_for_op(self.session.thread_id(), &op) - .await?; - self.io - .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) + request: TurnInputRequest, + ) -> CodexResult { + self.submit_turn_input_with_mode(request, TurnInputMode::StartOrSteer) .await } - /// Waits until Core has started a turn or steered the active turn. - pub async fn submit_user_input_and_wait_for_admission( + /// Starts a regular turn only when the thread is idle. + /// + /// Core declines the input without recording or enqueueing it when idle + /// work cannot start. + pub async fn start_turn_if_idle( &self, - op: Op, - trace: Option, - client_user_message_id: Option, - ) -> CodexResult { - let Op::UserInput { .. } = &op else { - return Err(CodexErr::InvalidRequest( - "user message admission requires user input".to_string(), - )); - }; - self.session - .services - .agent_control - .ensure_execution_capacity_for_op(self.session.thread_id(), &op) - .await?; - let submission_id = crate::session::new_submission_id(); - let (_pending_admission, admission) = self - .session - .pending_user_message_admissions - .register(submission_id.clone()); - self.io - .submit_with_id(Submission { - id: submission_id.clone(), - op, - client_user_message_id, - trace, - parent_turn_id: None, - root_turn_id: None, - }) - .await?; - tokio::select! { - biased; - result = admission => result.unwrap_or(Err(CodexErr::InternalAgentDied)), - () = self.io.session_loop_termination.clone() => { - Err(CodexErr::InternalAgentDied) - }, + request: TurnInputRequest, + ) -> CodexResult { + match self + .submit_turn_input_with_mode(request, TurnInputMode::StartIfIdle) + .await? + { + TurnInputSubmission::Started { turn_id } => { + Ok(StartIfIdleSubmission::Started { turn_id }) + } + TurnInputSubmission::NotSubmitted { reason } => { + Ok(StartIfIdleSubmission::NotSubmitted { reason }) + } + TurnInputSubmission::Steered { .. } => { + unreachable!("start-if-idle submission cannot steer") + } } } + /// Steers only if `expected_turn_id` is still the active regular turn. + pub async fn steer_turn( + &self, + request: TurnInputRequest, + expected_turn_id: String, + ) -> CodexResult { + match self + .submit_turn_input_with_mode(request, TurnInputMode::Steer { expected_turn_id }) + .await? + { + TurnInputSubmission::Steered { turn_id } => Ok(SteerSubmission::Steered { turn_id }), + TurnInputSubmission::NotSubmitted { reason } => { + Ok(SteerSubmission::NotSubmitted { reason }) + } + TurnInputSubmission::Started { .. } => { + unreachable!("steer-only submission cannot start a turn") + } + } + } + + async fn submit_turn_input_with_mode( + &self, + request: TurnInputRequest, + mode: TurnInputMode, + ) -> CodexResult { + if !matches!(mode, TurnInputMode::Steer { .. }) { + self.session + .services + .agent_control + .ensure_execution_capacity_for_turn_start(self) + .await?; + } + self.io.submit_turn_input(request, mode).await + } + /// Persist whether this thread is eligible for future memory generation. pub async fn set_thread_memory_mode(&self, mode: ThreadMemoryMode) -> anyhow::Result<()> { self.session.set_thread_memory_mode(mode).await } - pub async fn steer_input( - &self, - input: Vec, - additional_context: BTreeMap, - expected_turn_id: Option<&str>, - client_user_message_id: Option, - responsesapi_client_metadata: Option>, - ) -> Result { - self.session - .steer_input( - input, - additional_context, - expected_turn_id, - client_user_message_id, - responsesapi_client_metadata, - /*incoming_turn_metadata*/ None, - ) - .await - } - /// Injects model-visible items into the currently active turn. /// /// This is the thread-level bridge to `Session::inject_if_running` for @@ -394,26 +346,6 @@ impl CodexThread { self.session.inject_if_running(items).await } - /// Starts an automatic regular turn with response items or user input only - /// when idle work is allowed for this thread. - /// - /// This is the required entry point for extensions that want to launch - /// model-visible work from `ThreadLifecycleContributor::on_thread_idle`. - /// The call succeeds only if no user/client-triggered turn is queued and no - /// task is currently active. Work without user input is also rejected in - /// Plan mode. Active Review tasks are rejected by the active-task check - /// because Review turns are not steerable. - /// - /// On rejection, the returned error includes a stable reason and carries - /// the original `items` unchanged so the caller can decide whether to drop - /// them, retry later, or log why no automatic turn was started. - pub async fn try_start_turn_if_idle( - &self, - items: Vec, - ) -> Result<(), TryStartTurnIfIdleError> { - self.session.try_start_turn_if_idle(items).await - } - pub async fn set_app_server_client_info( &self, app_server_client_name: Option, @@ -484,13 +416,6 @@ impl CodexThread { } } - /// Use sparingly: this is intended to be removed soon. - pub async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> { - sub.parent_turn_id = None; - sub.root_turn_id = None; - self.io.submit_with_id(sub).await - } - pub async fn next_event(&self) -> CodexResult { self.io.next_event().await } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 21f903a186..45c17aff3c 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -51,6 +51,10 @@ use crate::session::turn_context::TurnContext; use codex_config::types::McpServerConfig; use codex_features::Feature; use codex_model_provider_info::ModelProviderInfo; +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_thread_store::PersistContext; use codex_utils_path_uri::PathUri; @@ -888,13 +892,9 @@ async fn run_review_on_session( .unwrap_or_else(|| params.parent_context.turn().config.cwd.clone()); let parent_turn = params.parent_context.turn(); - let submission = review_session.io.submit_with_trace( - Op::UserInput { - items: prompt_items.items, - final_output_json_schema: Some(params.schema.clone()), - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + let submission = review_session.io.submit_turn_input( + TurnInputRequest::user_input(prompt_items.items) + .with_thread_settings(codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( parent_turn_legacy_fallback_cwd, parent_turn_environments, @@ -913,11 +913,13 @@ async fn run_review_on_session( }, }), ..Default::default() - }, - }, - /*trace*/ None, - Some(parent_turn.sub_id.clone()), - parent_turn.turn_metadata_state.root_turn_id(), + }) + .on_start(TurnStartOptions { + final_output_json_schema: Some(params.schema.clone()), + parent_turn_id: Some(parent_turn.sub_id.clone()), + root_turn_id: parent_turn.turn_metadata_state.root_turn_id(), + }), + TurnInputMode::StartIfIdle, ); let submit_result = run_before_review_deadline( deadline, @@ -926,7 +928,17 @@ async fn run_review_on_session( ) .await; let child_turn_id = match submit_result { - Ok(Ok(child_turn_id)) => child_turn_id, + Ok(Ok(TurnInputSubmission::Started { turn_id })) => turn_id, + Ok(Ok(submission)) => { + return ( + GuardianReviewSessionOutcome::SessionFailed { + error: anyhow!("guardian review input was not started: {submission:?}"), + error_info: None, + }, + false, + analytics_result, + ); + } Ok(Err(err)) => { return ( GuardianReviewSessionOutcome::SessionFailed { @@ -1879,16 +1891,21 @@ mod tests { .await }); let submission = rx_sub.recv().await.expect("guardian submission"); + let id = submission.id; + let Op::TurnInput { reply, .. } = submission.op else { + panic!("expected turn-input submission"); + }; + reply + .send(Ok(TurnInputSubmission::Started { + turn_id: id.clone(), + })) + .expect("reply to guardian submission"); tx_event .send(turn_complete_event("prior-turn", Some("stale"), Some(9))) .await .expect("queue prior turn completion"); tx_event - .send(turn_complete_event( - submission.id.as_str(), - Some("fresh"), - Some(42), - )) + .send(turn_complete_event(id.as_str(), Some("fresh"), Some(42))) .await .expect("queue submitted turn completion"); @@ -1904,7 +1921,7 @@ mod tests { #[tokio::test] async fn run_review_removes_trunk_when_event_stream_is_broken() { - let (mut review_session, tx_event, _rx_sub) = test_review_session().await; + let (mut review_session, tx_event, rx_sub) = test_review_session().await; let params = test_review_params().await; review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( ¶ms.spawn_config, @@ -1915,16 +1932,26 @@ mod tests { .await .history_version(), ); - let manager = GuardianReviewSessionManager { + let manager = Arc::new(GuardianReviewSessionManager { state: Arc::new(Mutex::new(GuardianReviewSessionState { trunk: Some(Arc::new(review_session)), ephemeral_reviews: Vec::new(), })), ..Default::default() + }); + let manager_for_review = Arc::clone(&manager); + let review = tokio::spawn(async move { manager_for_review.run_review(params).await }); + let submission = rx_sub.recv().await.expect("guardian submission"); + let id = submission.id; + let Op::TurnInput { reply, .. } = submission.op else { + panic!("expected turn-input submission"); }; + reply + .send(Ok(TurnInputSubmission::Started { turn_id: id })) + .expect("reply to guardian submission"); drop(tx_event); - let (outcome, _) = manager.run_review(params).await; + let (outcome, _) = review.await.expect("review task should complete"); assert!(matches!( outcome, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 816af473d7..d1d1f5802a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -15,9 +15,14 @@ mod realtime_prompt; mod responses_metadata; mod responses_retry; pub(crate) mod session; +pub use codex_protocol::turn_input::NotSubmittedReason; +pub use codex_protocol::turn_input::StartIfIdleSubmission; +pub use codex_protocol::turn_input::SteerSubmission; +pub use codex_protocol::turn_input::TurnInput; +pub use codex_protocol::turn_input::TurnInputRequest; +pub use codex_protocol::turn_input::TurnInputSubmission; +pub use codex_protocol::turn_input::TurnStartOptions; pub use responses_metadata::CodexResponsesMetadata; -pub use session::SteerInputError; -pub use session::TurnInput; pub use turn_metadata::detached_memory_responses_metadata; mod codex_thread; mod compact_model_fallback; @@ -25,16 +30,12 @@ mod compact_remote; mod compact_remote_history; mod compact_remote_v2; mod compact_token_budget; -mod user_message_admission; pub use codex_thread::BackgroundTerminalInfo; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; -pub use codex_thread::TryStartTurnIfIdleError; -pub use codex_thread::TryStartTurnIfIdleRejectionReason; pub use environment_config::EnvironmentConfig; pub use session::turn_context::TurnContext; -pub use user_message_admission::UserMessageAdmission; mod agent; mod agent_communication; mod attestation; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 1edae00513..e3e5084055 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -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, - sub_id: String, - op: Op, - client_user_message_id: Option, - parent_turn_id: Option, - root_turn_id: Option, -) { - 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, - 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, - sub_id: String, - op: Op, - client_user_message_id: Option, - parent_turn_id: Option, - root_turn_id: Option, -) -> CodexResult { - 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::>(); - 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 } => { diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 7cb44e717f..6e51aa40e2 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -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, - input: Vec, - ) -> 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>) { - 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, diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 1291707155..1238cab10d 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -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] diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 961b46e9f0..d74773423d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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), - 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, - client_user_message_id: Option, - ) -> CodexResult { - 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 { + 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, 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, - additional_context: BTreeMap, - expected_turn_id: Option<&str>, - client_user_message_id: Option, - responsesapi_client_metadata: Option>, - incoming_turn_metadata: Option<&TurnMetadataState>, - ) -> Result { - 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::>(); - 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 diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index bfe89311fe..d3e2c930f8 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -57,8 +57,6 @@ pub(crate) struct Session { pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) async_hook_results: async_channel::Receiver, - 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, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f360f5c6f0..d9c89d7ed9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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::(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::(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::(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, + input: Vec, + 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::::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::::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::::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) diff --git a/codex-rs/core/src/session/thread_settings.rs b/codex-rs/core/src/session/thread_settings.rs new file mode 100644 index 0000000000..227b505ec1 --- /dev/null +++ b/codex-rs/core/src/session/thread_settings.rs @@ -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, + 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(), + }) +} diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs new file mode 100644 index 0000000000..1343af3ffe --- /dev/null +++ b/codex-rs/core/src/session/turn_input.rs @@ -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, + 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 { + 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, + submission_id: String, + ) -> CodexResult> { + 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, + request: TurnInputRequest, + mode: TurnInputMode, + submission_id: String, +) -> CodexResult { + 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, + request: TurnInputRequest, + submission_id: String, +) -> CodexResult { + 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, + request: TurnInputRequest, + submission_id: String, +) -> CodexResult { + 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, + request: TurnInputRequest, + expected_turn_id: String, + submission_id: String, +) -> CodexResult { + 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, 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>) { + 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, + additional_context: BTreeMap, + expected_turn_id: Option<&str>, + required_final_output_json_schema: Option<&Value>, + client_user_message_id: Option, + responsesapi_client_metadata: Option>, + incoming_root_turn_id: Option>, + ) -> Result { + 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, +) -> Vec { + 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) + } + } +} diff --git a/codex-rs/core/src/session/turn_input_tests.rs b/codex-rs/core/src/session/turn_input_tests.rs new file mode 100644 index 0000000000..4cfeefe2cb --- /dev/null +++ b/codex-rs/core/src/session/turn_input_tests.rs @@ -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, + _session: Arc, + _ctx: Arc, + _input: Vec, + 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, + 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, + input: Vec, + 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::::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::::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; + } +} diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index e46ac6c99d..1a6a9a715f 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -115,19 +115,6 @@ pub(crate) type ThreadIdGenerator = Arc ThreadId + Send + Sync>; fn capture_test_op(op: &Op) -> Option { match op { Op::Interrupt => Some(Op::Interrupt), - Op::UserInput { - items, - final_output_json_schema, - responsesapi_client_metadata, - additional_context, - thread_settings, - } => Some(Op::UserInput { - items: items.clone(), - final_output_json_schema: final_output_json_schema.clone(), - responsesapi_client_metadata: responsesapi_client_metadata.clone(), - additional_context: additional_context.clone(), - thread_settings: thread_settings.clone(), - }), Op::InterAgentCommunication { communication } => Some(Op::InterAgentCommunication { communication: communication.clone(), }), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 1de54d29f1..c18e2ae016 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -34,6 +34,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::items::TurnItem; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::BaseInstructions; use codex_protocol::models::BaseInstructionsProvenance; @@ -52,6 +53,7 @@ use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::NetworkSandboxPolicy; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; @@ -104,6 +106,27 @@ fn parse_agent_id(id: &str) -> ThreadId { ThreadId::from_string(id).expect("agent id should be valid") } +async fn wait_for_recorded_user_input(thread: &crate::CodexThread, expected: &[UserInput]) { + timeout(Duration::from_secs(5), async { + loop { + let event = thread + .next_event() + .await + .expect("event stream should stay open"); + if let EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(item), + .. + }) = event.msg + { + assert_eq!(item.content, expected); + return; + } + } + }) + .await + .expect("timed out waiting for recorded user input"); +} + fn thread_manager() -> ThreadManager { ThreadManager::with_models_provider_for_tests( CodexAuth::from_api_key("dummy"), @@ -2582,9 +2605,16 @@ async fn send_input_interrupts_before_prompt() { .iter() .filter_map(|(id, op)| (*id == agent_id).then_some(op)) .collect(); - assert_eq!(ops_for_agent.len(), 2); + assert_eq!(ops_for_agent.len(), 1); assert!(matches!(ops_for_agent[0], Op::Interrupt)); - assert!(matches!(ops_for_agent[1], Op::UserInput { .. })); + wait_for_recorded_user_input( + thread.thread.as_ref(), + &[UserInput::Text { + text: "hi".to_string(), + text_elements: Vec::new(), + }], + ) + .await; let _ = thread .thread @@ -2621,31 +2651,20 @@ async fn send_input_accepts_structured_items() { .await .expect("send_input should succeed"); - let expected_items = vec![ - UserInput::Mention { - name: "drive".to_string(), - path: "app://google_drive".to_string(), - }, - UserInput::Text { - text: "read the folder".to_string(), - text_elements: Vec::new(), - }, - ]; - assert!(manager.captured_ops().iter().any(|(id, op)| { - *id == agent_id - && matches!( - op, - Op::UserInput { - items, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context, - thread_settings, - } if items == &expected_items - && additional_context.is_empty() - && thread_settings == &Default::default() - ) - })); + wait_for_recorded_user_input( + thread.thread.as_ref(), + &[ + UserInput::Mention { + name: "drive".to_string(), + path: "app://google_drive".to_string(), + }, + UserInput::Text { + text: "read the folder".to_string(), + text_elements: Vec::new(), + }, + ], + ) + .await; let _ = thread .thread diff --git a/codex-rs/core/src/user_message_admission.rs b/codex-rs/core/src/user_message_admission.rs deleted file mode 100644 index e4def5c2e1..0000000000 --- a/codex-rs/core/src/user_message_admission.rs +++ /dev/null @@ -1,74 +0,0 @@ -use codex_protocol::error::Result as CodexResult; -use std::collections::HashMap; -use std::sync::Mutex; -use tokio::sync::oneshot; - -/// The turn that accepted a submitted user message. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UserMessageAdmission { - /// Core installed a new turn for the submitted message. - Started { turn_id: String }, - /// Core accepted the submitted message into an already-running turn. - Steered { turn_id: String }, -} - -#[derive(Default)] -pub(crate) struct PendingUserMessageAdmissions { - pending: Mutex>>>, -} - -impl PendingUserMessageAdmissions { - pub(crate) fn register( - &self, - submission_id: String, - ) -> ( - PendingUserMessageAdmissionGuard<'_>, - oneshot::Receiver>, - ) { - let (sender, receiver) = oneshot::channel(); - self.pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(submission_id.clone(), sender); - ( - PendingUserMessageAdmissionGuard { - admissions: self, - submission_id, - }, - receiver, - ) - } - - pub(crate) fn complete( - &self, - submission_id: &str, - admission: CodexResult, - ) { - let sender = self - .pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(submission_id); - if let Some(sender) = sender { - let _ = sender.send(admission); - } - } - - fn remove(&self, submission_id: &str) { - self.pending - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(submission_id); - } -} - -pub(crate) struct PendingUserMessageAdmissionGuard<'a> { - admissions: &'a PendingUserMessageAdmissions, - submission_id: String, -} - -impl Drop for PendingUserMessageAdmissionGuard<'_> { - fn drop(&mut self) { - self.admissions.remove(&self.submission_id); - } -} diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6a09c5cdea..183ac761ab 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -18,6 +18,7 @@ use codex_core::CodexThread; use codex_core::StartThreadOptions; use codex_core::ThreadManager; use codex_core::TimeProvider; +pub use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_core::shell::Shell; @@ -37,6 +38,9 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::SharedModelsManager; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID; use codex_protocol::models::PermissionProfile; @@ -44,7 +48,6 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; @@ -870,16 +873,10 @@ impl TestCodex { /// Submits a text turn without changing the current thread settings. pub async fn submit_text_turn(&self, prompt: &str) -> Result<()> { self.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: ThreadSettingsOverrides::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&self.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1007,31 +1004,28 @@ impl TestCodex { TurnEnvironmentSelections::new(self.config.cwd.clone(), environments) }); self.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.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 { environments: turn_environment_selections, approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, service_tier, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let turn_id = wait_for_event_match(&self.codex, |event| match event { diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 995d1513ef..58b5d9799a 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -4,13 +4,16 @@ use anyhow::Context; use anyhow::Result; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandStatus; -use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -21,6 +24,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; +use core_test_support::test_codex::TurnInputRequest; use core_test_support::wait_for_event; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -116,30 +120,24 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { ); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: "run the Windows smoke command".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 { + }]).with_thread_settings(ThreadSettingsOverrides { environments: Some(environments), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + })) .await?; let mut begin = None; diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index d44b4e5a23..4a9f939c6f 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -1,4 +1,5 @@ use assert_matches::assert_matches; +use codex_core::TurnInputRequest; use std::sync::Arc; use std::time::Duration; @@ -45,16 +46,10 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { // Kick off a turn that triggers the function call. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start sleep".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start sleep".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -102,16 +97,10 @@ async fn interrupt_tool_records_history_entries() { let codex = Arc::clone(&fixture.codex); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start history recording".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start history recording".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -123,16 +112,10 @@ async fn interrupt_tool_records_history_entries() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "follow up".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -206,16 +189,10 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { let codex = Arc::clone(&fixture.codex); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start interrupt marker".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -227,16 +204,10 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "follow up".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/additional_context.rs b/codex-rs/core/tests/suite/additional_context.rs index 6812be95e4..6b3859b334 100644 --- a/codex-rs/core/tests/suite/additional_context.rs +++ b/codex-rs/core/tests/suite/additional_context.rs @@ -1,10 +1,10 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_protocol::items::TurnItem; use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ItemCompletedEvent; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -36,14 +36,12 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "inspect the active tab".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "browser_info".to_string(), AdditionalContextEntry { @@ -58,9 +56,8 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re kind: AdditionalContextKind::Application, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; let user_item = wait_for_event_match(&test.codex, |event| match event { @@ -134,13 +131,7 @@ async fn external_context_like_user_text_remains_a_user_message_item() -> Result }; test.codex - .submit(Op::UserInput { - items: vec![user_input.clone()], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::new(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![user_input.clone()])) .await?; let user_item = wait_for_event_match(&test.codex, |event| match event { @@ -179,14 +170,12 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "inspect context".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "browser_info".to_string(), AdditionalContextEntry { @@ -201,9 +190,8 @@ async fn additional_context_trust_controls_message_role() -> Result<()> { kind: AdditionalContextKind::Application, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -259,16 +247,13 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re )]); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: additional_context.clone(), - thread_settings: Default::default(), - }) + }]) + .with_additional_context(additional_context.clone()), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -276,16 +261,13 @@ async fn additional_context_is_deduplicated_between_turns_while_retained() -> Re .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context, - thread_settings: Default::default(), - }) + }]) + .with_additional_context(additional_context), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -337,14 +319,12 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), AdditionalContextEntry { @@ -359,9 +339,8 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( kind: AdditionalContextKind::Untrusted, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -369,14 +348,12 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), AdditionalContextEntry { @@ -391,9 +368,8 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( kind: AdditionalContextKind::Untrusted, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -401,14 +377,12 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), AdditionalContextEntry { @@ -430,9 +404,8 @@ async fn additional_context_removes_one_value_while_adding_another() -> Result<( kind: AdditionalContextKind::Untrusted, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) @@ -497,14 +470,12 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< format!("{long_automation_value}"); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "summarize context".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::from([ + }]) + .with_additional_context(BTreeMap::from([ ( "automation_info".to_string(), AdditionalContextEntry { @@ -519,9 +490,8 @@ async fn additional_context_values_are_truncated_before_model_input() -> Result< kind: AdditionalContextKind::Untrusted, }, ), - ]), - thread_settings: Default::default(), - }) + ])), + ) .await?; wait_for_event_match(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index ddb719cd22..d32f49b1b4 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -1,9 +1,9 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::WebSocketConnectionConfig; @@ -48,19 +48,16 @@ async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Res test.submit_turn("non-lite turn").await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "lite turn".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 { model: Some("gpt-5.4".to_string()), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 9007591390..ad7e76cf71 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -2,6 +2,7 @@ use anyhow::Result; use anyhow::anyhow; use codex_core::ForkSnapshot; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; @@ -149,16 +150,10 @@ fn assert_single_instruction_fragment(request: &responses::ResponsesRequest, exp async fn submit_thread_turn(thread: &Arc, prompt: &str) -> Result<()> { thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; Ok(()) @@ -496,16 +491,10 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { no_environment_thread .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "inspect global instructions without an environment".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect global instructions without an environment".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&no_environment_thread.thread, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 34e96f5099..ba384c9302 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1,6 +1,7 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_core::TurnInputRequest; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; @@ -19,6 +20,9 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -28,8 +32,8 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] @@ -99,29 +103,26 @@ async fn submit_without_wait_with_turn_permissions( let test = harness.test(); let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } @@ -1888,15 +1889,12 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re }, ]; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "apply matching patches to local and remote environments".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), environments, @@ -1904,17 +1902,17 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut last_diff = None; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 75a9fabc76..74d6c4cdac 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -4,6 +4,7 @@ use anyhow::Context; use anyhow::Result; use codex_config::types::ApprovalsReviewer; use codex_core::CodexThread; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::config::ThreadStoreConfig; use codex_core::sandboxing::SandboxPermissions; @@ -12,6 +13,9 @@ use codex_models_manager::bundled_models_response; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -28,6 +32,7 @@ use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_apply_patch_custom_tool_call; @@ -658,30 +663,27 @@ async fn submit_turn( let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) @@ -695,29 +697,26 @@ async fn submit_turn_preserving_active_permission_profile( let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) @@ -2699,31 +2698,28 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run nested env zsh script through python".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let approval_event = wait_for_event_with_timeout( @@ -2843,31 +2839,28 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run allowed touch under zsh fork".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_completion_without_approval(&test).await; @@ -3760,31 +3753,28 @@ allow_local_binding = true turn_permission_fields(permission_profile, test.config.cwd.as_path()); let session_model = test.session_configured.model.clone(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "deny-read network retry".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), permission_profile: turn_permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); diff --git a/codex-rs/core/tests/suite/audio_truncation.rs b/codex-rs/core/tests/suite/audio_truncation.rs index 79732b0230..3e24f5939a 100644 --- a/codex-rs/core/tests/suite/audio_truncation.rs +++ b/codex-rs/core/tests/suite/audio_truncation.rs @@ -2,6 +2,7 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; @@ -105,16 +106,10 @@ async fn dynamic_tool_audio_exceeding_the_output_budget_is_omitted() -> Result<( test.session_configured = new_thread.session_configured; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Return a recording".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Return a recording".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::DynamicToolCallRequest(_)) diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 5823659398..513a7b54c8 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use std::time::Duration; use anyhow::Result; @@ -19,6 +20,7 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; @@ -165,7 +167,7 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(model.to_string()), ..Default::default() }, @@ -176,22 +178,19 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run the Guardian model override check".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; let permissions_request = wait_for_event(&codex, |event| { diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 7e09db057f..643ff5d91f 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_login::CodexAuth; use codex_models_manager::manager::RefreshStrategy; @@ -9,7 +10,6 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::PermissionMessages; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; @@ -82,16 +82,10 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul ) .await?; test.codex - .submit(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: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 6f6f30962d..4a86df29ca 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -5,6 +5,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TurnInputRequest; use codex_core::X_CODEX_ROUTING_HINT_HEADER; use codex_core::resolve_installation_id; use codex_core::thread_store_from_config; @@ -53,6 +54,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TestCodexResponsesRequestKind; use core_test_support::apps_test_server::AppsTestServer; @@ -318,16 +320,10 @@ async fn non_openai_responses_requests_include_item_ids_without_passthrough_meta .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -379,15 +375,9 @@ async fn sends_audio_urls_to_responses() { let audio_url = "data:audio/wav;base64,AAAA"; codex - .submit(Op::UserInput { - items: vec![UserInput::Audio { - audio_url: audio_url.to_string(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Audio { + audio_url: audio_url.to_string(), + }])) .await .unwrap(); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -430,15 +420,9 @@ async fn sends_local_audio_to_responses() -> anyhow::Result<()> { std::fs::write(&audio_path, b"audio")?; codex - .submit(Op::UserInput { - items: vec![UserInput::LocalAudio { - path: audio_path.clone(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::LocalAudio { + path: audio_path.clone(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -953,16 +937,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // 2) Submit new input; the request body must include the prior items, then initial context, then new user input. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1347,16 +1325,10 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { let expected_thread_id = test.session_configured.thread_id; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1613,16 +1585,10 @@ async fn includes_base_instructions_override_in_request() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1670,16 +1636,10 @@ async fn chatgpt_auth_sends_correct_request() { let expected_thread_id = test.session_configured.thread_id; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1809,16 +1769,10 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .expect("create new conversation"); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1848,16 +1802,10 @@ async fn includes_user_instructions_message_in_request() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1942,16 +1890,10 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2005,16 +1947,10 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2064,16 +2000,10 @@ async fn omits_apps_guidance_when_configured_off() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2141,16 +2071,10 @@ async fn omits_apps_guidance_when_orchestrator_mcp_is_disabled() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2223,16 +2147,10 @@ async fn omits_environment_context_when_configured_off() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2265,16 +2183,10 @@ async fn includes_configured_max_effort_in_request() -> anyhow::Result<()> { .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2307,16 +2219,10 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex().with_model("gpt-5.4").build(&server).await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2350,16 +2256,10 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info let TestCodex { codex, .. } = test_codex().with_model("gpt-5.4").build(&server).await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2401,15 +2301,12 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re }; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), @@ -2420,8 +2317,8 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re ), collaboration_mode: Some(collaboration_mode), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2460,16 +2357,10 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2535,16 +2426,10 @@ async fn model_without_summary_parameter_support_omits_configured_summary() -> a .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2582,16 +2467,10 @@ async fn sequential_cutoff_is_omitted_for_non_openai_provider() -> anyhow::Resul .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2626,16 +2505,10 @@ async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls( .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2687,30 +2560,27 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(config.cwd.clone())), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_configured.model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await .unwrap(); @@ -2747,16 +2617,10 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2805,16 +2669,10 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2844,16 +2702,10 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex().with_model("gpt-5.4").build(&server).await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2892,16 +2744,10 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2939,16 +2785,10 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2993,16 +2833,10 @@ async fn includes_developer_instructions_message_in_request() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -3320,16 +3154,10 @@ async fn token_count_includes_rate_limits_snapshot() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -3475,17 +3303,11 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "rate_limit_reached_type": "workspace_member_usage_limit_reached" }); - let submission_id = codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + let submission = codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .expect("submission should succeed while emitting usage limit error events"); @@ -3509,7 +3331,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { }; assert!( error_event.message.contains("spend cap set by the owner"), - "unexpected error message for submission {submission_id}: {}", + "unexpected error message for submission {submission:?}: {}", error_event.message ); @@ -3553,31 +3375,19 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "seed turn".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "seed turn".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "trigger context window".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "trigger context window".into(), + text_elements: Vec::new(), + }])) .await?; let token_event = wait_for_event(&codex, |event| { @@ -3653,16 +3463,10 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res .build(&server) .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "trigger incomplete".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "trigger incomplete".into(), + text_elements: Vec::new(), + }])) .await?; let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; @@ -3765,16 +3569,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -3855,16 +3653,10 @@ async fn env_var_overrides_loaded_auth() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -3921,48 +3713,30 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { // Turn 1: user sends U1; wait for completion. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "U1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "U1".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // Turn 2: user sends U2; wait for completion. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "U2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "U2".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // Turn 3: user sends U3; wait for completion. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "U3".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "U3".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1770da1973..601389938d 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -6,6 +6,7 @@ use codex_core::ModelClient; use codex_core::ModelClientSession; use codex_core::Prompt; use codex_core::ResponseEvent; +use codex_core::TurnInputRequest; use codex_core::X_CODEX_ROUTING_HINT_HEADER; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::test_support::with_parent_turn; @@ -33,7 +34,6 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; @@ -1609,18 +1609,12 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { .await .expect("build websocket codex"); - let submission_id = test + let submission = test .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .expect("submission should succeed while emitting usage limit error events"); @@ -1667,7 +1661,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { }; assert!( error_event.message.contains("spend cap set by the owner"), - "unexpected error message for submission {submission_id}: {}", + "unexpected error message for submission {submission:?}: {}", error_event.message ); @@ -1704,18 +1698,12 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { .await .expect("build websocket codex"); - let submission_id = test + let submission = test .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .expect("submission should succeed while emitting invalid request events"); @@ -1728,7 +1716,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { .message .to_lowercase() .contains("does not support image inputs"), - "unexpected error message for submission {submission_id}: {}", + "unexpected error message for submission {submission:?}: {}", error_event.message ); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index bb7688ebac..8935391c36 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -6,6 +6,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::CurrentTimeReminderConfig; use codex_extension_api::ExtensionData; @@ -20,6 +21,9 @@ use codex_features::CurrentTimeSource; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -33,6 +37,7 @@ use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_tools::FreeformTool; use codex_tools::FreeformToolFormat; @@ -283,16 +288,10 @@ async fn run_unavailable_code_mode_turn( .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "list available tools".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "list available tools".to_string(), + text_elements: Vec::new(), + }])) .await?; let mut warnings = Vec::new(); @@ -2141,16 +2140,10 @@ async fn code_mode_wait_timeout_reconnects_on_next_exec() -> Result<()> { .completion; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "wait for the stalled cell".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "wait for the stalled cell".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event_match(&test.codex, |event| match event { EventMsg::RawResponseItem(raw) => match &raw.item { @@ -3387,16 +3380,10 @@ async fn code_mode_interrupt_terminates_active_cells_and_nested_tools() -> Resul .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start a long-running nested tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start a long-running nested tool".to_string(), + text_elements: Vec::new(), + }])) .await?; let active_cell_id = tokio::time::timeout(Duration::from_secs(10), started_rx).await??; @@ -5006,15 +4993,12 @@ text(JSON.stringify({ let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "inspect and call normalized dynamic tools".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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( cwd, Vec::new(), @@ -5022,17 +5006,17 @@ text(JSON.stringify({ approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let turn_id = wait_for_event_match(&test.codex, |event| match event { @@ -5229,15 +5213,12 @@ text( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "use exec to inspect and call hidden tools".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( cwd, Vec::new(), @@ -5245,17 +5226,17 @@ text( approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let turn_id = wait_for_event_match(&test.codex, |event| match event { diff --git a/codex-rs/core/tests/suite/code_mode_elicitation.rs b/codex-rs/core/tests/suite/code_mode_elicitation.rs index 7df6cf2d4b..5b79bf6a5b 100644 --- a/codex-rs/core/tests/suite/code_mode_elicitation.rs +++ b/codex-rs/core/tests/suite/code_mode_elicitation.rs @@ -3,13 +3,18 @@ use std::time::Duration; use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; @@ -111,29 +116,26 @@ async fn submit_turn(test: &TestCodex, permission_profile: PermissionProfile) -> let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a code-mode tool that needs user input".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(wait_for_event_match(&test.codex, |event| match event { diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 63d6435c10..4b53f7ed26 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; @@ -8,6 +9,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; use core_test_support::PathExt; @@ -410,20 +412,17 @@ async fn guardian_delegate_rejects_escalation_requests_without_prompting() { .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "Trigger Guardian review of an escalated command".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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), ..Default::default() - }, - }) + }), + ) .await .expect("submit guardian-reviewed command"); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index a1a6c36460..7a6193a112 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -9,7 +10,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -113,16 +114,10 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { let test = test_codex().build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -173,7 +168,7 @@ async fn catalog_collaboration_messages_track_mode_changes() -> Result<()> { core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_for_model( ModeKind::Default, model_slug, @@ -197,7 +192,7 @@ async fn catalog_collaboration_messages_track_mode_changes() -> Result<()> { core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_for_model( ModeKind::Plan, model_slug, @@ -258,7 +253,7 @@ async fn missing_catalog_and_legacy_collaboration_message_clears_prior_instructi core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_for_model( ModeKind::Default, model_slug, @@ -272,7 +267,7 @@ async fn missing_catalog_and_legacy_collaboration_message_clears_prior_instructi core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_for_model( ModeKind::Plan, model_slug, @@ -328,7 +323,7 @@ async fn model_change_appends_new_catalog_collaboration_message() -> Result<()> core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_for_model( ModeKind::Default, first_slug, @@ -342,7 +337,7 @@ async fn model_change_appends_new_catalog_collaboration_message() -> Result<()> core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(second_slug.to_string()), ..Default::default() }, @@ -380,7 +375,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), ..Default::default() }, @@ -388,16 +383,10 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -425,15 +414,12 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), @@ -444,8 +430,8 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { ), collaboration_mode: Some(collaboration_mode), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -475,15 +461,12 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { let collaboration_mode = collab_mode_with_instructions(Some("turn instructions")); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), @@ -494,8 +477,8 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { ), collaboration_mode: Some(collaboration_mode), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -526,7 +509,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), ..Default::default() }, @@ -534,16 +517,10 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -574,7 +551,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(base_mode), ..Default::default() }, @@ -582,15 +559,12 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), @@ -601,8 +575,8 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu ), collaboration_mode: Some(turn_mode), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -638,7 +612,7 @@ async fn collaboration_mode_update_ignores_instruction_changes_within_same_mode( core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), ..Default::default() }, @@ -646,22 +620,16 @@ async fn collaboration_mode_update_ignores_instruction_changes_within_same_mode( .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), ..Default::default() }, @@ -669,16 +637,10 @@ async fn collaboration_mode_update_ignores_instruction_changes_within_same_mode( .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -713,7 +675,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), ..Default::default() }, @@ -721,22 +683,16 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), ..Default::default() }, @@ -744,16 +700,10 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -787,7 +737,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(default_text), @@ -798,22 +748,16 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Plan, Some(plan_text), @@ -824,16 +768,10 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -868,7 +806,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), @@ -879,22 +817,16 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), @@ -905,16 +837,10 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -948,7 +874,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { let collab_text = "resume instructions"; core_test_support::submit_thread_settings( &initial.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), ..Default::default() }, @@ -957,32 +883,20 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let resumed = builder.restart(&server, &initial).await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after resume".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1010,7 +924,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -1025,16 +939,10 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 99de88ae02..02e555ced0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ use anyhow::Result; use anyhow::anyhow; +use codex_core::TurnInputRequest; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; @@ -11,6 +12,9 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::AutoCompactTokenLimitScope; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelInfo; @@ -22,6 +26,7 @@ use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -102,33 +107,32 @@ fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { ) } -fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: String) -> Op { +fn disabled_permission_user_turn( + text: impl Into, + cwd: PathBuf, + model: String, +) -> TurnInputRequest { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); - Op::UserInput { - items: vec![UserInput::Text { - text: text.into(), - 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(cwd.abs())), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(cwd.abs())), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }) } fn auto_summary(summary: &str) -> String { @@ -528,16 +532,10 @@ async fn summarize_context_three_requests_and_instructions() { // 1) Normal user input – should hit server once. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello world".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello world".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -553,16 +551,10 @@ async fn summarize_context_three_requests_and_instructions() { // 3) Next user input – third hit; history should include only the summary. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: THIRD_USER_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: THIRD_USER_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -723,16 +715,10 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { let codex = test.codex.clone(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello before blocked compact".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello before blocked compact".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit first user turn"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -796,16 +782,10 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { let codex = test.codex.clone(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello before matched compact".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello before matched compact".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit first user turn"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -866,16 +846,10 @@ async fn manual_compact_uses_custom_prompt() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit first user turn"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1013,16 +987,10 @@ async fn manual_compact_emits_context_compaction_items() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "manual compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "manual compact".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1179,16 +1147,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // Start the conversation with the user message codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user_message.into(), + text_elements: Vec::new(), + }])) .await .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1654,48 +1616,30 @@ async fn auto_compact_runs_after_token_limit_hit() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: FIRST_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FIRST_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: SECOND_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: SECOND_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: POST_AUTO_USER_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1857,16 +1801,10 @@ async fn auto_compact_emits_context_compaction_items() { for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1938,46 +1876,28 @@ async fn auto_compact_starts_after_turn_started() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: FIRST_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FIRST_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: SECOND_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: SECOND_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: POST_AUTO_USER_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -2079,7 +1999,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( follow_up_user, resumed.cwd.path().to_path_buf(), resumed.session_configured.model.clone(), @@ -2158,7 +2078,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), previous_model.to_string(), @@ -2171,7 +2091,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -2260,7 +2180,7 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), previous_model.to_string(), @@ -2273,7 +2193,7 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -2368,7 +2288,7 @@ async fn pre_sampling_compact_falls_back_from_retired_previous_model_after_renam initial .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", initial.cwd.path().to_path_buf(), retired_model.to_string(), @@ -2405,7 +2325,7 @@ async fn pre_sampling_compact_falls_back_from_retired_previous_model_after_renam resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", resumed.cwd.path().to_path_buf(), renamed_model.to_string(), @@ -2507,7 +2427,7 @@ async fn pre_sampling_compact_falls_back_when_previous_model_is_not_found() { initial .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", initial.cwd.path().to_path_buf(), retired_model.to_string(), @@ -2545,7 +2465,7 @@ async fn pre_sampling_compact_falls_back_when_previous_model_is_not_found() { resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", resumed.cwd.path().to_path_buf(), renamed_model.to_string(), @@ -2640,7 +2560,7 @@ async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), retired_model.to_string(), @@ -2653,7 +2573,7 @@ async fn pre_sampling_compact_falls_back_after_previous_model_invalid_request_on .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -2745,7 +2665,7 @@ async fn pre_sampling_legacy_remote_compact_falls_back_after_previous_model_inva let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), retired_model.to_string(), @@ -2758,7 +2678,7 @@ async fn pre_sampling_legacy_remote_compact_falls_back_after_previous_model_inva .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -2835,7 +2755,7 @@ async fn pre_sampling_compact_keeps_unknown_previous_model_for_api_key_auth_and_ let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), previous_model.to_string(), @@ -2848,7 +2768,7 @@ async fn pre_sampling_compact_keeps_unknown_previous_model_for_api_key_auth_and_ .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -2925,7 +2845,7 @@ async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before hash", test.cwd.path().to_path_buf(), model_without_hash.to_string(), @@ -2938,7 +2858,7 @@ async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "hash introduced", test.cwd.path().to_path_buf(), model_with_hash.to_string(), @@ -2951,7 +2871,7 @@ async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "hash removed", test.cwd.path().to_path_buf(), next_model_without_hash.to_string(), @@ -3034,7 +2954,7 @@ async fn body_after_prefix_model_switch_budget_compacts_with_next_model() { let test = builder.build(&server).await.expect("build test codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before switch", test.cwd.path().to_path_buf(), previous_model.to_string(), @@ -3047,7 +2967,7 @@ async fn body_after_prefix_model_switch_budget_compacts_with_next_model() { .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after switch", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -3134,7 +3054,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { initial .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before resume", initial.cwd.path().to_path_buf(), previous_model.to_string(), @@ -3171,7 +3091,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after resume", resumed.cwd.path().to_path_buf(), next_model.to_string(), @@ -3255,7 +3175,7 @@ async fn pre_sampling_compact_recovers_comp_hash_after_resume() { initial .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before resume", initial.cwd.path().to_path_buf(), previous_model.to_string(), @@ -3302,7 +3222,7 @@ async fn pre_sampling_compact_recovers_comp_hash_after_resume() { resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after resume", resumed.cwd.path().to_path_buf(), next_model.to_string(), @@ -3382,7 +3302,7 @@ async fn pre_sampling_compact_skips_missing_comp_hash_after_resume() { initial .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "before resume", initial.cwd.path().to_path_buf(), previous_model.to_string(), @@ -3427,7 +3347,7 @@ async fn pre_sampling_compact_skips_missing_comp_hash_after_resume() { resumed .codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "after resume", resumed.cwd.path().to_path_buf(), next_model.to_string(), @@ -3522,46 +3442,28 @@ async fn auto_compact_persists_rollout_entries() { let session_configured = test.session_configured; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: FIRST_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FIRST_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: SECOND_AUTO_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: SECOND_AUTO_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: POST_AUTO_USER_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3636,16 +3538,10 @@ async fn manual_compact_retries_after_context_window_error() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first turn".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first turn".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3740,16 +3636,10 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first turn".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first turn".into(), + text_elements: Vec::new(), + }])) .await .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3835,16 +3725,10 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3853,16 +3737,10 @@ async fn manual_compact_twice_preserves_latest_user_messages() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3871,16 +3749,10 @@ async fn manual_compact_twice_preserves_latest_user_messages() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: final_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: final_user_message.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4079,16 +3951,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let mut auto_compact_lifecycle_events = Vec::new(); for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -4184,16 +4050,10 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { let codex = builder.build(&server).await.unwrap().codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: FUNCTION_CALL_LIMIT_MSG.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FUNCTION_CALL_LIMIT_MSG.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -4619,16 +4479,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { .enumerate() { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4742,16 +4596,10 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { for user in [first_user, second_user, third_user] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }])) .await .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4804,23 +4652,17 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess for user in ["USER_ONE", "USER_TWO"] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; } core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(local_selections( test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), )), @@ -4832,22 +4674,16 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); codex - .submit(Op::UserInput { - items: vec![ - UserInput::Image { - image_url: image_url.clone(), - detail: None, - }, - UserInput::Text { - text: "USER_THREE".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Image { + image_url: image_url.clone(), + detail: None, + }, + UserInput::Text { + text: "USER_THREE".to_string(), + text_elements: Vec::new(), + }, + ])) .await .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4930,7 +4766,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch .expect("build codex"); test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "BEFORE_SWITCH_USER", test.cwd.path().to_path_buf(), previous_model.to_string(), @@ -4943,7 +4779,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch .await; test.codex - .submit(disabled_permission_user_turn( + .start_or_steer_turn(disabled_permission_user_turn( "AFTER_SWITCH_USER", test.cwd.path().to_path_buf(), next_model.to_string(), @@ -5027,31 +4863,19 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit first user"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit second user"); let error_message = wait_for_event_match(&codex, |event| match event { @@ -5115,16 +4939,10 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit follow-up user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 7d7acca203..629dff8768 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -7,6 +7,7 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::X_CODEX_ROUTING_HINT_HEADER; use codex_core::compact::SUMMARY_PREFIX; use codex_features::Feature; @@ -46,6 +47,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::apps_test_server::configure_search_capable_model; @@ -669,16 +671,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -686,16 +682,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -901,16 +891,10 @@ async fn remote_compact_uses_agent_identity_assertion() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1015,84 +999,54 @@ async fn assert_remote_manual_compact_request_parity( .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "TURN_ONE_USER".to_string(), + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "TURN_ONE_USER".to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Text { + text: "TURN_TWO_PREFIX".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - wait_for_turn_complete(&codex).await; - - codex - .submit(Op::UserInput { - items: vec![ - UserInput::Text { - text: "TURN_TWO_PREFIX".to_string(), - text_elements: Vec::new(), - }, - UserInput::Text { - text: "TURN_TWO_SUFFIX".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - wait_for_turn_complete(&codex).await; - - codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "TURN_THREE_TOOL_USER".to_string(), + }, + UserInput::Text { + text: "TURN_TWO_SUFFIX".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + }, + ])) .await?; wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![ - UserInput::Image { - image_url, - detail: None, - }, - UserInput::Text { - text: "TURN_FOUR_IMAGE_USER".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "TURN_THREE_TOOL_USER".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "TURN_FIVE_USER".to_string(), + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Image { + image_url, + detail: None, + }, + UserInput::Text { + text: "TURN_FOUR_IMAGE_USER".to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + }, + ])) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "TURN_FIVE_USER".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1323,16 +1277,10 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1365,16 +1313,10 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1577,16 +1519,10 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1594,16 +1530,10 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1680,16 +1610,10 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1697,16 +1621,10 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - wait_for_turn_complete(&codex).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1789,16 +1707,10 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -1891,16 +1803,10 @@ async fn remote_compact_does_not_charge_inline_audio_payload_as_text() -> Result test.session_configured = new_thread.session_configured; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Return a recording".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Return a recording".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::DynamicToolCallRequest(_)) @@ -1981,16 +1887,10 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello remote compact".into(), + text_elements: Vec::new(), + }])) .await?; let message = wait_for_event_match(&codex, |event| match event { @@ -2118,30 +2018,18 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -2246,30 +2134,18 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -2372,30 +2248,18 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -2406,16 +2270,10 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "turn that triggers auto compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "turn that triggers auto compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; assert_eq!( @@ -2537,16 +2395,10 @@ async fn remote_compact_trims_tool_search_output_to_empty_tools_array() -> Resul let codex = test.codex.clone(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Find the oversized deferred tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Find the oversized deferred tool".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -2617,30 +2469,18 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "turn that exceeds token threshold".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "turn that exceeds token threshold".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "turn that triggers auto compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "turn that triggers auto compact".into(), + text_elements: Vec::new(), + }])) .await?; let error_message = wait_for_event_match(&codex, |event| match event { @@ -2725,16 +2565,10 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result .await; baseline_codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&baseline_codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2742,16 +2576,10 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result .await; baseline_codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&baseline_codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2833,16 +2661,10 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result .await; override_codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: first_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: first_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&override_codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2850,16 +2672,10 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result .await; override_codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: second_user_message.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: second_user_message.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&override_codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -2926,16 +2742,10 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "manual remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "manual remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -3007,16 +2817,10 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "manual remote compact".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "manual remote compact".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -3094,16 +2898,10 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "needs compaction".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "needs compaction".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3242,16 +3040,10 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start remote compact flow".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start remote compact flow".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3260,16 +3052,10 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact in same session".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact in same session".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3285,16 +3071,10 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after resume".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3384,16 +3164,10 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "start remote compact flow".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "start remote compact flow".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3401,16 +3175,10 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after compact in same session".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after compact in same session".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3473,30 +3241,18 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta start_realtime_conversation(test.codex.as_ref()).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3554,16 +3310,10 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - start_realtime_conversation(test.codex.as_ref()).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3685,16 +3435,10 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() start_realtime_conversation(test.codex.as_ref()).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3702,16 +3446,10 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3785,32 +3523,20 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real start_realtime_conversation(test.codex.as_ref()).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "SETUP_USER".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "SETUP_USER".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; close_realtime_conversation(test.codex.as_ref()).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -3894,7 +3620,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us if user == "USER_THREE" { core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(local_selections( test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs(), )), @@ -3904,16 +3630,10 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us .await?; } codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; } @@ -3991,38 +3711,26 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "BEFORE_SWITCH_USER".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "BEFORE_SWITCH_USER".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(next_model.to_string()), ..Default::default() }, ) .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "AFTER_SWITCH_USER".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "AFTER_SWITCH_USER".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4132,30 +3840,18 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await?; let error_message = wait_for_event_match(&codex, |event| match event { EventMsg::Error(err) => Some(err.message.clone()), @@ -4238,16 +3934,10 @@ async fn remote_pre_turn_compact_response_seeds_turn_state() -> Result<()> { // Phase 2: the next turn compacts before sampling and establishes turn state. for text in ["BEFORE_COMPACT_USER", "AFTER_COMPACT_USER"] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; } @@ -4314,16 +4004,10 @@ async fn remote_mid_turn_compact_v1_sends_turn_state_over_http() -> Result<()> { // Phase 1: sampling mints state and crosses the token limit with a pending tool follow-up. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "RUN_WITH_MID_TURN_COMPACT".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "RUN_WITH_MID_TURN_COMPACT".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -4399,16 +4083,10 @@ async fn remote_mid_turn_compact_v2_sends_turn_state_over_http() -> Result<()> { // Phase 1: sampling mints state and schedules inline v2 compaction. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "RUN_WITH_MID_TURN_COMPACT_V2".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "RUN_WITH_MID_TURN_COMPACT_V2".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&codex).await; @@ -4502,16 +4180,10 @@ async fn remote_mid_turn_compact_v2_sends_turn_state_over_websocket() -> Result< // Phase 1: startup prewarm stays empty, then WebSocket sampling mints state and schedules // inline v2 compaction. test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "RUN_WITH_WS_MID_TURN_COMPACT_V2".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "RUN_WITH_WS_MID_TURN_COMPACT_V2".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_turn_complete(&test.codex).await; @@ -4580,16 +4252,10 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4660,16 +4326,10 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4746,16 +4406,10 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4763,16 +4417,10 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_TWO".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -4845,16 +4493,10 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "USER_ONE".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index e243ba27a5..9c74360069 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -605,13 +606,7 @@ async fn capture_from_requests( async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec) -> Result<()> { codex - .submit(Op::UserInput { - items, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(items)) .await?; wait_for_turn_complete(codex).await; Ok(()) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 0e01ce6726..8a54d8a48c 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::TurnInputRequest; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -26,6 +27,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; @@ -636,7 +638,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { std::fs::create_dir_all(&override_cwd)?; core_test_support::submit_thread_settings( &conversation, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(local_selections(override_cwd.clone())), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, @@ -859,16 +861,10 @@ async fn start_test_conversation( async fn user_turn(conversation: &Arc, text: &str) { conversation - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }])) .await .expect("submit user turn"); wait_for_event(conversation, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index 4e1da82e5f..44fe2ad6c1 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -12,6 +12,7 @@ use chrono::Utc; use codex_core::SleepFuture; use codex_core::TimeFuture; use codex_core::TimeProvider; +use codex_core::TurnInputRequest; use codex_core::config::CurrentTimeReminderConfig; use codex_features::CurrentTimeReminderDeliveryMode; use codex_features::CurrentTimeSource; @@ -428,16 +429,10 @@ async fn time_provider_failure_stops_before_inference() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "fail before inference".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "fail before inference".into(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::Error(error) = diff --git a/codex-rs/core/tests/suite/cyber_exec_policy.rs b/codex-rs/core/tests/suite/cyber_exec_policy.rs index 9e83af9807..8ac4e1b084 100644 --- a/codex-rs/core/tests/suite/cyber_exec_policy.rs +++ b/codex-rs/core/tests/suite/cyber_exec_policy.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::Constrained; use codex_core::sandboxing::SandboxPermissions; @@ -260,20 +261,17 @@ async fn cyber_model_user_approval_never_offers_a_reusable_prefix( .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a command with one-time approval".to_string(), 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::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), ..Default::default() - }, - }) + }), + ) .await?; let EventMsg::ExecApprovalRequest(approval) = wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 83dda23eef..aad7a5de93 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::test_support::CloudConfigBundleFixture; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -12,6 +13,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -53,23 +55,20 @@ async fn submit_user_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: collaboration_mode.or({ - Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, @@ -77,8 +76,8 @@ async fn submit_user_turn( }) }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } @@ -402,30 +401,27 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run shell command".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let EventMsg::ExecCommandEnd(end) = wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e58cb2275c..777dd51854 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use anyhow::Context; use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::Constrained; use codex_extension_api::ExtensionRegistry; @@ -10,6 +11,9 @@ use codex_features::Feature; use codex_image_generation_extension::install as install_image_generation_extension; use codex_login::CodexAuth; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -22,6 +26,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -244,31 +249,28 @@ async fn extension_tool_uses_granted_turn_permissions_without_local_persistence( let (sandbox_policy, permission_profile) = turn_permission_fields(base_permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "request access and edit the image".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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let event = wait_for_event(&test.codex, |event| { matches!( diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index c93d03e40b..f92cce3c1b 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::ThreadConfigSnapshot; +use codex_core::TurnInputRequest; use codex_core::parse_turn_item; use codex_history::InitialHistory; use codex_history::ResumedHistory; @@ -11,7 +12,6 @@ use codex_history::RolloutLine; use codex_protocol::items::TurnItem; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_protocol::user_input::UserInput; @@ -55,16 +55,10 @@ async fn fork_thread_twice_drops_to_first_message() { // Send three user messages; wait for three completed turns. for text in ["first", "second", "third"] { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await .unwrap(); let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -204,16 +198,10 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto let thread_manager = test.thread_manager.clone(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "fork me from stored history".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "fork me from stored history".to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit initial user turn"); let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -281,16 +269,10 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto .expect("resume copied paginated fork") .thread; resumed - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "continue after cold resume".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "continue after cold resume".to_string(), + text_elements: Vec::new(), + }])) .await .expect("start resumed turn"); wait_for_event(&resumed, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/git_enrichment.rs b/codex-rs/core/tests/suite/git_enrichment.rs index 7d1c726b48..4a2e8e584a 100644 --- a/codex-rs/core/tests/suite/git_enrichment.rs +++ b/codex-rs/core/tests/suite/git_enrichment.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::ensure; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; #[cfg(not(target_os = "windows"))] use codex_core::config::Constrained; #[cfg(not(target_os = "windows"))] @@ -11,7 +12,6 @@ use codex_protocol::config_types::ApprovalsReviewer; #[cfg(not(target_os = "windows"))] use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSource; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; @@ -196,13 +196,10 @@ async fn guardian_prewarm_and_review_skip_redundant_git_enrichment() -> Result<( std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?; test.codex - .submit( - vec![UserInput::Text { - text: "run a command that requires Guardian review".into(), - text_elements: Vec::new(), - }] - .into(), - ) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }])) .await?; let (user_turn, guardian_turn) = tokio::time::timeout(Duration::from_secs(5), async { tokio::join!( @@ -295,16 +292,10 @@ async fn ephemeral_system_thread_prewarm_skips_and_turn_observes_fresh_state( std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?; system_thread .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "generate a thread title".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "generate a thread title".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(system_thread.thread.as_ref(), |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -423,24 +414,21 @@ async fn concurrent_turns_keep_distinct_worktree_and_repository_metadata() -> Re std::fs::write(repo.path().join("untracked.txt"), "dirty\n")?; std::fs::write(other_repo.path().join("untracked.txt"), "dirty\n")?; - let user_turn = |prompt: &str| Op::UserInput { - items: vec![UserInput::Text { + let user_turn = |prompt: &str| { + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), + }]) }; tokio::try_join!( - test.codex.submit(user_turn("inspect the main worktree")), + test.codex + .start_or_steer_turn(user_turn("inspect the main worktree")), worktree_thread .thread - .submit(user_turn("inspect the linked worktree")), + .start_or_steer_turn(user_turn("inspect the linked worktree")), other_thread .thread - .submit(user_turn("inspect the other repository")) + .start_or_steer_turn(user_turn("inspect the other repository")) )?; tokio::join!( wait_for_event(test.codex.as_ref(), |event| matches!( diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 7d246b4380..905e82d06e 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -8,6 +8,7 @@ use chrono::Utc; use codex_core::SleepFuture; use codex_core::TimeFuture; use codex_core::TimeProvider; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::config::CurrentTimeReminderConfig; use codex_core::sandboxing::SandboxPermissions; @@ -24,6 +25,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::fs_wait; @@ -258,13 +260,10 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review( .expect("guardian thread id"); test.codex - .submit( - vec![UserInput::Text { - text: "run a command that requires Guardian review".into(), - text_elements: Vec::new(), - }] - .into(), - ) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }])) .await?; let guardian_review = tokio::time::timeout( Duration::from_secs(5), @@ -448,22 +447,19 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run two commands that require Guardian review".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -637,22 +633,19 @@ async fn interrupted_guardian_tool_review_aborts_without_executing_the_command() .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "interrupt a Guardian-reviewed command".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), ..Default::default() - }, - }) + }), + ) .await?; tokio::time::timeout(Duration::from_secs(5), async { @@ -684,13 +677,10 @@ async fn interrupted_guardian_tool_review_aborts_without_executing_the_command() ) .await; test.codex - .submit( - vec![UserInput::Text { - text: "verify Guardian cancellation left the next turn clean".into(), - text_elements: Vec::new(), - }] - .into(), - ) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "verify Guardian cancellation left the next turn clean".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -784,21 +774,18 @@ async fn guardian_denial_rejects_tool_call_with_rationale() -> Result<()> { .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a command that Guardian should deny".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -898,21 +885,18 @@ async fn cyber_model_guardian_denial_interrupts_turn_immediately() -> Result<()> .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a command that Guardian should deny for a cyber model".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), ..Default::default() - }, - }) + }), + ) .await?; let warning = wait_for_event(&test.codex, |event| { @@ -1030,22 +1014,19 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a command that requires Guardian review".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 004aec9a22..1d6e990469 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::Constrained; use codex_core::config::ThreadStoreConfig; @@ -25,6 +26,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -1613,21 +1615,20 @@ async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "observe async context immediately".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 { - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - ..Default::default() - }, - }) + }]) + .with_thread_settings( + codex_protocol::protocol::ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }, + ), + ) .await?; let finished_path = test @@ -1753,16 +1754,10 @@ async fn async_hook_finishing_while_idle_waits_for_the_next_turn() -> Result<()> let next_prompt = "observe the buffered async context"; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: next_prompt.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: next_prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; let mut warning_event = None; @@ -2636,16 +2631,10 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu let test = builder.build_with_streaming_server(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "initial prompt".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "initial prompt".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { @@ -2655,16 +2644,10 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu for text in ["accepted queued prompt", "blocked queued prompt"] { test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await?; } @@ -2906,21 +2889,18 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<( let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "request strict review, then run the shell command".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; let request = wait_for_event(&test.codex, |event| { @@ -3595,21 +3575,20 @@ Path(r"{hook_finished_path}").write_text("finished", encoding="utf-8") let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run the original command with async pre-tool hooks".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 { - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - ..Default::default() - }, - }) + }]) + .with_thread_settings( + codex_protocol::protocol::ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + ..Default::default() + }, + ), + ) .await?; fs_wait::wait_for_path_exists(hook_finished_path, Duration::from_secs(5)) diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index bb39beaab6..f2c767ba89 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -1,9 +1,13 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; +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::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::ImageDetail; @@ -13,6 +17,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_image::data_url_from_bytes; use core_test_support::TempDirExt; @@ -125,8 +130,8 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![ + .start_or_steer_turn( + TurnInputRequest::user_input(vec![ UserInput::LocalImage { path: abs_path.clone(), detail: None, @@ -135,26 +140,23 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu text: "pasted image".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 { + ]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -224,8 +226,8 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![ + .start_or_steer_turn( + TurnInputRequest::user_input(vec![ UserInput::Image { image_url: image_url.clone(), detail: None, @@ -234,26 +236,23 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> text: "dropped image".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 { + ]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -374,16 +373,10 @@ async fn resumed_history_only_emits_resize_notices_for_new_images() -> anyhow::R .await; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Image { - image_url: original_image_url.clone(), - detail: Some(ImageDetail::High), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Image { + image_url: original_image_url.clone(), + detail: Some(ImageDetail::High), + }])) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/injected_models_cache.rs b/codex-rs/core/tests/suite/injected_models_cache.rs index ba8c123bd4..e909f88149 100644 --- a/codex-rs/core/tests/suite/injected_models_cache.rs +++ b/codex-rs/core/tests/suite/injected_models_cache.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; @@ -22,7 +23,6 @@ use codex_protocol::error::Result as CoreResult; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses; @@ -184,16 +184,10 @@ async fn run_agent_with_model(models_manager: SharedModelsManager, model_slug: & ) .await?; test.codex - .submit(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: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }])) .await?; loop { if matches!( diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 6bde20cc9f..e65d51059a 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; @@ -15,8 +16,8 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadHistoryMode; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -48,27 +49,22 @@ fn disabled_plan_turn( text: &str, _model: String, collaboration_mode: CollaborationMode, -) -> anyhow::Result { +) -> anyhow::Result { let cwd = std::env::current_dir()?.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); - Ok(Op::UserInput { - items: vec![UserInput::Text { - text: text.into(), - 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(cwd)), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(collaboration_mode), - ..Default::default() - }, - }) + Ok(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(cwd)), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(collaboration_mode), + ..Default::default() + })) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -92,13 +88,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { }; codex - .submit(Op::UserInput { - items: vec![expected_input.clone()], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![expected_input.clone()])) .await?; let started_item = wait_for_event_match(&codex, |ev| match ev { @@ -148,16 +138,10 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { mount_sse_once(&server, first_response).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "please summarize results".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "please summarize results".into(), + text_elements: Vec::new(), + }])) .await?; let started = wait_for_event_match(&codex, |ev| match ev { @@ -209,16 +193,10 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { mount_sse_once(&server, first_response).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "explain your reasoning".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "explain your reasoning".into(), + text_elements: Vec::new(), + }])) .await?; let started = wait_for_event_match(&codex, |ev| match ev { @@ -280,16 +258,10 @@ async fn missing_streamed_reasoning_id_is_reused_for_completion() -> anyhow::Res .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "explain your reasoning".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "explain your reasoning".into(), + text_elements: Vec::new(), + }])) .await?; let started_id = wait_for_event_match(&codex, |ev| match ev { @@ -339,16 +311,10 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { mount_sse_once(&server, first_response).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "find the weather".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "find the weather".into(), + text_elements: Vec::new(), + }])) .await?; let started = wait_for_event_match(&codex, |ev| match ev { @@ -434,16 +400,10 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { mount_sse_once(&server, stream).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "please stream text".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "please stream text".into(), + text_elements: Vec::new(), + }])) .await?; let (started_turn_id, started_item) = wait_for_event_match(&codex, |ev| match ev { @@ -513,7 +473,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< }; codex - .submit(disabled_plan_turn( + .start_or_steer_turn(disabled_plan_turn( "please plan", session_configured.model.clone(), collaboration_mode, @@ -578,7 +538,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { }; codex - .submit(disabled_plan_turn( + .start_or_steer_turn(disabled_plan_turn( "please plan", session_configured.model.clone(), collaboration_mode, @@ -675,7 +635,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done }; codex - .submit(disabled_plan_turn( + .start_or_steer_turn(disabled_plan_turn( "please plan with citations", session_configured.model.clone(), collaboration_mode, @@ -850,7 +810,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p }; codex - .submit(disabled_plan_turn( + .start_or_steer_turn(disabled_plan_turn( "please plan", session_configured.model.clone(), collaboration_mode, @@ -952,7 +912,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { }; codex - .submit(disabled_plan_turn( + .start_or_steer_turn(disabled_plan_turn( "please plan", session_configured.model.clone(), collaboration_mode, @@ -1018,16 +978,10 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { mount_sse_once(&server, stream).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "reason through it".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "reason through it".into(), + text_elements: Vec::new(), + }])) .await?; let reasoning_item = wait_for_event_match(&codex, |ev| match ev { @@ -1095,16 +1049,10 @@ async fn sequential_cutoff_renders_done_summaries_for_active_reasoning_item() -> .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "reason through it".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "reason through it".into(), + text_elements: Vec::new(), + }])) .await?; let reasoning_item = wait_for_event_match(&codex, |ev| match ev { @@ -1165,16 +1113,10 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { mount_sse_once(&server, stream).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "show raw reasoning".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "show raw reasoning".into(), + text_elements: Vec::new(), + }])) .await?; let reasoning_item = wait_for_event_match(&codex, |ev| match ev { diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index c9a855e6af..7bc8653245 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -1,9 +1,14 @@ #![cfg(not(target_os = "windows"))] +use codex_core::TurnInputRequest; +use codex_core::TurnStartOptions; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -77,30 +82,31 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { // 1) Normal user input – should hit server once. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello world".into(), text_elements: Vec::new(), - }], - final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }) + .on_start(TurnStartOptions { + final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), + ..Default::default() + }), + ) .await?; let message = wait_for_event(&codex, |ev| matches!(ev, EventMsg::AgentMessage(_))).await; diff --git a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs index a58e9764ba..670401fe3e 100644 --- a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs +++ b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::approvals::ElicitationRequest; @@ -135,16 +136,10 @@ default_tools_approval_mode = "auto" let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Use [$calendar](app://calendar) to create a calendar event.".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Use [$calendar](app://calendar) to create a calendar event.".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs index 9afb194e33..90cbbf8247 100644 --- a/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs +++ b/codex-rs/core/tests/suite/mcp_startup_refresh_http_proxy.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use std::collections::HashMap; use std::time::Duration; @@ -10,7 +11,7 @@ use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; use core_test_support::apps_test_server::AppsTestServer; @@ -313,8 +314,8 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()> turn_permission_fields(PermissionProfile::Disabled, fixture.config.cwd.as_path()); fixture .codex - .submit(Op::UserInput { - items: vec![ + .start_or_steer_turn( + TurnInputRequest::user_input(vec![ UserInput::Text { text: "please use $proxy-skill".to_string(), text_elements: Vec::new(), @@ -323,18 +324,15 @@ async fn skill_mcp_dependency_oauth_uses_configured_http_client() -> Result<()> name: "proxy-skill".to_string(), path: skill_path.to_path_buf(), }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + ]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(fixture.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; core_test_support::wait_for_event(fixture.codex.as_ref(), |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/mcp_tool_cache.rs b/codex-rs/core/tests/suite/mcp_tool_cache.rs index 2555c48c20..d4c7c4259b 100644 --- a/codex-rs/core/tests/suite/mcp_tool_cache.rs +++ b/codex-rs/core/tests/suite/mcp_tool_cache.rs @@ -7,6 +7,7 @@ use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::NewThread; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use codex_protocol::models::PermissionProfile; @@ -17,6 +18,7 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; use core_test_support::apps_test_server::AppsTestServer; @@ -40,21 +42,16 @@ use super::rmcp_client::remote_aware_stdio_server_bin; const SERVER_NAME: &str = "cached_rmcp"; const NAMESPACE: &str = "mcp__cached_rmcp"; -fn user_turn(prompt: &str) -> Op { - Op::UserInput { - items: vec![UserInput::Text { - text: prompt.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 { - approval_policy: Some(AskForApproval::Never), - permission_profile: Some(PermissionProfile::Disabled), - ..Default::default() - }, - } +fn user_turn(prompt: &str) -> TurnInputRequest { + TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + permission_profile: Some(PermissionProfile::Disabled), + ..Default::default() + }) } fn process_label(pid: &str) -> String { @@ -193,7 +190,7 @@ async fn mcp_calls_stay_bound_to_each_thread() -> anyhow::Result<()> { ) .await; thread - .submit(user_turn(&format!("Call the {SERVER_NAME} echo tool."))) + .start_or_steer_turn(user_turn(&format!("Call the {SERVER_NAME} echo tool."))) .await?; let EventMsg::McpToolCallEnd(end) = wait_for_event( thread, @@ -362,7 +359,7 @@ async fn cached_http_mcp_starts_lazily_for_subagents( ) .await; subagent - .submit(user_turn("Call the cached HTTP tool.")) + .start_or_steer_turn(user_turn("Call the cached HTTP tool.")) .await?; wait_for_event(&subagent, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -445,7 +442,10 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow ]), ) .await; - fixture.codex.submit(user_turn("use the echo tool")).await?; + fixture + .codex + .start_or_steer_turn(user_turn("use the echo tool")) + .await?; let first_pid = wait_for_new_pid(fs.as_ref(), &pid_file, /*previous_pid*/ None).await?; fs.write_file(&barrier_file, b"ready".to_vec(), /*sandbox*/ None) .await?; @@ -515,7 +515,7 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow ) .await; second_thread - .submit(user_turn("Do not call any MCP tools.")) + .start_or_steer_turn(user_turn("Do not call any MCP tools.")) .await?; let mut reported_ready_before_startup = false; wait_for_event(&second_thread, |event| { @@ -578,7 +578,7 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow let second_for_turn = Arc::clone(&second_thread); let cached_turn = tokio::spawn(async move { second_for_turn - .submit(user_turn("call the echo and cwd tools")) + .start_or_steer_turn(user_turn("call the echo and cwd tools")) .await?; let mut unrelated_finished_tx = Some(unrelated_finished_tx); let mut saw_starting = false; @@ -734,7 +734,7 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow ) .await; interrupted_thread - .submit(user_turn("Start the cached MCP tool.")) + .start_or_steer_turn(user_turn("Start the cached MCP tool.")) .await?; let interrupted_pid = wait_for_new_pid(fs.as_ref(), &pid_file, Some(&filtered_pid)).await?; wait_for_event(&interrupted_thread, |event| { @@ -790,7 +790,7 @@ async fn cached_mcp_startup_is_eager_for_root_and_lazy_for_subagents() -> anyhow ) .await; interrupted_thread - .submit(user_turn("Retry the cached MCP tool.")) + .start_or_steer_turn(user_turn("Retry the cached MCP tool.")) .await?; wait_for_event(&interrupted_thread, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index d3dfe3e0d7..9049f8f17a 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_config::Constrained; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_extension_api::ExtensionFuture; use codex_extension_api::ExtensionRegistryBuilder; @@ -904,16 +905,10 @@ async fn apps_guidance_and_deferred_namespace_appear_after_recovery_within_a_tur let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "use an app after it recovers".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "use an app after it recovers".into(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::RequestUserInput(request) = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::RequestUserInput(_)) @@ -1093,16 +1088,10 @@ async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup startup_control.fail_next_initialize_attempts(/*attempts*/ 1); test.codex.submit(Op::RefreshMcpServers).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "use Calendar after transient Apps startup failures".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "use Calendar after transient Apps startup failures".into(), + text_elements: Vec::new(), + }])) .await?; tokio::time::timeout(Duration::from_secs(5), async { let mut turn_complete = false; diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index cb47f28e63..941c6f3390 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -4,6 +4,7 @@ use anyhow::Result; use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AppToolApproval; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_features::Feature; use codex_history::RolloutItem; @@ -20,6 +21,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ElicitationAction; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -111,23 +113,20 @@ async fn submit_user_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: text.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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: collaboration_mode.or({ - Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, @@ -135,8 +134,8 @@ async fn submit_user_turn( }) }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 4bf5295eaa..ffed960c23 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -148,13 +148,13 @@ mod tool_harness; mod tool_parallelism; mod tools; mod truncation; +mod turn_input_submission; mod turn_state; mod unified_exec; mod unified_exec_process_events; #[cfg(unix)] mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; -mod user_message_admission; mod user_notification; mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 8061839b4e..2c3de12486 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; @@ -13,7 +14,6 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MultiAgentVersion; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses; @@ -133,16 +133,10 @@ async fn response_for_remote_model( ) .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "list tools".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "list tools".into(), + text_elements: Vec::new(), + }])) .await?; let mut warnings = Vec::new(); loop { @@ -329,16 +323,10 @@ async fn unsupported_code_mode_warning_is_emitted_each_turn() -> Result<()> { let mut warning_counts = Vec::new(); for prompt in ["first turn", "second turn"] { test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; let mut warning_count = 0; @@ -471,16 +459,10 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> assert_eq!(test.codex.multi_agent_version(), None); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: ROOT_PROMPT.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: ROOT_PROMPT.into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index bb2ea22a39..b233c7ccc8 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -2,15 +2,19 @@ use anyhow::Result; use codex_config::types::Personality; use codex_core::CodexThread; use codex_core::ForkSnapshot; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; @@ -47,30 +51,24 @@ use pretty_assertions::assert_eq; use test_case::test_case; use wiremock::MockServer; -fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) -> Op { +fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) -> TurnInputRequest { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); - Op::UserInput { - items, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - environments: Some(local_selections(test.config.cwd.clone())), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model, - reasoning_effort: test.config.model_reasoning_effort.clone(), - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(items).with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(test.config.cwd.clone())), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: test.config.model_reasoning_effort.clone(), + developer_instructions: None, + }, + }), + ..Default::default() + }) } async fn submit_model_turn( @@ -80,16 +78,13 @@ async fn submit_model_turn( ) -> Result<()> { thread_settings.model = Some(model.to_string()); thread - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "switch models".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings, - }) + }]) + .with_thread_settings(thread_settings), + ) .await?; wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; Ok(()) @@ -371,7 +366,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( let next_model = "gpt-5.4"; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "hello".into(), @@ -384,7 +379,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(next_model.to_string()), ..Default::default() }, @@ -392,7 +387,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( .await?; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "switch models".into(), @@ -466,7 +461,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul let next_model = "exp-codex-personality"; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "hello".into(), @@ -479,7 +474,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(next_model.to_string()), personality: Some(Personality::Pragmatic), ..Default::default() @@ -488,7 +483,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul .await?; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "switch model and personality".into(), @@ -793,7 +788,7 @@ async fn model_change_from_multimodal_to_text_strips_prior_media_content() -> Re .to_string(); test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![ UserInput::Image { @@ -814,7 +809,7 @@ async fn model_change_from_multimodal_to_text_strips_prior_media_content() -> Re wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "second turn".to_string(), @@ -911,7 +906,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { .await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "generate a lobster".to_string(), @@ -923,7 +918,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "describe the generated image".to_string(), @@ -1008,7 +1003,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima .await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "generate a lobster".to_string(), @@ -1020,7 +1015,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "describe the generated image".to_string(), @@ -1107,7 +1102,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() .await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "generate a lobster".to_string(), @@ -1127,7 +1122,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() .await; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "after rollback".to_string(), @@ -1283,7 +1278,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< ); test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "use larger model".into(), @@ -1318,7 +1313,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(smaller_model_slug.to_string()), ..Default::default() }, @@ -1326,7 +1321,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< .await?; test.codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &test, vec![UserInput::Text { text: "switch to smaller model".into(), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 6e64e42d28..e6fbd9719d 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -5,6 +5,7 @@ use std::sync::Mutex; use anyhow::Result; use codex_config::types::Personality; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_extension_api::ContextualUserFragment; @@ -17,10 +18,13 @@ use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; @@ -201,15 +205,12 @@ async fn model_visible_environment_context_preserves_foreign_workspace_roots() - let foreign_root = PathUri::parse("file:///C:/workspace")?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "inspect the workspace".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![TurnEnvironmentSelection { @@ -219,8 +220,8 @@ async fn model_visible_environment_context_preserves_foreign_workspace_roots() - }], )), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -282,30 +283,27 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), first_turn_cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "first turn".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(first_turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -317,31 +315,28 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { preturn_context_diff_cwd.as_path(), ); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "second turn with context updates".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(preturn_context_diff_cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, personality: Some(Personality::Friendly), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -408,30 +403,27 @@ async fn snapshot_model_visible_layout_cwd_change_refreshes_agents() -> Result<( turn_permission_fields(PermissionProfile::read_only(), cwd_one.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "first turn in agents_one".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_one.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(first_sandbox_policy), permission_profile: first_permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -441,30 +433,27 @@ async fn snapshot_model_visible_layout_cwd_change_refreshes_agents() -> Result<( let (second_sandbox_policy, second_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_two.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "second turn in agents_two".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_two)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(second_sandbox_policy), permission_profile: second_permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -520,16 +509,10 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul ) .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "seed resume history".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "seed resume history".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let initial_request = initial_mock.single_request(); @@ -564,31 +547,28 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul ); resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "resume and change personality".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(resume_override_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, personality: Some(Personality::Friendly), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: resumed.session_configured.model.clone(), reasoning_effort: resumed.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -633,16 +613,10 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - ) .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "seed resume history".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "seed resume history".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let initial_request = initial_mock.single_request(); @@ -668,7 +642,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resume_override_cwd = resume_override_cwd.abs(); core_test_support::submit_thread_settings( &resumed.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(local_selections(resume_override_cwd)), model: Some("gpt-5.2".to_string()), ..Default::default() @@ -677,16 +651,10 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - .await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first resumed turn after model override".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first resumed turn after model override".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5c6a9137e6..2241da1010 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::path::Path; use std::sync::Arc; @@ -9,7 +10,10 @@ use chrono::Utc; use codex_login::CodexAuth; use codex_models_manager::client_version_to_whole; use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -21,7 +25,7 @@ use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -98,30 +102,27 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hi".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let _ = wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 8b2e9f35b1..f3d559cea0 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] +use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::sync::Arc; use std::time::Duration; @@ -7,11 +8,14 @@ use std::time::Duration; use anyhow::Result; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::responses; @@ -106,30 +110,27 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please run a tool".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let _ = wait_for_event_with_timeout( diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index 585e63bdd8..3dae9bbee3 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_features::Feature; use codex_protocol::openai_models::ModelInfo; @@ -6,7 +7,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; @@ -74,19 +74,16 @@ async fn submit_turn( effort: Option, ) -> Result<()> { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.to_string(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { effort: effort.map(Some), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; Ok(()) diff --git a/codex-rs/core/tests/suite/multi_agent_resume.rs b/codex-rs/core/tests/suite/multi_agent_resume.rs index 1016d5a18c..e17012b9de 100644 --- a/codex-rs/core/tests/suite/multi_agent_resume.rs +++ b/codex-rs/core/tests/suite/multi_agent_resume.rs @@ -1,12 +1,12 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::AgentRoleConfig; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::assert_parent_turn; use core_test_support::responses::assert_root_turn; @@ -246,27 +246,18 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu }); let initial = initial_builder.build_with_auto_env(&server).await?; let root_thread_id = initial.session_configured.thread_id; - let mut op = vec![UserInput::Text { - text: INITIAL_PROMPT.to_string(), - text_elements: Vec::new(), - }] - .into(); - if let Op::UserInput { - thread_settings, .. - } = &mut op - { - thread_settings.permission_profile = Some(PermissionProfile::Disabled); - } initial .codex - .submit_with_id(Submission { - id: "spoofed-root-turn".to_string(), - op, - client_user_message_id: None, - trace: None, - parent_turn_id: Some("spoofed-parent-turn".to_string()), - root_turn_id: Some("spoofed-foreign-root-turn".to_string()), - }) + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: INITIAL_PROMPT.to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + permission_profile: Some(PermissionProfile::Disabled), + ..Default::default() + }), + ) .await?; wait_for_event(&initial.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/multi_exec_server_sandbox.rs b/codex-rs/core/tests/suite/multi_exec_server_sandbox.rs index 8cd40328c9..590b48ff5a 100644 --- a/codex-rs/core/tests/suite/multi_exec_server_sandbox.rs +++ b/codex-rs/core/tests/suite/multi_exec_server_sandbox.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use std::process::Stdio; use std::time::Duration; @@ -9,7 +10,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; @@ -206,15 +206,12 @@ async fn two_exec_servers_isolate_workspace_write_roots() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "write one file in each environment".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), environments, @@ -223,8 +220,8 @@ async fn two_exec_servers_isolate_workspace_write_roots() -> Result<()> { sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; wait_for_event_with_timeout( &test.codex, diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 5015dd9b79..17465ee0af 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use codex_config::types::ApprovalsReviewer; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -11,6 +12,9 @@ use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -19,6 +23,7 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; @@ -1664,31 +1669,28 @@ async fn submit_managed_network_turn( TurnEnvironmentSelections::new(test.config.cwd.clone(), environments); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(turn_environment_selections), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 1051d32805..cd40166938 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_features::Feature; use codex_otel::SessionTelemetry; @@ -128,16 +129,10 @@ async fn responses_api_emits_api_request_event() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -193,16 +188,10 @@ async fn process_sse_emits_tracing_for_output_item() { let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -239,16 +228,10 @@ async fn process_sse_emits_failed_event_on_parse_error() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -286,16 +269,10 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -353,16 +330,10 @@ async fn process_sse_failed_event_records_response_error_message() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -418,16 +389,10 @@ async fn process_sse_failed_event_logs_parse_error() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -470,16 +435,10 @@ async fn process_sse_failed_event_logs_missing_error() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -531,16 +490,10 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -592,16 +545,10 @@ async fn process_sse_emits_completed_telemetry() { let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -678,16 +625,10 @@ async fn turn_and_completed_response_spans_record_token_usage() { let TestCodex { codex, .. } = test; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -768,16 +709,10 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -862,16 +797,10 @@ async fn record_responses_sets_span_fields_for_response_events() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -951,16 +880,10 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1027,16 +950,10 @@ async fn handle_response_item_records_tool_result_for_function_call() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1104,16 +1021,10 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1277,16 +1188,10 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1332,16 +1237,10 @@ async fn handle_shell_command_user_approved_records_tool_decision() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "approved".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "approved".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1402,16 +1301,10 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "persist".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "persist".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1472,16 +1365,10 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "retry".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "retry".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1542,16 +1429,10 @@ async fn handle_shell_command_user_denies_records_tool_decision() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "deny".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "deny".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1612,16 +1493,10 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "persist".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "persist".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -1683,16 +1558,10 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "deny".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "deny".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 9711ed45e4..909829abbf 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -2,7 +2,10 @@ use core_test_support::test_codex::local_selections; use std::sync::Arc; use codex_core::CodexThread; +use codex_core::StartIfIdleSubmission; use codex_core::TurnInput; +use codex_core::TurnInputRequest; +use codex_core::TurnInputSubmission; use codex_core::config::CurrentTimeReminderConfig; use codex_extension_items::ExtensionItem; use codex_extension_items::sleep::SleepItem; @@ -70,14 +73,13 @@ async fn idle_response_items_include_pending_mailbox_in_first_request() -> anyho let test = test_codex().build_with_auto_env(&server).await?; submit_queue_only_agent_mail(test.codex.as_ref(), "pending mailbox input").await; - test.codex - .try_start_turn_if_idle(vec![TurnInput::ResponseItem( - responses::user_message_item("automatic response item").into(), - )]) - .await - .map_err(|error| { - anyhow::anyhow!("idle response items were rejected: {:?}", error.reason()) - })?; + let submission = test + .codex + .start_turn_if_idle(TurnInputRequest::new(TurnInput::ResponseItem( + responses::user_message_item("automatic response item"), + ))) + .await?; + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); wait_for_turn_complete(test.codex.as_ref()).await; let request = response.single_request(); @@ -144,13 +146,14 @@ async fn assert_idle_user_input_reaches_the_first_model_request( text: "queued user input reaches the first request".to_string(), text_elements: Vec::new(), }]; - test.codex - .try_start_turn_if_idle(vec![TurnInput::UserInput { + let submission = test + .codex + .start_turn_if_idle(TurnInputRequest::new(TurnInput::UserInput { content: expected_input.clone(), client_id: Some("queued-user-message".to_string()), - }]) - .await - .map_err(|error| anyhow::anyhow!("idle user input was rejected: {:?}", error.reason()))?; + })) + .await?; + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); let user_message = core_test_support::wait_for_event_match(test.codex.as_ref(), |event| { let EventMsg::ItemCompleted(event) = event else { @@ -276,16 +279,10 @@ async fn build_codex(server: &StreamingSseServer) -> Arc { async fn submit_user_input(codex: &CodexThread, text: &str) { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await .expect("submit user input"); } @@ -294,48 +291,40 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: text.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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await .expect("submit user turn"); } async fn steer_user_input(codex: &CodexThread, text: &str) { - codex - .steer_input( - vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - /*additional_context*/ Default::default(), - /*expected_turn_id*/ None, - /*client_user_message_id*/ None, - /*responsesapi_client_metadata*/ None, - ) + let submission = codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await .expect("steer user input"); + assert!(matches!(submission, TurnInputSubmission::Steered { .. })); } async fn enqueue_queue_only_agent_mail(codex: &CodexThread, text: &str) { @@ -759,16 +748,10 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { .codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first prompt".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first prompt".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -778,16 +761,10 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "second prompt".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "second prompt".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 40a2966b0b..306b970ee1 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_config::ConfigLayerStack; use codex_core::ForkSnapshot; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::context::ApprovalPromptContext; use codex_core::context::ContextualUserFragment; @@ -15,7 +16,7 @@ use codex_protocol::openai_models::PermissionMessages; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ResponsesRequest; @@ -84,16 +85,10 @@ async fn submit_text_turn( text: &str, ) -> Result<()> { test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; Ok(()) @@ -171,7 +166,7 @@ async fn model_change_appends_new_catalog_approval_message() -> Result<()> { core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(second_slug.to_string()), ..Default::default() }, @@ -304,7 +299,7 @@ async fn catalog_permission_message_is_sent_initially_and_after_model_change() - core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(second_slug.to_string()), ..Default::default() }, @@ -393,16 +388,10 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -433,22 +422,16 @@ async fn permissions_message_added_on_override_change() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), ..Default::default() }, @@ -456,16 +439,10 @@ async fn permissions_message_added_on_override_change() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -502,30 +479,18 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -562,22 +527,16 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), ..Default::default() }, @@ -585,16 +544,10 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -638,22 +591,16 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &initial.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), ..Default::default() }, @@ -662,32 +609,20 @@ async fn resume_replays_permissions_messages() -> Result<()> { initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let resumed = builder.restart(&server, &initial).await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after resume".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -737,22 +672,16 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &initial.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), ..Default::default() }, @@ -761,16 +690,10 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { initial .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -783,16 +706,10 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { let resumed = builder.restart(&server, &initial).await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "after resume".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -818,16 +735,10 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { .await?; forked .thread - .submit(Op::UserInput { - items: 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: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "after fork".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -879,16 +790,10 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index f70866d439..5e8b7f28a5 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -1,10 +1,14 @@ use codex_config::types::Personality; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::model_info::BASE_INSTRUCTIONS; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -18,7 +22,7 @@ use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; @@ -47,7 +51,7 @@ fn read_only_text_turn( text: &str, model: String, approval_policy: AskForApproval, -) -> Op { +) -> TurnInputRequest { let personality = None; read_only_text_turn_with_personality(test, text, model, approval_policy, personality) } @@ -58,34 +62,29 @@ fn read_only_text_turn_with_personality( model: String, approval_policy: AskForApproval, personality: Option, -) -> Op { +) -> TurnInputRequest { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), test.cwd_path()); - Op::UserInput { - items: vec![UserInput::Text { - text: text.into(), - 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(test.config.cwd.clone())), - approval_policy: Some(approval_policy), - sandbox_policy: Some(sandbox_policy), - permission_profile, - personality, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model, - reasoning_effort: test.config.model_reasoning_effort.clone(), - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(test.config.cwd.clone())), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + permission_profile, + personality, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: test.config.model_reasoning_effort.clone(), + developer_instructions: None, + }, + }), + ..Default::default() + }) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -103,7 +102,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -141,7 +140,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -186,7 +185,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -245,7 +244,7 @@ async fn config_personality_none_strips_baked_personality_section() -> anyhow::R let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -282,7 +281,7 @@ async fn config_personality_none_preserves_explicit_base_instructions() -> anyho let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -315,7 +314,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -356,7 +355,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -368,7 +367,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { personality: Some(Personality::Friendly), ..Default::default() }, @@ -376,7 +375,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> .await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -432,7 +431,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -444,7 +443,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), ..Default::default() }, @@ -452,7 +451,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho .await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -552,7 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> let test = builder.build(&server).await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -564,7 +563,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), ..Default::default() }, @@ -572,7 +571,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> .await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", test.session_configured.model.clone(), @@ -702,7 +701,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex - .submit(read_only_text_turn_with_personality( + .start_or_steer_turn(read_only_text_turn_with_personality( &test, "hello", remote_slug.to_string(), @@ -830,7 +829,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - wait_for_model_available(&test.thread_manager.get_models_manager(), remote_slug).await; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", remote_slug.to_string(), @@ -842,7 +841,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - core_test_support::submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { personality: Some(Personality::Friendly), ..Default::default() }, @@ -850,7 +849,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - .await?; test.codex - .submit(read_only_text_turn( + .start_or_steer_turn(read_only_text_turn( &test, "hello", remote_slug.to_string(), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index d9cad14ecf..a7654e2f9a 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -5,6 +5,7 @@ use std::time::Duration; use std::time::Instant; use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core_plugins::store::PluginStore; use codex_extension_api::ExtensionRegistry; @@ -16,10 +17,13 @@ use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_plugin::PluginId; use codex_protocol::auth::AuthMode; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_skills_extension::SkillsExtensionConfig; use codex_skills_extension::install; @@ -415,30 +419,27 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![codex_protocol::user_input::UserInput::Text { text: "run the remote plugin script".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let begin = wait_for_event_match(&codex, |event| match event { @@ -503,16 +504,10 @@ async fn agent_plugin_skills_use_shared_catalog_and_direct_child_discovery() -> test_codex .codex - .submit(Op::UserInput { - items: vec![UserInput::Skill { - name: "acme.tools:review".into(), - path: skill_path, - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Skill { + name: "acme.tools:review".into(), + path: skill_path, + }])) .await?; let warning = wait_for_event(&test_codex.codex, |ev| { matches!( @@ -618,16 +613,10 @@ async fn plugin_skill_product_policy_and_migrated_command_precedence_reach_agent ); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Inspect the available plugin skills.".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Inspect the available plugin skills.".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -675,16 +664,10 @@ async fn legacy_plugin_skill_prompt_remains_complete() -> Result<()> { test_codex .codex - .submit(Op::UserInput { - items: vec![UserInput::Skill { - name: "sample:sample-search".into(), - path: skill_path, - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Skill { + name: "sample:sample-search".into(), + path: skill_path, + }])) .await?; wait_for_event(&test_codex.codex, |ev| { matches!(ev, EventMsg::TurnComplete(_)) @@ -776,16 +759,10 @@ async fn agent_plugin_root_mcp_stdio_tool_round_trip_expands_reserved_paths() -> test_codex .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "call the Agent Plugin echo tool".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "call the Agent Plugin echo tool".into(), + text_elements: Vec::new(), + }])) .await?; let end = wait_for_event(&test_codex.codex, |event| { matches!(event, EventMsg::McpToolCallEnd(_)) @@ -890,16 +867,12 @@ async fn agent_turns_route_curated_plugin_skills_after_auth_switch() -> Result<( .await? .thread; thread - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + codex_protocol::user_input::UserInput::Text { text: prompt.to_string(), 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(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -1106,16 +1079,12 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins( wait_for_mcp_server(&codex, CODEX_APPS_MCP_SERVER_NAME).await?; codex - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Mention { + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + }, + ])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1190,16 +1159,12 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> wait_for_mcp_server(&codex, "sample").await?; codex - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Mention { + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + }, + ])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1291,13 +1256,7 @@ async fn explicitly_requested_mcp_waits_for_startup(request: ExplicitMcpRequest) }, }; codex - .submit(Op::UserInput { - items: vec![input], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![input])) .await?; tokio::time::sleep(Duration::from_millis(1200)).await; assert!( @@ -1372,16 +1331,12 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { let codex = Arc::clone(&test_codex.codex); codex - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Mention { + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + codex_protocol::user_input::UserInput::Mention { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + }, + ])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1427,16 +1382,10 @@ async fn explicit_plugin_skill_invocation_tracks_remote_plugin_id() -> Result<() let codex = Arc::clone(&test_codex.codex); codex - .submit(Op::UserInput { - items: vec![UserInput::Skill { - name: "sample:sample-search".into(), - path: skill_path, - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Skill { + name: "sample:sample-search".into(), + path: skill_path, + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1512,16 +1461,10 @@ async fn implicit_plugin_skill_invocation_tracks_remote_plugin_id( let codex = Arc::clone(&test_codex.codex); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "inspect the sample skill".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect the sample skill".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 3c0f5aa110..4938ff8486 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::Path; +use codex_core::TurnInputRequest; use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -17,7 +18,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::responses::ev_completed; @@ -167,30 +168,18 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { let base_instructions = model_info.get_model_instructions(config.personality); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -261,30 +250,18 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -340,30 +317,18 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -442,16 +407,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // First turn codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -467,7 +426,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an .expect("workspace profile should have legacy projection"); core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), @@ -480,16 +439,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an // Second turn after overrides codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 2".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 2".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -569,7 +522,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), model: Some("gpt-5.4".to_string()), effort: Some(Some(ReasoningEffort::Low)), @@ -580,16 +533,10 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first message".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first message".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -728,16 +675,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res // First turn codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello 1".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello 1".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -753,15 +694,12 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, new_cwd.path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(new_cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), @@ -770,8 +708,8 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -875,58 +813,52 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_summary = config.model_reasoning_summary; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: default_model.clone(), reasoning_effort: default_effort.clone(), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: default_model.clone(), reasoning_effort: default_effort, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1014,61 +946,55 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_summary = config.model_reasoning_summary; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello 1".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: default_model, reasoning_effort: default_effort, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, default_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello 2".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(default_cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, summary: Some(ReasoningSummary::Detailed), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: "o3".to_string(), reasoning_effort: Some(ReasoningEffort::High), developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 33f7d77fa7..7497a0f776 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -1,6 +1,6 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -40,16 +40,10 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "quota?".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "quota?".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 422137935c..c6b5634485 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use chrono::Utc; use codex_config::config_toml::RealtimeWsVersion; +use codex_core::TurnInputRequest; use codex_core::test_support::auth_manager_from_auth; use codex_history::InitialHistory; use codex_history::RolloutItem; @@ -3054,16 +3055,10 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { let user_text = "typed follow-up for realtime"; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: user_text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: user_text.to_string(), + text_elements: Vec::new(), + }])) .await?; let turn_complete = wait_for_event_match(&test.codex, |event| match event { @@ -4695,16 +4690,10 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { .await; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first prompt".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first prompt".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 2cbfb09f67..2c3bb036e8 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -6,6 +6,7 @@ use codex_api::AuthProvider; use codex_config::types::ApprovalsReviewer; use codex_core::EnvironmentConfig; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::WaitForEnvironmentToolConfig; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; @@ -39,6 +40,9 @@ use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; @@ -206,30 +210,27 @@ async fn submit_turn_with_approval_and_environments( environments, ); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(turn_environment_selections), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) @@ -540,16 +541,10 @@ async fn settings_update_does_not_retarget_active_turn_environment() -> Result<( let next_cwd = next_workspace.path().abs(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "pause before continuing".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "pause before continuing".into(), + text_elements: Vec::new(), + }])) .await?; let request = wait_for_event_match(&test.codex, |event| match event { EventMsg::RequestUserInput(request) => Some(request.clone()), @@ -676,22 +671,19 @@ async fn deferred_executor_promotes_primary_environment_when_startup_completes() ) .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "wait for the primary environment".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![remote_selection, local_selection], )), ..Default::default() - }, - }) + }), + ) .await?; let request = wait_for_event_match(&test.codex, |event| match event { EventMsg::RequestUserInput(request) => Some(request.clone()), @@ -1062,13 +1054,10 @@ async fn shared_executor_keeps_ready_capability_roots_scoped_to_each_attachment( } thread - .submit( - vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }] - .into(), - ) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; } @@ -1359,15 +1348,12 @@ async fn deferred_executor_stays_pending_after_materialization() -> Result<()> { )?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "wait for the environment".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![TurnEnvironmentSelection { @@ -1377,8 +1363,8 @@ async fn deferred_executor_stays_pending_after_materialization() -> Result<()> { }], )), ..Default::default() - }, - }) + }), + ) .await?; wait_for_response_request_count(&response_mock, /*expected_count*/ 1).await; assert_eq!(provider.calls.load(Ordering::Relaxed), 0); @@ -1503,22 +1489,19 @@ async fn deferred_executor_spawn_agent_inherits_ready_step_environments( let mut created_threads = test.thread_manager.subscribe_thread_created(); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "spawn after the environment becomes ready".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), expected_environments.clone(), )), ..Default::default() - }, - }) + }), + ) .await?; wait_for_response_request_count(&response_mock, /*expected_count*/ 1).await; attach_tx.send(()).expect("attach remote environment"); @@ -1655,15 +1638,12 @@ async fn deferred_executor_guardian_uses_newly_ready_step_environment() -> Resul turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "review a command after the remote environment becomes ready".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![remote_selection, local(local_cwd.clone())], @@ -1671,8 +1651,8 @@ async fn deferred_executor_guardian_uses_newly_ready_step_environment() -> Resul sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; wait_for_response_request_count(&responses, /*expected_count*/ 1).await; attach_tx.send(()).expect("attach remote environment"); @@ -1776,16 +1756,10 @@ async fn deferred_executor_loads_agents_md_when_environment_becomes_ready() -> R .context("thread startup should not wait for the remote environment")??; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "load the environment instructions".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "load the environment instructions".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_response_request_count(&response_mock, /*expected_count*/ 1).await; let agents_path = PathUri::from_abs_path(&test.config.cwd).join("AGENTS.md")?; @@ -1890,16 +1864,10 @@ async fn deferred_executor_compaction_preserves_then_updates_environment_once() .context("thread startup should not wait for the remote environment")??; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "wait for the environment".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "wait for the environment".into(), + text_elements: Vec::new(), + }])) .await?; let request = wait_for_event_match(&test.codex, |event| match event { EventMsg::RequestUserInput(request) => Some(request.clone()), @@ -2269,15 +2237,12 @@ async fn remote_exec_materializes_target_roots_before_sandbox_selection() -> Res let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "try to read the denied remote workspace root".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 { environments: Some(TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![ @@ -2296,17 +2261,17 @@ async fn remote_exec_materializes_target_roots_before_sandbox_selection() -> Res approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 1a8bd6cf4d..5268fa3193 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,5 +1,6 @@ #![cfg(not(target_os = "windows"))] use anyhow::Result; +use codex_core::TurnInputRequest; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; @@ -22,7 +23,7 @@ use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::load_default_config_for_test; @@ -206,16 +207,10 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "check context window".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }])) .await?; let turn_started_event = wait_for_event(&codex, |event| { @@ -273,16 +268,10 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "check context window".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }])) .await?; let turn_started_event = wait_for_event(&codex, |event| { @@ -339,16 +328,10 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "check context window".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "check context window".into(), + text_elements: Vec::new(), + }])) .await?; let turn_started_event = wait_for_event(&codex, |event| { @@ -418,16 +401,10 @@ async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "check model slug".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "check model slug".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -468,16 +445,10 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "check namespaced model metadata".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "check namespaced model metadata".into(), + text_elements: Vec::new(), + }])) .await?; let mut fallback_warning_count = 0; @@ -601,7 +572,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(REMOTE_MODEL_SLUG.to_string()), ..Default::default() }, @@ -631,23 +602,20 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run call".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, summary: Some(ReasoningSummary::Auto), ..Default::default() - }, - }) + }), + ) .await?; let begin_event = wait_for_event_match(&codex, |msg| match msg { @@ -876,30 +844,27 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello base".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, summary: Some(ReasoningSummary::Auto), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some(model.to_string()), ..Default::default() }, @@ -909,23 +874,20 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello remote".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, summary: Some(ReasoningSummary::Auto), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index e70346a5a3..567bed6461 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -1,9 +1,9 @@ #![cfg(not(target_os = "windows"))] +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -39,16 +39,10 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any let codex = builder.build(&server).await?.codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "compress me".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "compress me".into(), + text_elements: Vec::new(), + }])) .await?; // Wait until the task completes so the request definitely hit the server. @@ -89,16 +83,10 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> let codex = builder.build(&server).await?.codex; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "do not compress".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "do not compress".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index af2f32d864..de7733bca1 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1,10 +1,14 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::sandboxing::SandboxPermissions; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; @@ -15,6 +19,7 @@ use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -175,31 +180,28 @@ async fn submit_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index b02459c8cd..9538743eae 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -2,9 +2,13 @@ #![cfg(target_os = "macos")] use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -12,6 +16,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -144,31 +149,28 @@ async fn submit_turn( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index f37a412a1d..cdf58a608d 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -5,6 +5,7 @@ use anyhow::Result; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::ToolSuggestDiscoverableType; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_features::Feature; @@ -213,21 +214,18 @@ async fn start_gated_step_preparation(test: &TestCodex, server: &MockServer) -> let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "prepare MCP and plugin recommendations".to_string(), 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), sandbox_policy: Some(sandbox_policy), permission_profile, ..Default::default() - }, - }) + }), + ) .await?; let fs = test.fs(); @@ -262,15 +260,12 @@ async fn start_install_turn(test: &TestCodex, prompt: &str) -> Result Result anyhow::Resul turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), @@ -152,8 +151,8 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul }, }), ..Default::default() - }, - }) + }), + ) .await?; let request = wait_for_event_match(&codex, |event| match event { @@ -275,15 +274,12 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), @@ -297,8 +293,8 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re }, }), ..Default::default() - }, - }) + }), + ) .await?; let request = wait_for_event_match(&codex, |event| match event { @@ -379,23 +375,20 @@ where turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please confirm".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: Some(collaboration_mode), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index acbd62fa8e..e156cd8f31 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -3,11 +3,15 @@ use anyhow::Result; use anyhow::anyhow; +use codex_core::TurnInputRequest; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ResponseMock; use core_test_support::responses::ResponsesRequest; @@ -157,30 +161,27 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::workspace_write(), cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let turn_started = wait_for_event_result(test, "turn started", |event| { diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 315be6ea55..9876a1c4b0 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Context; use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; @@ -248,22 +249,16 @@ async fn responses_lite_prepares_images() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![ - UserInput::Image { - image_url: image_url.to_string(), - detail: Some(ImageDetail::Original), - }, - UserInput::Image { - image_url: remote_image_url.to_string(), - detail: Some(ImageDetail::High), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Image { + image_url: image_url.to_string(), + detail: Some(ImageDetail::Original), + }, + UserInput::Image { + image_url: remote_image_url.to_string(), + detail: Some(ImageDetail::High), + }, + ])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 759d3c5a5e..dcd9af433d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -1,6 +1,7 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -40,16 +41,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { )]; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Record some messages".into(), - text_elements: text_elements.clone(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Record some messages".into(), + text_elements: text_elements.clone(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -101,16 +96,10 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> mount_sse_once(&server, initial_sse).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Record reasoning messages".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Record reasoning messages".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -164,16 +153,10 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { let initial_mock = mount_sse_once(&server, initial_sse).await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Record initial instructions".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Record initial instructions".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let initial_body = initial_mock.single_request().body_json(); @@ -206,16 +189,10 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { let resumed = resume_builder.restart(&server, &initial).await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Resume with different model".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Resume with different model".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -224,16 +201,10 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Second turn after resume".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Second turn after resume".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -291,16 +262,10 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu ) .await; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Record initial instructions".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Record initial instructions".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let _ = initial_mock.single_request(); @@ -321,7 +286,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu let resumed = resume_builder.restart(&server, &initial).await?; core_test_support::submit_thread_settings( &resumed.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { model: Some("gpt-5.4".to_string()), ..Default::default() }, @@ -329,16 +294,10 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu .await?; resumed .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first turn after override".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first turn after override".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index e122f27fb4..2007b9edac 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1,5 +1,6 @@ use codex_core::CodexThread; use codex_core::REVIEW_PROMPT; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::Constrained; use codex_core::find_thread_path_by_id_str; @@ -1125,16 +1126,10 @@ async fn review_history_surfaces_in_parent_session() { // 2) Continue in the parent session; request input must not include any review items. let followup = "back to parent".to_string(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: followup.clone(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: followup.clone(), + text_elements: Vec::new(), + }])) .await .unwrap(); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1240,7 +1235,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(local_selections(repo_path.to_path_buf().abs())), ..Default::default() }, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index b6e900e2d0..33614db3e5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -22,6 +22,7 @@ use codex_config::types::McpServerConfig; use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; @@ -37,7 +38,10 @@ use codex_models_manager::manager::RefreshStrategy; use codex_utils_path_uri::LegacyAppPathString; use codex_history::RolloutItem; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; @@ -55,6 +59,7 @@ use codex_protocol::protocol::McpStartupFailureReason; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_cargo_bin::cargo_bin; use codex_utils_path_uri::PathUri; @@ -115,7 +120,7 @@ fn assert_wall_time_header(output: &str) { assert_eq!(marker, "Output:"); } -fn read_only_user_turn(fixture: &TestCodex, text: impl Into) -> Op { +fn read_only_user_turn(fixture: &TestCodex, text: impl Into) -> TurnInputRequest { read_only_user_turn_with_model(fixture, text, fixture.session_configured.model.clone()) } @@ -123,11 +128,11 @@ fn read_only_user_turn_with_model( fixture: &TestCodex, text: impl Into, model: String, -) -> Op { +) -> TurnInputRequest { user_turn_with_permission_profile(fixture, text, model, PermissionProfile::read_only()) } -fn auto_approved_user_turn(fixture: &TestCodex, text: impl Into) -> Op { +fn auto_approved_user_turn(fixture: &TestCodex, text: impl Into) -> TurnInputRequest { user_turn_with_permission_profile( fixture, text, @@ -141,33 +146,28 @@ fn user_turn_with_permission_profile( text: impl Into, model: String, permission_profile: PermissionProfile, -) -> Op { +) -> TurnInputRequest { let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, cwd.as_path()); - Op::UserInput { - items: vec![UserInput::Text { - text: text.into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }) } #[derive(Debug, PartialEq, Eq)] @@ -390,7 +390,7 @@ async fn call_structured_tool( fixture .codex - .submit(read_only_user_turn(fixture, "call the requested rmcp tool")) + .start_or_steer_turn(read_only_user_turn(fixture, "call the requested rmcp tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -540,7 +540,7 @@ async fn mcp_namespace_instructions_are_preserved_without_hiding_tools() -> anyh fixture .codex - .submit(read_only_user_turn(&fixture, "show the bounded MCP tools")) + .start_or_steer_turn(read_only_user_turn(&fixture, "show the bounded MCP tools")) .await?; wait_for_event(&fixture.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -650,7 +650,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { fixture .codex - .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) + .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool")) .await?; let begin_event = wait_for_event(&fixture.codex, |ev| { @@ -951,7 +951,7 @@ async fn modern_mcp_pagination_preserves_valid_tools_and_rejects_oversized_curso fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "show the paginated MCP tools", )) @@ -1159,7 +1159,7 @@ async fn interrupt_during_mcp_startup_preserves_user_input_in_history( let prompt = "keep this interrupted prompt in conversation history"; fixture .codex - .submit(read_only_user_turn(&fixture, prompt)) + .start_or_steer_turn(read_only_user_turn(&fixture, prompt)) .await?; wait_for_event(&fixture.codex, |event| { matches!(event, EventMsg::TurnStarted(_)) @@ -1546,7 +1546,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: // Keep this baseline on the mutable sync tool so read-only hints do not // make the call parallel-safe. Bypass read-only turn permissions so // approval behavior does not block the scheduling assertion. - .submit(auto_approved_user_turn( + .start_or_steer_turn(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) @@ -1684,7 +1684,7 @@ async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in() fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp sync_readonly tool twice", )) @@ -1777,7 +1777,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res // Exercise the server opt-in with the mutable sync tool rather than the // read-only sync_readonly tool. Bypass read-only turn permissions so // approval behavior does not block the scheduling assertion. - .submit(auto_approved_user_turn( + .start_or_steer_turn(auto_approved_user_turn( &fixture, "call the rmcp sync tool twice", )) @@ -1857,7 +1857,7 @@ async fn stdio_encrypted_content_responses_round_trip() -> anyhow::Result<()> { fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp encrypted output tool", )) @@ -1956,7 +1956,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { fixture .codex - .submit(read_only_user_turn(&fixture, "call the rmcp image tool")) + .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp image tool")) .await?; // Wait for tool begin/end and final completion. @@ -2113,7 +2113,7 @@ async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> { fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp image_scenario tool", )) @@ -2201,7 +2201,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp image_scenario tool", )) @@ -2362,7 +2362,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re fixture .codex - .submit(read_only_user_turn_with_model( + .start_or_steer_turn(read_only_user_turn_with_model( &fixture, "call the rmcp image tool", text_only_model_slug.to_string(), @@ -2463,7 +2463,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { fixture .codex - .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) + .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool")) .await?; let begin_event = wait_for_event(&fixture.codex, |ev| { @@ -2588,7 +2588,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu fixture .codex - .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) + .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -2684,7 +2684,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result fixture .codex - .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) + .start_or_steer_turn(read_only_user_turn(&fixture, "call the rmcp echo tool")) .await?; wait_for_event(&fixture.codex, |ev| { @@ -2886,7 +2886,7 @@ async fn streamable_http_tool_call_round_trip(with_headers_helper: bool) -> anyh // Phase 4: submit the user turn that should trigger the MCP tool call. fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp streamable http echo tool", )) @@ -3317,7 +3317,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { store_lock.try_lock()?; fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "continue while OAuth credentials are locked", )) @@ -3397,7 +3397,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { .await; fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "continue while a newly discovered OAuth server is starting", )) @@ -3466,7 +3466,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture .codex - .submit(read_only_user_turn( + .start_or_steer_turn(read_only_user_turn( &fixture, "call the rmcp streamable http oauth echo tool", )) diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 9bbf98f1fe..991cac1d9c 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_core::config::RolloutBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; @@ -141,16 +142,10 @@ async fn invalid_provider_rollout_budget_units_fail_without_retry() -> Result<() .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "reject invalid provider budget units".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "reject invalid provider budget units".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::Error(error) = @@ -312,16 +307,10 @@ async fn exhausted_budget_fails_current_and_later_turns() -> Result<()> { for prompt in ["exhaust the budget", "try another turn"] { test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs index a3a13e75c9..bb3979e603 100644 --- a/codex-rs/core/tests/suite/safety_buffering.rs +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -1,6 +1,6 @@ use anyhow::Ok; +use codex_core::TurnInputRequest; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SafetyBufferingEvent; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; @@ -48,16 +48,10 @@ async fn emits_safety_buffering_from_response_metadata_with_the_header_fallback_ let test = test_codex().build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Check this request".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Check this request".into(), + text_elements: Vec::new(), + }])) .await?; let event = wait_for_event_match(&test.codex, |event| match event { @@ -103,16 +97,10 @@ async fn emits_safety_buffering_with_the_responses_api_model_without_header_gati let test = test_codex().build(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Check this request".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Check this request".into(), + text_elements: Vec::new(), + }])) .await?; let event = wait_for_event_match(&test.codex, |event| match event { diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 178ddb1750..609caa113e 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -1,4 +1,8 @@ use anyhow::Result; +use codex_core::TurnInputRequest; +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::PermissionProfile; use codex_protocol::models::ResponseItem; @@ -7,7 +11,7 @@ use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ModelRerouteReason; use codex_protocol::protocol::ModelVerification; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_function_call; @@ -35,33 +39,28 @@ const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; const CYBER_POLICY_MESSAGE: &str = "This request has been flagged for potentially high-risk cyber activity."; -fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { +fn disabled_text_turn(test: &TestCodex, text: &str) -> TurnInputRequest { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); - Op::UserInput { - items: vec![UserInput::Text { - text: text.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(test.config.cwd.clone())), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort.clone(), - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(test.config.cwd.clone())), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: test.config.model_reasoning_effort.clone(), + developer_instructions: None, + }, + }), + ..Default::default() + }) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -77,7 +76,7 @@ async fn openai_model_header_mismatch_emits_warning_event() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger safety check")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger safety check")) .await?; let reroute = wait_for_event(&test.codex, |event| { @@ -125,7 +124,7 @@ async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger cyber policy error")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger cyber policy error")) .await?; let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await; @@ -164,7 +163,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger response model check")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger response model check")) .await?; let reroute = wait_for_event(&test.codex, |event| { @@ -234,7 +233,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger follow-up turn")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger follow-up turn")) .await?; let mut warning_count = 0; @@ -272,7 +271,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger casing check")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger casing check")) .await?; let mut reroute_count = 0; @@ -315,7 +314,7 @@ async fn model_verification_emits_structured_event_without_reroute_or_warning() let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn(&test, "trigger model verification")) + .start_or_steer_turn(disabled_text_turn(&test, "trigger model verification")) .await?; let mut verification_count = 0; @@ -391,7 +390,7 @@ async fn model_verification_only_emits_once_per_turn() -> Result<()> { let test = builder.build(&server).await?; test.codex - .submit(disabled_text_turn( + .start_or_steer_turn(disabled_text_turn( &test, "trigger follow-up model verification", )) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 05b9c84a82..3e1e79b4ff 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -4,6 +4,7 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; @@ -598,16 +599,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build_with_auto_env(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Find the calendar create tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Find the calendar create tool".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| { @@ -1157,16 +1152,10 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - test.session_configured = new_thread.session_configured; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Use the automation tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Use the automation tool".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| { @@ -1489,16 +1478,10 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< wait_for_mcp_server(&test.codex, "rmcp").await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Find the rmcp echo tool and call it.".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Find the rmcp echo tool and call it.".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| { @@ -1822,16 +1805,10 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch test.session_configured = new_thread.session_configured; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Search for the dynamic tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Search for the dynamic tool".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index bd8f86261b..ca6eeb389a 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -1,13 +1,18 @@ use anyhow::Result; +use codex_core::TurnInputRequest; #[cfg(target_os = "macos")] use codex_core::shell::get_shell_by_model_provided_path; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -162,30 +167,27 @@ async fn run_snapshot_command_with_options( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run unified exec with shell snapshot".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let begin = wait_for_event_match(&codex, |ev| match ev { @@ -260,30 +262,27 @@ async fn run_shell_command_snapshot_with_options( turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run shell_command with shell snapshot".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let begin = wait_for_event_match(&codex, |ev| match ev { @@ -339,30 +338,27 @@ async fn run_tool_turn_on_harness( let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event_match(&codex, |ev| match ev { @@ -582,30 +578,27 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "apply patch via shell_command with snapshot".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_patch_begin = false; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 50ea9495ac..ba487a7964 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -2,12 +2,16 @@ #![cfg(unix)] use anyhow::Result; +use codex_core::TurnInputRequest; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::mount_function_call_agent_response; use core_test_support::responses::start_mock_server; @@ -47,30 +51,27 @@ async fn submit_turn_with_policies( let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.cwd_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 373f90e43b..a21787cf4b 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -1,14 +1,19 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_core::StartIfIdleSubmission; use codex_core::TurnInput; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_extension_api::ExtensionRegistryBuilder; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_skills_extension::SkillsExtensionConfig; use codex_skills_extension::install; @@ -24,6 +29,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use std::sync::Arc; @@ -88,8 +94,8 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![ + .start_or_steer_turn( + TurnInputRequest::user_input(vec![ UserInput::Text { text: "please use $demo".to_string(), text_elements: Vec::new(), @@ -98,25 +104,23 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { name: "demo".to_string(), path: skill_path.clone(), }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + ]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; core_test_support::wait_for_event(test.codex.as_ref(), |event| { @@ -192,8 +196,9 @@ async fn user_turn_selects_symlinked_skill_by_advertised_discovery_path() -> Res ) .await; - test.codex - .try_start_turn_if_idle(vec![TurnInput::UserInput { + let submission = test + .codex + .start_turn_if_idle(TurnInputRequest::new(TurnInput::UserInput { content: vec![ UserInput::Text { text: format!("please use [$linked-demo]({discovery_path_display})"), @@ -205,11 +210,9 @@ async fn user_turn_selects_symlinked_skill_by_advertised_discovery_path() -> Res }, ], client_id: Some("linked-skill-user-message".to_string()), - }]) - .await - .map_err(|error| { - anyhow::anyhow!("linked skill input was rejected: {:?}", error.reason()) - })?; + })) + .await?; + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); core_test_support::wait_for_event(test.codex.as_ref(), |event| { matches!(event, codex_protocol::protocol::EventMsg::TurnComplete(_)) @@ -274,8 +277,9 @@ async fn idle_user_turn_includes_skill_instructions_in_the_first_request() -> Re ) .await; - test.codex - .try_start_turn_if_idle(vec![TurnInput::UserInput { + let submission = test + .codex + .start_turn_if_idle(TurnInputRequest::new(TurnInput::UserInput { content: vec![ UserInput::Text { text: "please use $queued-demo".to_string(), @@ -287,9 +291,9 @@ async fn idle_user_turn_includes_skill_instructions_in_the_first_request() -> Re }, ], client_id: Some("queued-skill-user-message".to_string()), - }]) - .await - .map_err(|error| anyhow::anyhow!("idle skill input was rejected: {:?}", error.reason()))?; + })) + .await?; + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); core_test_support::wait_for_event(test.codex.as_ref(), |event| { matches!(event, codex_protocol::protocol::EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index 7b9367ec4d..9e356e18e5 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -7,6 +7,7 @@ use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -18,7 +19,6 @@ use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use codex_skills_extension::HostSkillProvider; use codex_skills_extension::OrchestratorSkillProvider; @@ -450,16 +450,10 @@ async fn rendered_catalogs_for_turns( let mut client_warning_messages = Vec::new(); for _ in 0..turn_count { test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "Inspect the available skills.".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Inspect the available skills.".to_string(), + text_elements: Vec::new(), + }])) .await?; loop { match core_test_support::wait_for_event(&test.codex, |_| true).await { @@ -587,30 +581,24 @@ async fn capability_sections_render_in_order_with_host_repo_and_plugin_skills() .to_path_buf(); test.codex - .submit(Op::UserInput { - items: vec![ - UserInput::Text { - text: "use all skills".to_string(), - text_elements: Vec::new(), - }, - UserInput::Skill { - name: "host-search".to_string(), - path: host_skill_path.clone(), - }, - UserInput::Skill { - name: "repo-search".to_string(), - path: repo_skill_path.clone(), - }, - UserInput::Skill { - name: "sample:sample-search".to_string(), - path: plugin_skill_path.clone(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Text { + text: "use all skills".to_string(), + text_elements: Vec::new(), + }, + UserInput::Skill { + name: "host-search".to_string(), + path: host_skill_path.clone(), + }, + UserInput::Skill { + name: "repo-search".to_string(), + path: repo_skill_path.clone(), + }, + UserInput::Skill { + name: "sample:sample-search".to_string(), + path: plugin_skill_path.clone(), + }, + ])) .await?; core_test_support::wait_for_event(&test.codex, |event| { @@ -718,16 +706,10 @@ async fn agent_plugin_skill_prompt_stays_bounded_without_skills_extension() -> R let test = builder.build_with_auto_env(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Skill { - name: "acme.tools:review".into(), - path: skill_path, - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Skill { + name: "acme.tools:review".into(), + path: skill_path, + }])) .await?; let warning = core_test_support::wait_for_event(&test.codex, |event| { matches!( @@ -792,22 +774,16 @@ async fn explicit_skill_prompt_precedes_plugin_instructions() -> Result<()> { let test = builder.build_with_auto_env(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![ - UserInput::Skill { - name: "sample:sample-search".to_string(), - path: skill_path, - }, - UserInput::Mention { - name: "sample".to_string(), - path: "plugin://sample@test".to_string(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Skill { + name: "sample:sample-search".to_string(), + path: skill_path, + }, + UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + ])) .await?; core_test_support::wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1972,22 +1948,16 @@ async fn production_turn_warns_and_omits_unreadable_host_skill() -> Result<()> { std::fs::remove_file(&missing_skill_path)?; test.codex - .submit(Op::UserInput { - items: vec![ - UserInput::Skill { - name: "missing-host".to_string(), - path: missing_skill_path.clone(), - }, - UserInput::Skill { - name: "available-host".to_string(), - path: available_skill_path.clone(), - }, - ], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + UserInput::Skill { + name: "missing-host".to_string(), + path: missing_skill_path.clone(), + }, + UserInput::Skill { + name: "available-host".to_string(), + path: available_skill_path.clone(), + }, + ])) .await?; let mut warnings = Vec::new(); @@ -2186,16 +2156,10 @@ async fn production_turn_keeps_orchestrator_world_state_incremental_across_turns ] { orchestrator_thread .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }])) .await?; core_test_support::wait_for_event(&orchestrator_thread.thread, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 84ff379f31..9fabd70bcd 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_extension_api::ExtensionRegistryBuilder; use codex_features::Feature; @@ -9,6 +10,9 @@ use codex_history::RolloutItem; use codex_history::RolloutLine; use codex_login::CodexAuth; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; @@ -21,6 +25,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::UserInput; use codex_web_search_extension::install as install_web_search_extension; @@ -165,16 +170,10 @@ async fn resume_restores_dynamic_tools_from_rollout_with_sqlite_enabled() -> Res started .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "persist this thread".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "persist this thread".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&started.thread, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -263,16 +262,10 @@ async fn resume_restores_legacy_dynamic_tools_from_rollout_with_sqlite_enabled() started .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "persist this thread".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "persist this thread".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&started.thread, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -710,30 +703,27 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "call the rmcp echo tool".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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::McpToolCallEnd(_)) diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index f93a4b66e6..32ebbf2979 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,7 +1,7 @@ +use codex_core::TurnInputRequest; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -94,16 +94,10 @@ async fn continue_after_stream_error() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "first message".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "first message".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -125,16 +119,10 @@ async fn continue_after_stream_error() { // mock server SSE stream. If the agent failed to clear the running task on // error above, this submission would be rejected/queued indefinitely. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "follow up".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 69f7852f9a..d9741d0381 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -1,10 +1,10 @@ //! Verifies that the agent retries when the SSE stream terminates before //! delivering a `response.completed` event. +use codex_core::TurnInputRequest; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -79,16 +79,10 @@ async fn retries_on_early_close() { .unwrap(); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }])) .await .unwrap(); @@ -126,16 +120,10 @@ async fn connection_failure_pauses_retry_budget_until_provider_is_reachable() -> .await?; codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "recover after the network returns".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "recover after the network returns".into(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::StreamError(connection_error) = diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index fe02afc684..1f6b171ee5 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::StartThreadOptions; use codex_core::ThreadConfigSnapshot; +use codex_core::TurnInputRequest; use codex_core::config::AgentRoleConfig; use codex_features::Feature; use codex_history::RolloutItem; @@ -16,6 +17,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadHistoryMode; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; @@ -812,23 +814,20 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); internal_thread .thread - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: INTERNAL_SUBAGENT_PROMPT.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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, model: Some(internal_thread.session_configured.model.clone()), ..Default::default() - }, - }) + }), + ) .await?; let turn_id = wait_for_event_match(internal_thread.thread.as_ref(), |event| match event { EventMsg::TurnStarted(event) => Some(event.turn_id.clone()), diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 4a602b2d76..93193e7e0d 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::TokenBudgetConfig; use codex_extension_api::ExtensionRegistryBuilder; @@ -428,16 +429,10 @@ async fn token_budget_defaults_follow_the_active_model() -> Result<()> { ) .await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "inspect second-model guidance".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: ThreadSettingsOverrides::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "inspect second-model guidance".to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 963123b59e..fb35299931 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,15 +1,19 @@ #![cfg(not(target_os = "windows"))] +use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::fs; use assert_matches::assert_matches; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::assert_regex_match; @@ -96,30 +100,27 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please run the shell command".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -177,30 +178,27 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_plan_update = false; @@ -268,30 +266,27 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please update the plan".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_plan_update = false; @@ -369,30 +364,27 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_file_change_started = false; @@ -507,30 +499,27 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "please apply a patch".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd_path)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index a11ee5c2ee..1958df84fe 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -1,15 +1,19 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used)] +use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::fs; use std::time::Duration; use std::time::Instant; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -38,30 +42,27 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -363,30 +364,27 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "stream delayed completion".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let _ = first_gate_tx.send(()); diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 2374220d7c..4c8822698a 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -8,6 +8,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use codex_core::StartThreadOptions; +use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::sandboxing::SandboxPermissions; use codex_features::Feature; @@ -23,7 +24,6 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; @@ -133,16 +133,10 @@ async fn strict_tool_collisions_fail_the_turn_before_sampling( .thread; thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "use the planning tool".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "use the planning tool".to_string(), + text_elements: Vec::new(), + }])) .await?; let EventMsg::Error(error) = @@ -196,16 +190,10 @@ async fn strict_tool_collisions_do_not_duplicate_unrelated_compaction_errors() - let test = builder.build_with_auto_env(&server).await?; test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "trigger compaction".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "trigger compaction".to_string(), + text_elements: Vec::new(), + }])) .await?; let mut errors = Vec::new(); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index ad15a0d0d8..8d87942e08 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -5,10 +5,14 @@ use anyhow::Context; use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::TurnInputRequest; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::assert_regex_match; @@ -524,30 +528,27 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { fixture .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "call the rmcp image tool".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(fixture.cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; // Wait for completion to ensure the outbound request is captured. diff --git a/codex-rs/core/tests/suite/turn_input_submission.rs b/codex-rs/core/tests/suite/turn_input_submission.rs new file mode 100644 index 0000000000..c40085fc21 --- /dev/null +++ b/codex-rs/core/tests/suite/turn_input_submission.rs @@ -0,0 +1,385 @@ +use codex_core::NotSubmittedReason; +use codex_core::StartIfIdleSubmission; +use codex_core::SteerSubmission; +use codex_core::TurnInput; +use codex_core::TurnInputRequest; +use codex_core::TurnInputSubmission; +use codex_core::TurnStartOptions; +use codex_core::config::Constrained; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Barrier; +use tokio::sync::oneshot; +use tokio::time::timeout; + +fn user_message_request(text: &str) -> TurnInputRequest { + TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }]) +} + +async fn submit_user_message( + codex: &codex_core::CodexThread, + text: &str, +) -> codex_protocol::error::Result { + codex.start_or_steer_turn(user_message_request(text)).await +} + +#[tokio::test] +async fn start_turn_if_idle_rejects_non_user_input_that_requests_plan_mode() { + let server = responses::start_mock_server().await; + let test = test_codex() + .build_with_auto_env(&server) + .await + .expect("build turn-input submission session"); + let original_collaboration_mode = test.codex.config_snapshot().await.collaboration_mode; + + let submission = test + .codex + .start_turn_if_idle( + TurnInputRequest::new(TurnInput::ResponseItem(responses::user_message_item( + "automatic input", + ))) + .with_thread_settings(ThreadSettingsOverrides { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }), + ) + .await + .expect("idle turn submission should return a typed rejection"); + + assert_eq!( + submission, + StartIfIdleSubmission::NotSubmitted { + reason: NotSubmittedReason::PlanMode, + } + ); + assert_eq!( + test.codex.config_snapshot().await.collaboration_mode, + original_collaboration_mode + ); +} + +/// Concurrent submissions must start exactly one turn and steer the other message. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_input_submission_reports_started_and_steered_for_concurrent_submissions() { + let (release_response, response_gate) = oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![ + vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-1")]), + }, + StreamingSseChunk { + gate: Some(response_gate), + body: responses::sse(vec![ev_completed("resp-1")]), + }, + ], + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + }], + ]) + .await; + let test = test_codex() + .with_model("gpt-5.4") + .build_with_streaming_server(&server) + .await + .expect("build turn-input submission session"); + let codex = Arc::clone(&test.codex); + let barrier = Arc::new(Barrier::new(3)); + + let first_submission = tokio::spawn({ + let codex = Arc::clone(&codex); + let barrier = Arc::clone(&barrier); + async move { + barrier.wait().await; + submit_user_message(codex.as_ref(), "first message").await + } + }); + let second_submission = tokio::spawn({ + let codex = Arc::clone(&codex); + let barrier = Arc::clone(&barrier); + async move { + barrier.wait().await; + submit_user_message(codex.as_ref(), "second message").await + } + }); + barrier.wait().await; + + timeout( + Duration::from_secs(5), + server.wait_for_request_count(/*count*/ 1), + ) + .await + .expect("the started turn should reach its first model request"); + release_response + .send(()) + .expect("response gate should remain open"); + + let (first_submission, second_submission) = timeout(Duration::from_secs(5), async { + tokio::join!(first_submission, second_submission) + }) + .await + .expect("both concurrent submissions should resolve once their messages are submitted"); + let first_submission = first_submission + .expect("first submission task should finish") + .expect("first user message should be submitted"); + let second_submission = second_submission + .expect("second submission task should finish") + .expect("second user message should be submitted"); + let (started_turn_id, steered_turn_id, started_message) = + match (&first_submission, &second_submission) { + ( + TurnInputSubmission::Started { turn_id: started }, + TurnInputSubmission::Steered { turn_id: steered }, + ) => (started, steered, "first message"), + ( + TurnInputSubmission::Steered { turn_id: steered }, + TurnInputSubmission::Started { turn_id: started }, + ) => (started, steered, "second message"), + _ => panic!( + "concurrent messages must start exactly one turn and steer the other: \ + {first_submission:?}, {second_submission:?}" + ), + }; + assert_eq!(started_turn_id, steered_turn_id); + + wait_for_event(codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = server.requests().await; + assert_eq!(requests.len(), 2); + let request_bodies: Vec = requests + .iter() + .map(|request| serde_json::from_slice(request).expect("parse model request")) + .collect(); + assert!(request_bodies[0].to_string().contains(started_message)); + assert!(request_bodies[1].to_string().contains("first message")); + assert!(request_bodies[1].to_string().contains("second message")); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_input_submission_applies_thread_settings_only_after_accepted_input() { + let (release_response, response_gate) = oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![ + vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-1")]), + }, + StreamingSseChunk { + gate: Some(response_gate), + body: responses::sse(vec![ev_completed("resp-1")]), + }, + ], + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + }], + ]) + .await; + let test = test_codex() + .with_model("gpt-5.4") + .with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }) + .build_with_streaming_server(&server) + .await + .expect("build approval-constrained turn-input submission session"); + let codex = &test.codex; + + let started = submit_user_message(codex, "start turn") + .await + .expect("first message should start a turn"); + let TurnInputSubmission::Started { turn_id } = started else { + panic!("first message should start a turn"); + }; + timeout( + Duration::from_secs(5), + server.wait_for_request_count(/*count*/ 1), + ) + .await + .expect("started turn should reach its first model request"); + + let steered = codex + .start_or_steer_turn( + user_message_request("steer active turn").with_thread_settings( + ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + ..Default::default() + }, + ), + ) + .await + .expect("persistent settings should not reject a steer"); + assert_eq!(steered, TurnInputSubmission::Steered { turn_id }); + assert_eq!( + codex.config_snapshot().await.approval_policy, + AskForApproval::Never + ); + + release_response + .send(()) + .expect("response gate should remain open"); + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let rejected = codex + .steer_turn( + user_message_request("no active turn").with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::OnRequest), + ..Default::default() + }), + "missing-turn".to_string(), + ) + .await + .expect("idle steer should return a typed rejection"); + assert_eq!( + rejected, + SteerSubmission::NotSubmitted { + reason: NotSubmittedReason::NoActiveTurn, + } + ); + assert_eq!( + codex.config_snapshot().await.approval_policy, + AskForApproval::Never + ); + server.shutdown().await; +} + +#[tokio::test] +async fn start_or_steer_turn_requires_matching_active_output_schema() { + let (release_response, response_gate) = oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![ + vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-1")]), + }, + StreamingSseChunk { + gate: Some(response_gate), + body: responses::sse(vec![ev_completed("resp-1")]), + }, + ], + vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + }], + ]) + .await; + let test = test_codex() + .with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + }) + .build_with_streaming_server(&server) + .await + .expect("build turn-input submission session"); + let codex = &test.codex; + let active_schema: Value = serde_json::from_str( + r#"{"type":"object","properties":{"answer":{"type":"string"},"count":{"type":"number"}},"required":["answer","count"]}"#, + ) + .expect("parse active schema"); + let matching_schema_with_different_object_order: Value = serde_json::from_str( + r#"{"required":["answer","count"],"properties":{"count":{"type":"number"},"answer":{"type":"string"}},"type":"object"}"#, + ) + .expect("parse matching schema"); + let different_schema: Value = serde_json::from_str( + r#"{"type":"object","properties":{"answer":{"type":"number"}},"required":["answer"]}"#, + ) + .expect("parse different schema"); + + let started = codex + .start_or_steer_turn( + user_message_request("start turn").on_start(TurnStartOptions { + final_output_json_schema: Some(active_schema), + ..Default::default() + }), + ) + .await + .expect("first message should start a turn"); + let TurnInputSubmission::Started { turn_id } = started else { + panic!("first message should start a turn"); + }; + timeout( + Duration::from_secs(5), + server.wait_for_request_count(/*count*/ 1), + ) + .await + .expect("started turn should reach its first model request"); + + let rejected = codex + .start_or_steer_turn( + user_message_request("rejected steer") + .with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + ..Default::default() + }) + .on_start(TurnStartOptions { + final_output_json_schema: Some(different_schema), + ..Default::default() + }), + ) + .await + .expect("schema mismatch should return a typed rejection"); + assert_eq!( + rejected, + TurnInputSubmission::NotSubmitted { + reason: NotSubmittedReason::ActiveTurnOutputSchemaMismatch, + } + ); + assert_eq!( + codex.config_snapshot().await.approval_policy, + AskForApproval::OnRequest + ); + + let steered = codex + .start_or_steer_turn( + user_message_request("accepted steer").on_start(TurnStartOptions { + final_output_json_schema: Some(matching_schema_with_different_object_order), + ..Default::default() + }), + ) + .await + .expect("matching schema should steer"); + assert_eq!(steered, TurnInputSubmission::Steered { turn_id }); + + release_response + .send(()) + .expect("response gate should remain open"); + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let requests = server.requests().await; + assert_eq!(requests.len(), 2); + let second_request = String::from_utf8_lossy(&requests[1]); + assert!(second_request.contains("accepted steer")); + assert!(!second_request.contains("rejected steer")); + server.shutdown().await; +} diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 0bae92cead..f259dd0207 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,3 +1,4 @@ +use codex_core::TurnInputRequest; use core_test_support::test_codex::local_selections; use std::collections::HashMap; use std::ffi::OsStr; @@ -8,6 +9,9 @@ use anyhow::Context; use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -16,6 +20,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use codex_utils_output_truncation::approx_tokens_from_byte_count; use codex_utils_path_uri::PathUri; @@ -198,29 +203,26 @@ async fn submit_unified_exec_turn( turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) @@ -342,30 +344,27 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, &cwd); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "apply patch via unified exec".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_patch_begin = false; @@ -2605,30 +2604,27 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let begin_event = wait_for_event_match(&codex, |msg| match msg { @@ -2708,30 +2704,27 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "interrupt long-running unified exec".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let _begin_event = wait_for_event_match(&codex, |msg| match msg { @@ -3212,30 +3205,27 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "summarize large output".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -3335,30 +3325,27 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "read the fixture files".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; @@ -3473,30 +3460,27 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "start python under seatbelt".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(turn_cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index 46aea1ee2b..54d02a2b1b 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -3,8 +3,12 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::test_support::CloudConfigBundleFixture; +use codex_core::TurnInputRequest; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -16,6 +20,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; @@ -681,15 +686,12 @@ timeout = 900 let (sandbox_policy, permission_profile) = turn_permission_fields(turn_permission_profile, test.config.cwd.as_path()); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run a one-shot remote command".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: foreign_cwd.then(|| { let cwd = PathUri::parse("file:///C:/workspace").expect("valid Windows cwd"); TurnEnvironmentSelections::new( @@ -720,17 +722,17 @@ timeout = 900 ), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: test.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut saw_exec_command_begin = false; let mut saw_patch_denial_approval = false; diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs index 8f338b4a30..fd23f006bf 100644 --- a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -3,6 +3,7 @@ use anyhow::Result; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::types::ApprovalsReviewer; +use codex_core::TurnInputRequest; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -740,15 +741,12 @@ async fn submit_turn_with_session_permissions( test.cwd.path(), ); test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.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 { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer), @@ -763,8 +761,8 @@ async fn submit_turn_with_session_permissions( }, }), ..Default::default() - }, - }) + }), + ) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/user_message_admission.rs b/codex-rs/core/tests/suite/user_message_admission.rs deleted file mode 100644 index 289476b7e2..0000000000 --- a/codex-rs/core/tests/suite/user_message_admission.rs +++ /dev/null @@ -1,452 +0,0 @@ -use codex_core::UserMessageAdmission; -use codex_core::config::Constrained; -use codex_protocol::error::CodexErrorDetails; -use codex_protocol::items::TurnItem; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::CodexErrorInfo; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::ThreadHistoryMode; -use codex_protocol::user_input::UserInput; -use core_test_support::responses; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_response_created; -use core_test_support::streaming_sse::StreamingSseChunk; -use core_test_support::streaming_sse::start_streaming_sse_server; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::Value; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Barrier; -use tokio::sync::oneshot; -use tokio::time::timeout; - -fn user_input(text: &str) -> Op { - Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } -} - -fn empty_user_input() -> Op { - Op::UserInput { - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } -} - -#[tokio::test] -async fn user_message_admission_starts_turn_for_empty_input() { - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_once( - &server, - responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; - let test = test_codex() - .with_model("gpt-5.4") - .build_with_auto_env(&server) - .await - .expect("build empty user-message admission session"); - - let admission = test - .codex - .submit_user_input_and_wait_for_admission( - empty_user_input(), - /*trace*/ None, - Some("clock-wake".to_string()), - ) - .await - .expect("empty user input should start a turn"); - assert!(matches!(admission, UserMessageAdmission::Started { .. })); - - loop { - let event = timeout(Duration::from_secs(10), test.codex.next_event()) - .await - .expect("empty-input turn should finish promptly") - .expect("empty-input turn event stream should remain available"); - match event.msg { - EventMsg::ItemStarted(event) => { - assert!(!matches!(event.item, TurnItem::UserMessage(_))); - } - EventMsg::ItemCompleted(event) => { - assert!(!matches!(event.item, TurnItem::UserMessage(_))); - } - EventMsg::Error(error) => panic!("empty-input turn failed: {error:?}"), - EventMsg::StreamError(error) => { - panic!("empty-input turn stream failed: {error:?}") - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - - let request = response_mock.single_request(); - responses::assert_root_turn(&request.body_json(), /*expected*/ None) - .expect("synthetic clock wake must not become a root"); - let user_input_groups = request.message_input_text_groups("user"); - assert_eq!(user_input_groups.len(), 1); - assert_eq!(user_input_groups[0].len(), 1); - assert!(user_input_groups[0][0].starts_with("")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn default_turn_start_persists_developer_and_user_input_before_model_request() { - let (release_response, response_gate) = oneshot::channel(); - let (server, _completions) = start_streaming_sse_server(vec![vec![StreamingSseChunk { - gate: Some(response_gate), - body: responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - }]]) - .await; - let test = test_codex() - .with_model("gpt-5.4") - .with_history_mode(ThreadHistoryMode::Paginated) - .build_with_streaming_server(&server) - .await - .expect("build default thread-store session"); - test.codex - .inject_response_items_for_turn(vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "turn-start developer instructions".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }]) - .await - .expect("inject developer instructions"); - test.codex - .submit(user_input("turn-start user input")) - .await - .expect("submit user input"); - - timeout( - Duration::from_secs(5), - server.wait_for_request_count(/*count*/ 1), - ) - .await - .expect("turn should reach the model request"); - let rollout_path = test.codex.rollout_path().expect("local rollout path"); - let rollout = tokio::fs::read_to_string(rollout_path) - .await - .expect("read rollout while the model response is blocked"); - assert!(rollout.contains("turn-start developer instructions")); - assert!(rollout.contains("turn-start user input")); - - release_response.send(()).expect("release model response"); - loop { - let event = timeout(Duration::from_secs(5), test.codex.next_event()) - .await - .expect("turn should finish") - .expect("event stream should remain available"); - if matches!(event.msg, EventMsg::TurnComplete(_)) { - break; - } - } - server.shutdown().await; -} - -/// Concurrent submissions must start exactly one turn and steer the other message. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_message_admission_reports_started_and_steered_for_concurrent_submissions() { - let (release_response, response_gate) = oneshot::channel(); - let (server, _completions) = start_streaming_sse_server(vec![ - vec![ - StreamingSseChunk { - gate: None, - body: responses::sse(vec![ev_response_created("resp-1")]), - }, - StreamingSseChunk { - gate: Some(response_gate), - body: responses::sse(vec![ev_completed("resp-1")]), - }, - ], - vec![StreamingSseChunk { - gate: None, - body: responses::sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), - }], - ]) - .await; - let test = test_codex() - .with_model("gpt-5.4") - .with_history_mode(ThreadHistoryMode::Paginated) - .build_with_streaming_server(&server) - .await - .expect("build user-message admission session"); - let codex = Arc::clone(&test.codex); - let barrier = Arc::new(Barrier::new(3)); - - let first_submission = tokio::spawn({ - let codex = Arc::clone(&codex); - let barrier = Arc::clone(&barrier); - async move { - barrier.wait().await; - codex - .submit_user_input_and_wait_for_admission( - user_input("first message"), - /*trace*/ None, - Some("client-message-1".to_string()), - ) - .await - } - }); - let second_submission = tokio::spawn({ - let codex = Arc::clone(&codex); - let barrier = Arc::clone(&barrier); - async move { - barrier.wait().await; - codex - .submit_user_input_and_wait_for_admission( - user_input("second message"), - /*trace*/ None, - Some("client-message-2".to_string()), - ) - .await - } - }); - barrier.wait().await; - - timeout( - Duration::from_secs(5), - server.wait_for_request_count(/*count*/ 1), - ) - .await - .expect("the started turn should reach its first model request"); - release_response - .send(()) - .expect("response gate should remain open"); - - let (first_admission, second_admission) = timeout(Duration::from_secs(5), async { - tokio::join!(first_submission, second_submission) - }) - .await - .expect("both concurrent admissions should resolve"); - let first_admission = first_admission - .expect("first submission task should finish") - .expect("first user message should be admitted"); - let second_admission = second_admission - .expect("second submission task should finish") - .expect("second user message should be admitted"); - let (started_turn_id, steered_turn_id, started_message) = - match (&first_admission, &second_admission) { - ( - UserMessageAdmission::Started { turn_id: started }, - UserMessageAdmission::Steered { turn_id: steered }, - ) => (started, steered, "first message"), - ( - UserMessageAdmission::Steered { turn_id: steered }, - UserMessageAdmission::Started { turn_id: started }, - ) => (started, steered, "second message"), - _ => panic!( - "concurrent messages must start exactly one turn and steer the other: \ - {first_admission:?}, {second_admission:?}" - ), - }; - assert_eq!(started_turn_id, steered_turn_id); - - let mut observed_client_message_ids = Vec::new(); - loop { - let event = timeout(Duration::from_secs(10), codex.next_event()) - .await - .expect("admitted user-message events should arrive promptly") - .expect("admitted user-message event stream should remain available"); - match event.msg { - EventMsg::ItemStarted(item) => { - if let TurnItem::UserMessage(message) = item.item { - observed_client_message_ids.push(message.client_id); - } - } - EventMsg::Error(error) => panic!("admitted user message failed: {error:?}"), - EventMsg::StreamError(error) => panic!("admitted response stream failed: {error:?}"), - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - observed_client_message_ids.sort(); - assert_eq!( - observed_client_message_ids, - vec![ - Some("client-message-1".to_string()), - Some("client-message-2".to_string()), - ] - ); - - let requests = server.requests().await; - assert_eq!(requests.len(), 2); - let request_bodies: Vec = requests - .iter() - .map(|request| serde_json::from_slice(request).expect("parse model request")) - .collect(); - assert!(request_bodies[0].to_string().contains(started_message)); - assert!(request_bodies[1].to_string().contains("first message")); - assert!(request_bodies[1].to_string().contains("second message")); - - server.shutdown().await; -} - -/// Handler-side settings rejection must acknowledge failure once and leave the session usable. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_message_admission_reports_invalid_settings_and_recovers() { - let (server, _completions) = start_streaming_sse_server(vec![vec![StreamingSseChunk { - gate: None, - body: responses::sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - }]]) - .await; - let test = test_codex() - .with_model("gpt-5.4") - .with_config(|config| { - config.permissions.approval_policy = Constrained::allow_only(AskForApproval::OnRequest); - }) - .build_with_streaming_server(&server) - .await - .expect("build approval-constrained user-message admission session"); - let codex = &test.codex; - let mut invalid_input = user_input("invalid approval policy"); - let Op::UserInput { - thread_settings, .. - } = &mut invalid_input - else { - unreachable!("test helper always produces a user-input operation"); - }; - thread_settings.approval_policy = Some(AskForApproval::Never); - - let error = timeout( - Duration::from_secs(5), - codex.submit_user_input_and_wait_for_admission( - invalid_input, - /*trace*/ None, - Some("invalid-client-message".to_string()), - ), - ) - .await - .expect("handler-side rejection should resolve promptly") - .expect_err("disallowed approval settings should not be admitted"); - assert!(matches!( - error.details(), - CodexErrorDetails::InvalidRequest(_) - )); - - let error_event = loop { - let event = timeout(Duration::from_secs(5), codex.next_event()) - .await - .expect("invalid settings should emit an error event promptly") - .expect("event stream should remain available"); - match event.msg { - EventMsg::Error(error) => break error, - EventMsg::TurnComplete(_) => { - panic!("invalid settings completed a turn without emitting an error") - } - EventMsg::StreamError(error) => { - panic!("invalid settings unexpectedly started a model stream: {error:?}") - } - _ => {} - } - }; - assert_eq!( - error_event.codex_error_info, - Some(CodexErrorInfo::BadRequest) - ); - - let recovered = timeout( - Duration::from_secs(5), - codex.submit_user_input_and_wait_for_admission( - user_input("valid message after rejected settings"), - /*trace*/ None, - Some("valid-client-message".to_string()), - ), - ) - .await - .expect("session should accept a valid message after rejecting invalid settings") - .expect("valid message should start a turn"); - assert!(matches!(recovered, UserMessageAdmission::Started { .. })); - - loop { - let event = timeout(Duration::from_secs(10), codex.next_event()) - .await - .expect("recovered turn should finish promptly") - .expect("event stream should remain available after rejected settings"); - match event.msg { - EventMsg::Error(error) => panic!("settings rejection emitted another error: {error:?}"), - EventMsg::StreamError(error) => panic!("recovered turn stream failed: {error:?}"), - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - assert_eq!(server.requests().await.len(), 1); - server.shutdown().await; -} - -/// Non-user operations must fail validation immediately instead of waiting for a user-message acknowledgement. -#[tokio::test] -async fn user_message_admission_rejects_non_user_operations_without_waiting() { - let (server, _completions) = start_streaming_sse_server(Vec::new()).await; - let test = test_codex() - .with_model("gpt-5.4") - .build_with_streaming_server(&server) - .await - .expect("build user-message admission session"); - let codex = &test.codex; - - let error = timeout( - Duration::from_secs(5), - codex.submit_user_input_and_wait_for_admission( - Op::Interrupt, - /*trace*/ None, - Some("client-message-1".to_string()), - ), - ) - .await - .expect("non-user operation should be rejected promptly") - .expect_err("non-user operation should not use message admission"); - assert!(matches!( - error.details(), - CodexErrorDetails::InvalidRequest(_) - )); - server.shutdown().await; -} - -/// A terminated session must reject admission immediately instead of leaving a retained waiter unresolved. -#[tokio::test] -async fn user_message_admission_fails_promptly_after_session_shutdown() { - let (server, _completions) = start_streaming_sse_server(Vec::new()).await; - let test = test_codex() - .with_model("gpt-5.4") - .build_with_streaming_server(&server) - .await - .expect("build user-message admission session"); - let codex = &test.codex; - codex - .shutdown_and_wait() - .await - .expect("test session should shut down"); - - let error = timeout( - Duration::from_secs(5), - codex.submit_user_input_and_wait_for_admission( - user_input("after shutdown"), - /*trace*/ None, - Some("client-message-1".to_string()), - ), - ) - .await - .expect("closed session should reject admission promptly") - .expect_err("closed session should not accept user messages"); - assert!(matches!( - error.details(), - CodexErrorDetails::InternalAgentDied - )); - server.shutdown().await; -} diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 328a1c972a..bc2c5314a2 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -1,9 +1,9 @@ #![cfg(not(target_os = "windows"))] +use codex_core::TurnInputRequest; use std::os::unix::fs::PermissionsExt; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use core_test_support::fs_wait; use core_test_support::responses; @@ -56,16 +56,10 @@ mv "${tmp_path}" "${payload_path}""#, // 1) Normal user input – should hit server once. codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "hello world".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "hello world".into(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 174aba27e4..499c7d98d4 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,5 +1,9 @@ use anyhow::Context; +use codex_core::TurnInputRequest; use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -8,6 +12,7 @@ use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecOutputStream; use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; @@ -107,7 +112,7 @@ async fn user_shell_command_without_local_environment_emits_error() -> anyhow::R let test = builder.build(&server).await?; submit_thread_settings( &test.codex, - codex_protocol::protocol::ThreadSettingsOverrides { + ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![], @@ -210,30 +215,27 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> fixture .codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "run model shell command".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 { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: fixture.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let _ = wait_for_event_match(&fixture.codex, |ev| match ev { diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index ace0eba4cc..7607d48da3 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -3,13 +3,17 @@ use anyhow::Context; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_core::TurnInputRequest; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -26,7 +30,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_utils_path_uri::PathUri; @@ -80,29 +84,23 @@ enum ImageBudgetPolicy { UnifiedResponsesLiteWithoutOriginalSupport, } -fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> Op { +fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> TurnInputRequest { let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); - Op::UserInput { - items, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - } + TurnInputRequest::user_input(items).with_thread_settings(ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }) } fn image_messages(body: &Value) -> Vec<&Value> { @@ -255,7 +253,7 @@ async fn assert_user_turn_local_image_resizes_to( let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::LocalImage { path: abs_path.clone(), @@ -463,7 +461,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please add the screenshot".into(), @@ -877,7 +875,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please add the original screenshot".into(), @@ -1042,7 +1040,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please attach the image at low detail".into(), @@ -1122,7 +1120,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please attach the image with a null detail".into(), @@ -1231,7 +1229,7 @@ async fn assert_view_image_tool_resizes_without_original_support( let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please add the screenshot".into(), @@ -1325,7 +1323,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please add the screenshot".into(), @@ -1407,7 +1405,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please attach the folder".into(), @@ -1479,7 +1477,7 @@ async fn view_image_tool_rejects_invalid_image_before_tool_output() -> anyhow::R .await; codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please inspect the image".into(), @@ -1554,7 +1552,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { let session_model = session_configured.model.clone(); codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please attach the missing image".into(), @@ -1694,7 +1692,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an let mock = responses::mount_sse_once(&server, second_response).await; codex - .submit(disabled_user_turn( + .start_or_steer_turn(disabled_user_turn( &test, vec![UserInput::Text { text: "please attach the image".into(), diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index cdf77694c2..529419b8d1 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -1,9 +1,13 @@ use anyhow::Result; +use codex_core::TurnInputRequest; use codex_model_provider_info::WireApi; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::responses; @@ -154,30 +158,27 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result turn_permission_fields(PermissionProfile::Disabled, cwd.path()); codex - .submit(Op::UserInput { - items: vec![UserInput::Text { + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + }]) + .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { model: session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() - }, - }) + }), + ) .await?; let mut stream_error_messages = Vec::new(); diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 8a2a097843..273b4eaa9a 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -1,6 +1,7 @@ use super::compact::COMPACT_WARNING_MESSAGE; use anyhow::Result; use codex_core::CodexThread; +use codex_core::TurnInputRequest; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -101,16 +102,10 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }])) .await?; wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; Ok(()) diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f16da209f6..7caff51672 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -23,7 +23,7 @@ These are entities exit on the codex backend. The intent of this section is to e 3. `Task` - A `Task` is `Codex` executing work in response to user input. - `Session` has at most one `Task` running at a time. - - Receiving `Op::UserTurn` starts a `Task` (`Op::UserInput` is legacy) + - Receiving user turn input starts a `Task` - Consists of a series of `Turn`s - The `Task` executes to until: - The `Model` completes the task and there is no output to feed into an additional `Turn` @@ -67,13 +67,9 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op` - `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer` - - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - - `Op::UserInput` accepts an optional `personality` turn-context override that updates the model’s communication style - -Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` diff --git a/codex-rs/ext/agent/src/lib.rs b/codex-rs/ext/agent/src/lib.rs index 349fb46c09..bcd8fbb69e 100644 --- a/codex-rs/ext/agent/src/lib.rs +++ b/codex-rs/ext/agent/src/lib.rs @@ -1,7 +1,9 @@ use codex_core::CodexThread; use codex_core::NewThread; +use codex_core::StartIfIdleSubmission; use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; @@ -71,16 +73,23 @@ impl AgentRunner { }, ) .await?; - let turn_id = thread - .submit_with_trace( - vec![UserInput::Text { + let turn_id = match thread + .start_turn_if_idle( + TurnInputRequest::user_input(vec![UserInput::Text { text: prompt, text_elements: Vec::new(), - }] - .into(), - parent_trace, + }]) + .with_trace(parent_trace), ) - .await?; + .await? + { + StartIfIdleSubmission::Started { turn_id } => turn_id, + StartIfIdleSubmission::NotSubmitted { reason } => { + return Err(CodexErr::InvalidRequest(format!( + "agent prompt was not submitted: {reason:?}" + ))); + } + }; Ok(AgentRun { thread_id, diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index d85cfbeb77..28cc327127 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -3,8 +3,10 @@ use std::sync::Weak; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use codex_core::StartIfIdleSubmission; use codex_core::ThreadManager; use codex_core::TurnInput; +use codex_core::TurnInputRequest; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; @@ -403,15 +405,23 @@ impl GoalRuntimeHandle { } let item = continuation_steering_item(&protocol_goal_from_state(goal)); - if let Err(err) = thread - .try_start_turn_if_idle(vec![TurnInput::ResponseItem(item.into())]) + match thread + .start_turn_if_idle(TurnInputRequest::new(TurnInput::ResponseItem(item))) .await { - let reason = err.reason(); - tracing::debug!( - ?reason, - "skipping goal continuation because automatic idle work was rejected" - ); + Ok(StartIfIdleSubmission::Started { .. }) => {} + Ok(StartIfIdleSubmission::NotSubmitted { reason }) => { + tracing::debug!( + ?reason, + "skipping goal continuation because automatic idle work was rejected" + ); + } + Err(error) => { + tracing::debug!( + %error, + "skipping goal continuation because turn input submission failed" + ); + } } let current_turn_is_goal_active = self diff --git a/codex-rs/ext/queue/src/service.rs b/codex-rs/ext/queue/src/service.rs index 2d428ac87a..6f1e049e46 100644 --- a/codex-rs/ext/queue/src/service.rs +++ b/codex-rs/ext/queue/src/service.rs @@ -1,13 +1,14 @@ -use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::Weak; use codex_core::CodexThread; +use codex_core::NotSubmittedReason; +use codex_core::StartIfIdleSubmission; use codex_core::ThreadManager; use codex_core::TurnInput; -use codex_core::UserMessageAdmission; +use codex_core::TurnInputRequest; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; use codex_extension_api::ThreadIdleCause; @@ -19,9 +20,7 @@ use codex_protocol::models::snapshot_local_user_input; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadQueueChangedEvent; -use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput; @@ -49,8 +48,10 @@ pub enum QueueServiceError { InvalidPayload(#[from] serde_json::Error), #[error("local queued attachment is invalid: {0}")] InvalidAttachment(#[from] std::io::Error), - #[error("queued user message could not be admitted: {0}")] - Admission(#[from] CodexErr), + #[error("Core failed to submit queued user message: {0}")] + CoreSubmissionError(#[from] CodexErr), + #[error("Core declined to start queued user message: {0:?}")] + NotStarted(NotSubmittedReason), #[error("only user input can be added to the user-message queue")] InvalidInput, #[error( @@ -189,14 +190,13 @@ impl QueuedItemService { Ok(()) } - /// Starts or steers the exact queued message and removes it after Core - /// accepts the input for turn processing. + /// Starts the exact queued message when idle and removes it after submission. pub async fn start( &self, thread: &CodexThread, queued_item_id: String, trace: Option, - ) -> Result { + ) -> Result { let thread_id = thread.session_configured().thread_id; let _dispatch_guard = self.dispatch_guard(thread_id).await; let item = self @@ -207,24 +207,18 @@ impl QueuedItemService { .ok_or_else(|| ThreadStoreError::InvalidRequest { message: format!("queued item not found: {queued_item_id}"), })?; - let TurnInput::UserInput { content, client_id } = item.input else { + let input @ TurnInput::UserInput { .. } = item.input else { return Err(QueueServiceError::InvalidInput); }; - let admission = thread - .submit_user_input_and_wait_for_admission( - Op::UserInput { - items: content, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: BTreeMap::new(), - thread_settings: ThreadSettingsOverrides::default(), - }, - trace, - client_id, - ) - .await?; + let submission = thread + .start_turn_if_idle(TurnInputRequest::new(input).with_trace(trace)) + .await + .map_err(QueueServiceError::CoreSubmissionError)?; + if let StartIfIdleSubmission::NotSubmitted { reason } = submission { + return Err(QueueServiceError::NotStarted(reason)); + } self.delete_locked(thread_id, queued_item_id).await?; - Ok(admission) + Ok(submission) } async fn dispatch_if_idle(&self, thread_id: ThreadId) -> Result<(), QueueServiceError> { @@ -261,18 +255,33 @@ impl QueuedItemService { continue; } - if let Err(error) = thread.try_start_turn_if_idle(vec![input]).await { - tracing::warn!( - %thread_id, - %queued_item_id, - reason = ?error.reason(), - "core could not start queued user input" - ); - return Ok(()); + match thread + .start_turn_if_idle(TurnInputRequest::new(input)) + .await + { + Ok(StartIfIdleSubmission::Started { .. }) => { + self.delete_locked(thread_id, queued_item_id).await?; + return Ok(()); + } + Ok(StartIfIdleSubmission::NotSubmitted { reason }) => { + tracing::warn!( + %thread_id, + %queued_item_id, + ?reason, + "core could not start queued user input" + ); + return Ok(()); + } + Err(error) => { + tracing::warn!( + %thread_id, + %queued_item_id, + %error, + "core could not start queued user input" + ); + return Ok(()); + } } - - self.delete_locked(thread_id, queued_item_id).await?; - return Ok(()); } } diff --git a/codex-rs/ext/queue/tests/queue_service.rs b/codex-rs/ext/queue/tests/queue_service.rs index 83c4c0cf82..2fdbbc2ba5 100644 --- a/codex-rs/ext/queue/tests/queue_service.rs +++ b/codex-rs/ext/queue/tests/queue_service.rs @@ -6,8 +6,11 @@ use std::sync::Weak; use std::time::Duration; use anyhow::Context; +use codex_core::NotSubmittedReason; +use codex_core::StartIfIdleSubmission; use codex_core::TurnInput; -use codex_core::UserMessageAdmission; +use codex_core::TurnInputRequest; +use codex_core::TurnInputSubmission; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; @@ -37,11 +40,14 @@ use codex_utils_absolute_path::test_support::PathExt; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses; use core_test_support::responses::start_mock_server; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use tempfile::TempDir; +use tokio::sync::oneshot; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, @@ -291,11 +297,11 @@ async fn starting_a_selected_item_preserves_the_remaining_queue() -> anyhow::Res .enqueue(thread_id, structured_user_input("second")) .await?; - let admission = service + let submission = service .start(test.codex.as_ref(), second.id.clone(), /*trace*/ None) .await?; - assert!(matches!(admission, UserMessageAdmission::Started { .. })); + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); assert_eq!(vec![first], service.list(thread_id).await?); wait_for_event_match(test.codex.as_ref(), |event| match event { EventMsg::TurnComplete(_) => Some(()), @@ -313,6 +319,65 @@ async fn starting_a_selected_item_preserves_the_remaining_queue() -> anyhow::Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn starting_a_selected_item_while_active_leaves_it_queued() -> anyhow::Result<()> { + let (release_response, response_gate) = oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![responses::ev_response_created("resp-1")]), + }, + StreamingSseChunk { + gate: Some(response_gate), + body: responses::sse(vec![responses::ev_completed("resp-1")]), + }, + ]]) + .await; + let test = test_codex().build_with_streaming_server(&server).await?; + let thread_id = test.session_configured.thread_id; + let service = QueuedItemService::new( + loaded_thread_queue(&test)?, + Weak::new(), + Arc::new(NoopExtensionEventSink), + ); + let queued = service + .enqueue(thread_id, user_input("stay queued")) + .await?; + + let active_turn = test + .codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "active turn".to_string(), + text_elements: Vec::new(), + }])) + .await?; + assert!(matches!(active_turn, TurnInputSubmission::Started { .. })); + tokio::time::timeout( + Duration::from_secs(5), + server.wait_for_request_count(/*count*/ 1), + ) + .await?; + + let error = service + .start(test.codex.as_ref(), queued.id.clone(), /*trace*/ None) + .await + .expect_err("active turn should reject explicit queue start"); + assert!(matches!( + error, + QueueServiceError::NotStarted(NotSubmittedReason::NotIdle) + )); + assert_eq!(vec![queued], service.list(thread_id).await?); + + release_response + .send(()) + .expect("active response gate should remain open"); + wait_for_event_match(test.codex.as_ref(), |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stopped_turns_leave_queued_messages_in_place() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -453,12 +518,13 @@ async fn rejected_queue_messages_are_consumed_without_retrying_or_blocking_follo assert!(queue.list(thread_id).await?.is_empty()); let rejected = staging.enqueue(thread_id, user_input("blocked")).await?; - let admission = tokio::time::timeout( + let submission = tokio::time::timeout( Duration::from_secs(10), queue.start(test.codex.as_ref(), rejected.id, /*trace*/ None), ) - .await??; - assert!(matches!(admission, UserMessageAdmission::Started { .. })); + .await? + .expect("explicitly started input should be submitted"); + assert!(matches!(submission, StartIfIdleSubmission::Started { .. })); wait_for_event_match(test.codex.as_ref(), |event| { matches!(event, EventMsg::TurnComplete(_)).then_some(()) }) @@ -616,7 +682,7 @@ async fn non_user_input_cannot_enter_the_user_message_queue() -> anyhow::Result< let error = service .enqueue( ThreadId::new(), - TurnInput::ResponseItem(ResponseItem::Other.into()), + TurnInput::ResponseItem(ResponseItem::Other), ) .await .expect_err("response item should not enter the user queue"); diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 82653c6bc5..69fb1e8ce6 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -47,7 +47,6 @@ tokio = { workspace = true, features = [ ] } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -uuid = { workspace = true, features = ["v7"] } [dev-dependencies] app_test_support = { workspace = true } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 1dadc465fb..7caf042b65 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -11,8 +11,11 @@ use crate::outgoing_message::OutgoingNotificationMeta; use crate::patch_approval::handle_patch_approval_request; use codex_core::CodexThread; use codex_core::NewThread; +use codex_core::StartIfIdleSubmission; use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TurnInputRequest; +use codex_core::TurnInputSubmission; use codex_core::config::Config as CodexConfig; use codex_protocol::ThreadId; use codex_protocol::protocol::AgentMessageEvent; @@ -20,15 +23,12 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::Submission; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; use rmcp::model::CallToolResult; use rmcp::model::ContentBlock; use rmcp::model::RequestId; use serde_json::json; -use uuid::Uuid; /// To adhere to MCP `tools/call` response format, include the Codex /// `threadId` in the `structured_content` field of the response. @@ -51,6 +51,14 @@ pub(crate) fn create_call_tool_result_with_thread_id( result } +fn prompt_request(prompt: String) -> TurnInputRequest { + TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt, + // MCP tool prompts are plain text with no UI element ranges. + text_elements: Vec::new(), + }]) +} + /// Run a complete Codex session and stream events back to the client. /// /// On completion (success or error) the function sends the appropriate @@ -94,39 +102,33 @@ pub async fn run_codex_tool_session( }), ); - // Preserve the legacy event ID for initial `codex` calls. Each call starts - // a new thread, so the thread and turn pair remains unique. - let turn_id = id.to_string(); - active_turns.register(id.clone(), thread_id, turn_id.clone()); - let submission = Submission { - id: turn_id, - op: Op::UserInput { - items: vec![UserInput::Text { - text: initial_prompt.clone(), - // MCP tool prompts are plain text with no UI element ranges. - 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, - trace: None, - parent_turn_id: None, - root_turn_id: None, + let turn_id = match thread + .start_turn_if_idle(prompt_request(initial_prompt)) + .await + { + Ok(StartIfIdleSubmission::Started { turn_id }) => turn_id, + Ok(StartIfIdleSubmission::NotSubmitted { reason }) => { + tracing::error!("Failed to submit initial prompt: {reason:?}"); + let result = create_call_tool_result_with_thread_id( + thread_id, + format!("Failed to submit initial prompt: {reason:?}"), + Some(true), + ); + outgoing.send_response(id.clone(), result); + return; + } + Err(e) => { + tracing::error!("Failed to submit initial prompt: {e}"); + let result = create_call_tool_result_with_thread_id( + thread_id, + format!("Failed to submit initial prompt: {e}"), + Some(true), + ); + outgoing.send_response(id.clone(), result); + return; + } }; - - if let Err(e) = thread.submit_with_id(submission).await { - tracing::error!("Failed to submit initial prompt: {e}"); - let result = create_call_tool_result_with_thread_id( - thread_id, - format!("Failed to submit initial prompt: {e}"), - Some(true), - ); - active_turns.finish(&id, || outgoing.send_response(id.clone(), result)); - return; - } + active_turns.register(id.clone(), thread_id, turn_id); run_codex_tool_session_inner(thread_id, thread, outgoing, id, active_turns).await; } @@ -139,42 +141,32 @@ pub async fn run_codex_tool_session_reply( prompt: String, active_turns: Arc, ) { - // Replies share a thread, so use Core's UUIDv7 submission ID convention - // instead of a reusable MCP request ID. - let turn_id = Uuid::now_v7().to_string(); - active_turns.register(request_id.clone(), thread_id, turn_id.clone()); - if let Err(e) = thread - .submit_with_id(Submission { - id: turn_id, - op: Op::UserInput { - items: vec![UserInput::Text { - text: prompt, - // MCP tool prompts are plain text with no UI element ranges. - 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, - trace: None, - parent_turn_id: None, - root_turn_id: None, - }) - .await - { - tracing::error!("Failed to submit user input: {e}"); - let result = create_call_tool_result_with_thread_id( - thread_id, - format!("Failed to submit user input: {e}"), - Some(true), - ); - active_turns.finish(&request_id, || { + let turn_id = match thread.start_or_steer_turn(prompt_request(prompt)).await { + Ok(TurnInputSubmission::Started { turn_id } | TurnInputSubmission::Steered { turn_id }) => { + turn_id + } + Ok(TurnInputSubmission::NotSubmitted { reason }) => { + tracing::error!("Failed to submit user input: {reason:?}"); + let result = create_call_tool_result_with_thread_id( + thread_id, + format!("Failed to submit user input: {reason:?}"), + Some(true), + ); outgoing.send_response(request_id.clone(), result); - }); - return; - } + return; + } + Err(e) => { + tracing::error!("Failed to submit user input: {e}"); + let result = create_call_tool_result_with_thread_id( + thread_id, + format!("Failed to submit user input: {e}"), + Some(true), + ); + outgoing.send_response(request_id.clone(), result); + return; + } + }; + active_turns.register(request_id.clone(), thread_id, turn_id); run_codex_tool_session_inner(thread_id, thread, outgoing, request_id, active_turns).await; } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index f8242a6555..559074d921 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -11,7 +11,6 @@ use codex_login::AuthManager; use codex_login::default_client::USER_AGENT_SUFFIX; use codex_login::default_client::get_codex_user_agent; use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::Submission; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::ClientNotification; @@ -535,7 +534,7 @@ impl MessageProcessor { tracing::warn!("ignoring cancellation without a request id"); return; }; - // Create a stable string form early for logging and submission id. + // Create a stable string form early for logging. let request_id_string = request_id.to_string(); // Resolve the thread for the active MCP request. @@ -559,14 +558,7 @@ impl MessageProcessor { // Submit interrupt to Codex. if let Err(e) = codex_arc - .submit_with_id(Submission { - id: request_id_string, - op: codex_protocol::protocol::Op::Interrupt, - client_user_message_id: None, - trace: None, - parent_turn_id: None, - root_turn_id: None, - }) + .submit(codex_protocol::protocol::Op::Interrupt) .await { tracing::error!("Failed to submit interrupt to Codex: {e}"); diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 61f721c5d3..0b430f3eb5 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -564,7 +564,11 @@ async fn codex_tool_forwards_skills_extension_warnings() -> anyhow::Result<()> { warning_json["params"]["_meta"]["requestId"], codex_request_id ); - assert_eq!(warning_json["params"]["id"], codex_request_id.to_string()); + assert!( + warning_json["params"]["id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); assert!( warning_json["params"]["_meta"]["threadId"] .as_str() diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index b80c4ebabf..0d61493fff 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -3,8 +3,10 @@ use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; +use codex_core::StartIfIdleSubmission; use codex_core::StartThreadOptions; use codex_core::ThreadManager; +use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::content_items_to_text; use codex_core::detached_memory_responses_metadata; @@ -26,7 +28,6 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InternalSessionSource; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; @@ -336,23 +337,24 @@ impl MemoryStartupContext { .await?; let agent = SpawnedConsolidationAgent { thread_id, thread }; - if let Err(err) = agent + let submit_result = match agent .thread - .submit(Op::UserInput { - items: prompt, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + .start_turn_if_idle(TurnInputRequest::user_input(prompt)) .await { + Ok(StartIfIdleSubmission::Started { .. }) => Ok(()), + Ok(submission) => Err(anyhow::anyhow!( + "memory consolidation input was not started: {submission:?}" + )), + Err(err) => Err(err.into()), + }; + if let Err(err) = submit_result { if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await { tracing::warn!( "failed to shut down consolidation agent after submit error: {shutdown_err}" ); } - return Err(err.into()); + return Err(err); } Ok(agent) diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 8b4a083156..fe2680f43f 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -36,4 +36,5 @@ pub mod request_permissions; pub mod request_user_input; pub mod review_format; pub mod shell_environment; +pub mod turn_input; pub mod user_input; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9167b82f78..56a6aff271 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -31,6 +31,7 @@ use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; use crate::dynamic_tools::DynamicToolResponse; use crate::dynamic_tools::DynamicToolSpec; +use crate::error::Result as CodexResult; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; @@ -54,6 +55,10 @@ use crate::plan_tool::UpdatePlanArgs; use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; +use crate::turn_input::TurnInputMode; +use crate::turn_input::TurnInputRequest; +use crate::turn_input::TurnInputSubmission; +#[cfg(test)] use crate::user_input::UserInput; use codex_extension_items::image_generation::ImageGenerationFailure; use codex_utils_absolute_path::AbsolutePathBuf; @@ -67,6 +72,7 @@ use serde_json::Map; use serde_json::Value; use serde_with::serde_as; use strum_macros::Display; +use tokio::sync::oneshot; use tracing::error; use ts_rs::TS; @@ -179,11 +185,11 @@ pub struct Submission { pub id: String, /// Payload pub op: Op, - /// Client-provided id for the user message represented by `Op::UserInput`. - pub client_user_message_id: Option, /// Optional W3C trace carrier propagated across async submission handoffs. pub trace: Option, /// Core-provided ID of the parent turn that directly initiated this submission. + /// + /// This is only used for inter-agent communication. pub parent_turn_id: Option, /// Core-provided ID of the top-level turn that causally initiated this submission. pub root_turn_id: Option, @@ -558,19 +564,11 @@ pub enum Op { /// Request the list of voices supported by realtime conversation streams. RealtimeConversationListVoices, - /// User input, optionally with thread-settings overrides applied first. - UserInput { - /// User input items, see `InputItem` - items: Vec, - /// Optional JSON Schema used to constrain the final assistant message for this turn. - final_output_json_schema: Option, - /// Optional turn-scoped Responses API `client_metadata`. - responsesapi_client_metadata: Option>, - /// Client-supplied context fragments keyed by an opaque source identifier. - additional_context: BTreeMap, - - /// Persistent thread-settings overrides to apply before the input. - thread_settings: ThreadSettingsOverrides, + /// Submit turn input using the requested routing behavior. + TurnInput { + request: Box, + mode: TurnInputMode, + reply: oneshot::Sender>, }, /// Apply persistent thread-settings overrides without starting a turn. @@ -727,18 +725,6 @@ impl FromStr for ThreadHistoryMode { } } -impl From> for Op { - fn from(value: Vec) -> Self { - Op::UserInput { - items: value, - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: ThreadSettingsOverrides::default(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -877,7 +863,7 @@ impl Op { Self::RealtimeConversationSpeech(_) => "realtime_conversation_speech", Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", - Self::UserInput { .. } => "user_input", + Self::TurnInput { .. } => "turn_input", Self::ThreadSettings { .. } => "thread_settings", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::ExecApproval { .. } => "exec_approval", diff --git a/codex-rs/protocol/src/turn_input.rs b/codex-rs/protocol/src/turn_input.rs new file mode 100644 index 0000000000..6506364a37 --- /dev/null +++ b/codex-rs/protocol/src/turn_input.rs @@ -0,0 +1,197 @@ +//! Turn-input request and result types shared by Core's submission APIs. + +use crate::models::ResponseItem; +use crate::protocol::AdditionalContextEntry; +use crate::protocol::InterAgentCommunication; +use crate::protocol::NonSteerableTurnKind; +use crate::protocol::ThreadSettingsOverrides; +use crate::protocol::W3cTraceContext; +use crate::user_input::UserInput; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::collections::HashMap; + +/// Input consumed by a regular turn. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum TurnInput { + UserInput { + content: Vec, + client_id: Option, + }, + ResponseItem(ResponseItem), + InterAgentCommunication(InterAgentCommunication), +} + +/// One turn input and the context that follows it through submission. +/// +/// Callers choose start-or-steer, idle-start, or steer-only behavior through +/// the corresponding `CodexThread` method. +#[derive(Clone, Debug)] +pub struct TurnInputRequest { + pub input: TurnInput, + pub thread_settings: ThreadSettingsOverrides, + pub start: TurnStartOptions, + pub additional_context: BTreeMap, + pub responsesapi_client_metadata: Option>, + pub trace: Option, +} + +impl TurnInputRequest { + /// Creates turn input that can be passed to one of the submission methods. + pub fn new(input: TurnInput) -> Self { + Self { + input, + thread_settings: ThreadSettingsOverrides::default(), + start: TurnStartOptions::default(), + additional_context: BTreeMap::new(), + responsesapi_client_metadata: None, + trace: None, + } + } + + /// Creates ordinary user input without a client-provided message id. + pub fn user_input(content: Vec) -> Self { + Self::new(TurnInput::UserInput { + content, + client_id: None, + }) + } + + /// Persistent thread settings applied when Core accepts this input. + /// + /// Settings are applied for both `Started` and `Steered`. A steered input + /// cannot change its already-active turn context, so those settings apply + /// to subsequent turns. `NotSubmitted` leaves them unapplied. + pub fn with_thread_settings(mut self, thread_settings: ThreadSettingsOverrides) -> Self { + self.thread_settings = thread_settings; + self + } + + /// Options consulted only by start-capable submission methods when this + /// request starts a turn. + pub fn on_start(mut self, start: TurnStartOptions) -> Self { + self.start = start; + self + } + + /// Context merged whether this request starts or steers. + pub fn with_additional_context( + mut self, + additional_context: BTreeMap, + ) -> Self { + self.additional_context = additional_context; + self + } + + /// Responses metadata attached whether this request starts or steers. + pub fn with_responses_metadata( + mut self, + responsesapi_client_metadata: Option>, + ) -> Self { + self.responsesapi_client_metadata = responsesapi_client_metadata; + self + } + + /// Trace context used when this request crosses the session loop. + pub fn with_trace(mut self, trace: Option) -> Self { + self.trace = trace; + self + } +} + +/// How Core should route submitted turn input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TurnInputMode { + /// Start a regular turn when idle, otherwise steer the active regular turn. + StartOrSteer, + /// Start only when the thread is idle. + StartIfIdle, + /// Steer only if this exact turn is active. + Steer { expected_turn_id: String }, +} + +/// Options for the new-turn branch of a submission. +/// +/// Core only records these options when input starts a turn. When input steers, +/// `final_output_json_schema` is a compatibility requirement: Core only +/// accepts the steer if the active turn already uses the same schema. For +/// child input, Core also compares root lineage to detect ambiguity. +#[derive(Clone, Debug, Default)] +pub struct TurnStartOptions { + /// Structured-output schema for a new turn. When steering, Core rejects + /// the input if the active turn uses a different schema. + pub final_output_json_schema: Option, + /// Parent turn lineage recorded if this request starts a new turn. + pub parent_turn_id: Option, + /// Causal root turn lineage recorded if this request starts a new turn. + pub root_turn_id: Option, +} + +/// What Core did with input submitted through `start_or_steer_turn`. +/// +/// Started and Steered only mean Core accepted the input for turn processing. They +/// do not wait for user-prompt hooks, updating the in-memory model context, +/// rollout persistence, or sampling. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TurnInputSubmission { + /// Core started a turn. Persistent thread settings and start options were applied. + Started { turn_id: String }, + /// Core steered an active turn. Persistent thread settings were applied for + /// subsequent turns. No new turn was created, so lineage metadata was not + /// recorded. If the request included `final_output_json_schema`, the active + /// turn already used the same schema. + Steered { turn_id: String }, + /// Core rejected the input without applying settings or start options. + NotSubmitted { reason: NotSubmittedReason }, +} + +/// What Core did with input submitted only for an idle turn start. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StartIfIdleSubmission { + /// Core started a turn. Persistent thread settings and start options were applied. + Started { turn_id: String }, + /// Core rejected the input without applying settings or start options. + NotSubmitted { reason: NotSubmittedReason }, +} + +/// What Core did with input submitted only for steering. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SteerSubmission { + /// Core steered an active turn. Persistent thread settings were applied for + /// subsequent turns. + Steered { turn_id: String }, + /// Core rejected the input without applying settings. + NotSubmitted { reason: NotSubmittedReason }, +} + +/// Why Core did not accept submitted turn input for turn processing. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NotSubmittedReason { + /// `start_turn_if_idle` found an active turn. + NotIdle, + + /// `start_turn_if_idle` yielded to higher-priority trigger-turn mailbox input. + PendingTriggerTurn, + + /// `start_turn_if_idle` received automatic non-user input for a turn that + /// would run in Plan mode. + PlanMode, + + /// `steer_turn` found no active turn. + NoActiveTurn, + + /// `steer_turn` found a different active turn. + ExpectedTurnMismatch { expected: String, actual: String }, + + /// `start_or_steer_turn` or `steer_turn` found an active turn that does not accept steering. + ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind }, + + /// `start_or_steer_turn` or `steer_turn` required a final output schema + /// that differs from the active turn's schema. + ActiveTurnOutputSchemaMismatch, + + /// `start_or_steer_turn` or `steer_turn` reached a steering path with empty user input. + EmptyInput, +} diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 3a55d58e9f..28121f368a 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -36,7 +36,6 @@ use codex_core_api::NewThread; use codex_core_api::Notice; use codex_core_api::OAuthCredentialsStoreMode; use codex_core_api::OPENAI_PROVIDER_ID; -use codex_core_api::Op; use codex_core_api::OtelConfig; use codex_core_api::PermissionProfile; use codex_core_api::Permissions; @@ -46,6 +45,7 @@ use codex_core_api::RealtimeConfig; use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; use codex_core_api::SqliteConfig; +use codex_core_api::StartIfIdleSubmission; use codex_core_api::StartThreadOptions; use codex_core_api::TerminalResizeReflowConfig; use codex_core_api::ThreadManager; @@ -54,6 +54,7 @@ use codex_core_api::ToolSuggestConfig; use codex_core_api::TuiKeymap; use codex_core_api::TuiNotificationSettings; use codex_core_api::TuiPetAnchor; +use codex_core_api::TurnInputRequest; use codex_core_api::UriBasedFileOpener; use codex_core_api::UserInput; use codex_core_api::WebSearchMode; @@ -322,19 +323,16 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R } async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyhow::Result<()> { - thread - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: prompt, - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) + let submission = thread + .start_turn_if_idle(TurnInputRequest::user_input(vec![UserInput::Text { + text: prompt, + text_elements: Vec::new(), + }])) .await .context("submit user input")?; + if let StartIfIdleSubmission::NotSubmitted { reason } = submission { + bail!("turn input was not submitted: {reason:?}"); + } let mut current_turn_id: Option = None; let mut stdout = std::io::stdout().lock(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 494c7cbb03..91ccf26036 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1561,7 +1561,7 @@ mod tests { let AppEvent::CodexOp(op) = event else { panic!("expected CodexOp"); }; - assert_eq!(op, Op::interrupt()); + assert!(matches!(op, Op::Interrupt)); assert!( rx.try_recv().is_err(), "unexpected AppEvents before interrupt completion"