diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 37122e08a234..8b1f1aedde68 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -12,6 +12,8 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use tokio::time::timeout; @@ -52,6 +54,11 @@ async fn request_user_input_round_trip() -> Result<()> { }], model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + })), ..Default::default() }) .await?; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b7dbd0796938..6af644b7e437 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1518,6 +1518,11 @@ impl Session { self.features.clone() } + pub(crate) async fn collaboration_mode(&self) -> CollaborationMode { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index f63b11d23543..aca43a1d124b 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::request_user_input::RequestUserInputArgs; pub struct RequestUserInputHandler; @@ -35,6 +36,17 @@ impl ToolHandler for RequestUserInputHandler { } }; + let disallowed_mode = match session.collaboration_mode().await { + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => Some("Custom"), + _ => None, + }; + if let Some(mode_name) = disallowed_mode { + return Err(FunctionCallError::RespondToModel(format!( + "request_user_input is unavailable in {mode_name} mode" + ))); + } + let args: RequestUserInputArgs = parse_arguments(&arguments)?; let response = session .request_user_input(turn.as_ref(), call_id, args) diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 3c4405eeb3a2..adf9e3e9d3a3 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -7,7 +7,9 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -45,6 +47,27 @@ fn call_output(req: &ResponsesRequest, call_id: &str) -> String { } } +fn call_output_content_and_success( + req: &ResponsesRequest, + call_id: &str, +) -> (String, Option) { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + let content = match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + }; + (content, success) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -109,7 +132,11 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, - collaboration_mode: None, + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + })), }) .await?; @@ -153,3 +180,112 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> Ok(()) } + +async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let builder = test_codex(); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder + .with_config(|config| { + config.features.enable(Feature::CollaborationModes); + }) + .build(&server) + .await?; + + let mode_slug = mode_name.to_lowercase(); + let call_id = format!("user-input-{mode_slug}-call"); + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(&call_id, "request_user_input", &request_args), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "thanks"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + let collaboration_mode = build_mode(session_model.clone()); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let (output, success) = call_output_content_and_success(&req, &call_id); + assert_eq!(success, None); + assert_eq!( + output, + format!("request_user_input is unavailable in {mode_name} mode") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Execute", |model| { + CollaborationMode::Execute(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Custom", |model| { + CollaborationMode::Custom(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +}