From 57ec3a82778b1cc88a000016b79bd4732ff18a43 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 19 Jan 2026 10:17:30 -0800 Subject: [PATCH] Feat: request user input tool (#9472) ### Summary * Add `requestUserInput` tool that the model can use for gather feedback/asking question mid turn. ### Tool input schema ``` { "$schema": "http://json-schema.org/draft-07/schema#", "title": "requestUserInput input", "type": "object", "additionalProperties": false, "required": ["questions"], "properties": { "questions": { "type": "array", "description": "Questions to show the user (1-3). Prefer 1 unless multiple independent decisions block progress.", "minItems": 1, "maxItems": 3, "items": { "type": "object", "additionalProperties": false, "required": ["id", "header", "question"], "properties": { "id": { "type": "string", "description": "Stable identifier for mapping answers (snake_case)." }, "header": { "type": "string", "description": "Short header label shown in the UI (12 or fewer chars)." }, "question": { "type": "string", "description": "Single-sentence prompt shown to the user." }, "options": { "type": "array", "description": "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, do not include any option.", "minItems": 2, "maxItems": 3, "items": { "type": "object", "additionalProperties": false, "required": ["value", "label", "description"], "properties": { "value": { "type": "string", "description": "Machine-readable value (snake_case)." }, "label": { "type": "string", "description": "User-facing label (1-5 words)." }, "description": { "type": "string", "description": "One short sentence explaining impact/tradeoff if selected." } } } } } } } } } ``` ### Tool output schema ``` { "$schema": "http://json-schema.org/draft-07/schema#", "title": "requestUserInput output", "type": "object", "additionalProperties": false, "required": ["answers"], "properties": { "answers": { "type": "object", "description": "Map of question id to user answer.", "additionalProperties": { "type": "object", "additionalProperties": false, "required": ["selected"], "properties": { "selected": { "type": "array", "items": { "type": "string" } }, "other": { "type": ["string", "null"] } } } } } } ``` --- .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 48 +++++ codex-rs/app-server/README.md | 20 +- .../app-server/src/bespoke_event_handling.rs | 117 ++++++++++++ codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 23 +++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/request_user_input.rs | 127 +++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 14 +- codex-rs/core/src/codex.rs | 72 +++++++ codex-rs/core/src/codex_delegate.rs | 67 +++++++ codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/state/turn.rs | 18 ++ codex-rs/core/src/tools/handlers/mod.rs | 2 + .../src/tools/handlers/request_user_input.rs | 60 ++++++ codex-rs/core/src/tools/spec.rs | 175 +++++++++++++++++- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_tools.rs | 12 +- codex-rs/core/tests/suite/prompt_caching.rs | 8 + .../core/tests/suite/request_user_input.rs | 155 ++++++++++++++++ codex-rs/core/tests/suite/tool_harness.rs | 1 - codex-rs/docs/protocol_v1.md | 2 + .../src/event_processor_with_human_output.rs | 3 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + .../mcp-server/tests/common/mcp_process.rs | 4 +- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 13 ++ codex-rs/protocol/src/request_user_input.rs | 48 +++++ codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui2/src/chatwidget.rs | 3 +- 30 files changed, 985 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/request_user_input.rs create mode 100644 codex-rs/core/src/tools/handlers/request_user_input.rs create mode 100644 codex-rs/core/tests/suite/request_user_input.rs create mode 100644 codex-rs/protocol/src/request_user_input.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 401132806..911a4a0e9 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -510,6 +510,12 @@ server_request_definitions! { response: v2::FileChangeRequestApprovalResponse, }, + /// EXPERIMENTAL - Request input from the user for a tool call. + ToolRequestUserInput => "item/tool/requestUserInput" { + params: v2::ToolRequestUserInputParams, + response: v2::ToolRequestUserInputResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index db51c278f..563a78cc6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2277,6 +2277,54 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its optional options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4c376ce6a..9bc8c3dff 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -90,6 +90,7 @@ Example (from OpenAI's official VSCode extension): - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. @@ -470,8 +471,15 @@ Invoke a skill by including `$` in the text input. Add a `skill` inp "params": { "threadId": "thread-1", "input": [ - { "type": "text", "text": "$skill-creator Add a new skill for triaging flaky CI." }, - { "type": "skill", "name": "skill-creator", "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" } + { + "type": "text", + "text": "$skill-creator Add a new skill for triaging flaky CI." + }, + { + "type": "skill", + "name": "skill-creator", + "path": "/Users/me/.codex/skills/skill-creator/SKILL.md" + } ] } } @@ -506,10 +514,14 @@ Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, wi To enable or disable a skill by path: ```json -{ "method": "skills/config/write", "id": 26, "params": { +{ + "method": "skills/config/write", + "id": 26, + "params": { "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", "enabled": false -} } + } +} ``` ## Auth endpoints diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0b2520098..225b2afac 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -54,6 +54,10 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; @@ -83,6 +87,8 @@ use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ReviewOutputEvent; +use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; use std::path::PathBuf; @@ -258,6 +264,57 @@ pub(crate) async fn apply_bespoke_event_handling( }); } }, + EventMsg::RequestUserInput(request) => { + if matches!(api_version, ApiVersion::V2) { + let questions = request + .questions + .into_iter() + .map(|question| ToolRequestUserInputQuestion { + id: question.id, + header: question.header, + question: question.question, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| ToolRequestUserInputOption { + label: option.label, + description: option.description, + }) + .collect() + }), + }) + .collect(); + let params = ToolRequestUserInputParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + questions, + }; + let rx = outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput(params)) + .await; + tokio::spawn(async move { + on_request_user_input_response(event_turn_id, rx, conversation).await; + }); + } else { + error!( + "request_user_input is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + } + } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { let notification = construct_mcp_tool_call_notification( @@ -1347,6 +1404,66 @@ async fn on_exec_approval_response( } } +async fn on_request_user_input_response( + event_turn_id: String, + receiver: oneshot::Receiver, + conversation: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + let empty = CoreRequestUserInputResponse { + answers: HashMap::new(), + }; + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } + return; + } + }; + + let response = + serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ToolRequestUserInputResponse: {err}"); + ToolRequestUserInputResponse { + answers: HashMap::new(), + } + }); + let response = CoreRequestUserInputResponse { + answers: response + .answers + .into_iter() + .map(|(id, answer)| { + ( + id, + CoreRequestUserInputAnswer { + selected: answer.selected, + other: answer.other, + }, + ) + }) + .collect(), + }; + + if let Err(err) = conversation + .submit(Op::UserInputAnswer { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit UserInputAnswer: {err}"); + } +} + const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; fn render_review_output_text(output: &ReviewOutputEvent) -> String { diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index d350a5df8..809312140 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -27,6 +27,7 @@ pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; +pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_text_elements; diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 35c1862e8..e15319e02 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -60,3 +60,26 @@ pub fn create_exec_command_sse_response(call_id: &str) -> anyhow::Result responses::ev_completed("resp-1"), ])) } + +pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result { + let tool_call_arguments = serde_json::to_string(&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." + }] + }] + }))?; + + Ok(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments), + responses::ev_completed("resp-1"), + ])) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 82939908e..bf6230ae5 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -6,6 +6,7 @@ mod initialize; mod model_list; mod output_schema; mod rate_limits; +mod request_user_input; mod review; mod thread_archive; mod thread_fork; 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 new file mode 100644 index 000000000..37122e08a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_request_user_input_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +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::openai_models::ReasoningEffort; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn request_user_input_round_trip() -> Result<()> { + let codex_home = tempfile::TempDir::new()?; + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let turn_start_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + ..Default::default() + }) + .await?; + let turn_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, params } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + assert_eq!(params.item_id, "call1"); + assert_eq!(params.questions.len(), 1); + + mcp.send_response( + request_id, + serde_json::json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": serde_json::Value::Null } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +collaboration_modes = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} 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 4c8df56c3..29a7d6b93 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1471,8 +1471,18 @@ unified_exec = true unreachable!("loop ensures we break on command execution items"); }; assert_eq!(completed_id, "uexec-1"); - assert_eq!(completed_status, CommandExecutionStatus::Completed); - assert_eq!(exit_code, Some(0)); + assert!( + matches!( + completed_status, + CommandExecutionStatus::Completed | CommandExecutionStatus::Failed + ), + "unexpected command execution status: {completed_status:?}" + ); + if completed_status == CommandExecutionStatus::Completed { + assert_eq!(exit_code, Some(0)); + } else { + assert!(exit_code.is_some(), "expected exit_code for failed command"); + } assert_eq!( completed_process_id.as_deref(), Some(started_process_id.as_str()) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b6876c83c..3d48810ff 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -49,6 +49,8 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use futures::future::BoxFuture; @@ -118,6 +120,7 @@ use crate::protocol::Op; use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::RequestUserInputEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; @@ -1296,6 +1299,63 @@ impl Session { rx_approve } + pub async fn request_user_input( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestUserInputArgs, + ) -> Option { + let sub_id = turn_context.sub_id.clone(); + let (tx_response, rx_response) = oneshot::channel(); + let event_id = sub_id.clone(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_user_input(sub_id, tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending user input for sub_id: {event_id}"); + } + + let event = EventMsg::RequestUserInput(RequestUserInputEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + questions: args.questions, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + + pub async fn notify_user_input_response( + &self, + sub_id: &str, + response: RequestUserInputResponse, + ) { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_user_input(sub_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending user input found for sub_id: {sub_id}"); + } + } + } + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { let mut active = self.active_turn.lock().await; @@ -1933,6 +1993,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; } + Op::UserInputAnswer { id, response } => { + handlers::request_user_input_response(&sess, id, response).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -2022,6 +2085,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_user_input::RequestUserInputResponse; use crate::context_manager::is_user_turn_boundary; use codex_protocol::config_types::CollaborationMode; @@ -2250,6 +2314,14 @@ mod handlers { } } + pub async fn request_user_input_response( + sess: &Arc, + id: String, + response: RequestUserInputResponse, + ) { + sess.notify_user_input_response(&id, response).await; + } + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 49409f8e8..b85561211 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -9,9 +10,12 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RequestUserInputEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use std::time::Duration; use tokio::time::timeout; @@ -229,6 +233,20 @@ async fn forward_events( ) .await; } + Event { + id, + msg: EventMsg::RequestUserInput(event), + } => { + handle_request_user_input( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } other => { match tx_sub.send(other).or_cancel(&cancel_token).await { Ok(Ok(())) => {} @@ -334,6 +352,55 @@ async fn handle_patch_approval( let _ = codex.submit(Op::PatchApproval { id, decision }).await; } +async fn handle_request_user_input( + codex: &Codex, + id: String, + parent_session: &Session, + parent_ctx: &TurnContext, + event: RequestUserInputEvent, + cancel_token: &CancellationToken, +) { + let args = RequestUserInputArgs { + questions: event.questions, + }; + let response_fut = + parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args); + let response = await_user_input_with_cancel( + response_fut, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + let _ = codex.submit(Op::UserInputAnswer { id, response }).await; +} + +async fn await_user_input_with_cancel( + fut: F, + parent_session: &Session, + sub_id: &str, + cancel_token: &CancellationToken, +) -> RequestUserInputResponse +where + F: core::future::Future>, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + let empty = RequestUserInputResponse { + answers: HashMap::new(), + }; + parent_session + .notify_user_input_response(sub_id, empty.clone()) + .await; + empty + } + response = fut => response.unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }), + } +} + /// Await an approval decision, aborting on cancellation. async fn await_approval_with_cancel( fut: F, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index fafdc8310..2d4a79cc2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -67,6 +67,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) + | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 3dfc5132d..66e2f694e 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,6 +9,7 @@ use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; +use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::oneshot; use crate::codex::TurnContext; @@ -68,6 +69,7 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_user_input: HashMap>, pending_input: Vec, } @@ -89,9 +91,25 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_user_input.clear(); self.pending_input.clear(); } + pub(crate) fn insert_pending_user_input( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_user_input.insert(key, tx) + } + + pub(crate) fn remove_pending_user_input( + &mut self, + key: &str, + ) -> Option> { + self.pending_user_input.remove(key) + } + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { self.pending_input.push(input); } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ab8123df1..8b63c9567 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -6,6 +6,7 @@ mod mcp; mod mcp_resource; mod plan; mod read_file; +mod request_user_input; mod shell; mod test_sync; mod unified_exec; @@ -23,6 +24,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs new file mode 100644 index 000000000..f63b11d23 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -0,0 +1,60 @@ +use async_trait::async_trait; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::request_user_input::RequestUserInputArgs; + +pub struct RequestUserInputHandler; + +#[async_trait] +impl ToolHandler for RequestUserInputHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_user_input handler received unsupported payload".to_string(), + )); + } + }; + + let args: RequestUserInputArgs = parse_arguments(&arguments)?; + let response = session + .request_user_input(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "request_user_input was cancelled before receiving a response".to_string(), + ) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_user_input response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + content_items: None, + success: Some(true), + }) + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 9d272a5e6..e330d93a0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -27,6 +27,7 @@ pub(crate) struct ToolsConfig { pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub collab_tools: bool, + pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, } @@ -45,6 +46,7 @@ impl ToolsConfig { } = params; let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_collab_tools = features.enabled(Feature::Collab); + let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); let shell_type = if !features.enabled(Feature::ShellTool) { ConfigShellToolType::Disabled @@ -76,6 +78,7 @@ impl ToolsConfig { apply_patch_tool_type, web_search_mode: *web_search_mode, collab_tools: include_collab_tools, + collaboration_modes_tools: include_collaboration_modes_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), } } @@ -532,6 +535,88 @@ fn create_wait_tool() -> ToolSpec { }) } +fn create_request_user_input_tool() -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "label".to_string(), + JsonSchema::String { + description: Some("User-facing label (1-5 words).".to_string()), + }, + ); + option_props.insert( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected.".to_string(), + ), + }, + ); + + let options_schema = JsonSchema::Array { + description: Some( + "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec!["label".to_string(), "description".to_string()]), + additional_properties: Some(false.into()), + }), + }; + + let mut question_props = BTreeMap::new(); + question_props.insert( + "id".to_string(), + JsonSchema::String { + description: Some("Stable identifier for mapping answers (snake_case).".to_string()), + }, + ); + question_props.insert( + "header".to_string(), + JsonSchema::String { + description: Some( + "Short header label shown in the UI (12 or fewer chars).".to_string(), + ), + }, + ); + question_props.insert( + "question".to_string(), + JsonSchema::String { + description: Some("Single-sentence prompt shown to the user.".to_string()), + }, + ); + question_props.insert("options".to_string(), options_schema); + + let questions_schema = JsonSchema::Array { + description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + items: Box::new(JsonSchema::Object { + properties: question_props, + required: Some(vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("questions".to_string(), questions_schema); + + ToolSpec::Function(ResponsesApiTool { + name: "request_user_input".to_string(), + description: + "Request user input for one to three short questions and wait for the response." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["questions".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1140,6 +1225,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; @@ -1157,6 +1243,7 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler); + let request_user_input_handler = Arc::new(RequestUserInputHandler); match &config.shell_type { ConfigShellToolType::Default => { @@ -1197,6 +1284,11 @@ pub(crate) fn build_specs( builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); + if config.collaboration_modes_tools { + builder.push_spec(create_request_user_input_tool()); + builder.register_handler("request_user_input", request_user_input_handler); + } + if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type { match apply_patch_tool_type { ApplyPatchToolType::Freeform => { @@ -1398,6 +1490,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1430,6 +1523,7 @@ mod tests { create_list_mcp_resource_templates_tool(), create_read_mcp_resource_tool(), PLAN_TOOL.clone(), + create_request_user_input_tool(), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), @@ -1460,6 +1554,7 @@ mod tests { let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); let mut features = Features::with_defaults(); features.enable(Feature::Collab); + features.enable(Feature::CollaborationModes); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, @@ -1472,6 +1567,33 @@ mod tests { ); } + #[test] + fn request_user_input_requires_collaboration_modes_feature() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.disable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert!( + !tools.iter().any(|t| t.spec.name() == "request_user_input"), + "request_user_input should be disabled when collaboration_modes feature is off" + ); + + features.enable(Feature::CollaborationModes); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + }); + let (tools, _) = build_specs(&tools_config, None).build(); + assert_contains_tool_names(&tools, &["request_user_input"]); + } + fn assert_model_tools( model_slug: &str, features: &Features, @@ -1536,9 +1658,11 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1546,6 +1670,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1555,9 +1680,11 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_default() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1565,6 +1692,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1574,9 +1702,12 @@ mod tests { #[test] fn test_build_specs_gpt5_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1585,6 +1716,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1594,9 +1726,12 @@ mod tests { #[test] fn test_build_specs_gpt51_codex_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1605,6 +1740,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1614,9 +1750,11 @@ mod tests { #[test] fn test_codex_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "local_shell", @@ -1624,6 +1762,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1632,9 +1771,11 @@ mod tests { #[test] fn test_codex_5_1_mini_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1-codex-mini", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1642,6 +1783,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1651,9 +1793,11 @@ mod tests { #[test] fn test_gpt_5_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell", @@ -1661,6 +1805,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1669,9 +1814,11 @@ mod tests { #[test] fn test_gpt_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "gpt-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "shell_command", @@ -1679,6 +1826,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1688,9 +1836,11 @@ mod tests { #[test] fn test_exp_5_1_defaults() { + let mut features = Features::with_defaults(); + features.enable(Feature::CollaborationModes); assert_model_tools( "exp-5.1", - &Features::with_defaults(), + &features, Some(WebSearchMode::Cached), &[ "exec_command", @@ -1699,6 +1849,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1708,9 +1859,12 @@ mod tests { #[test] fn test_codex_mini_unified_exec_web_search() { + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::CollaborationModes); assert_model_tools( "codex-mini-latest", - Features::with_defaults().enable(Feature::UnifiedExec), + &features, Some(WebSearchMode::Live), &[ "exec_command", @@ -1719,6 +1873,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index b8066db38..5cdc440c5 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -53,6 +53,7 @@ mod quota_exceeded; mod read_file; mod remote_models; mod request_compression; +mod request_user_input; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 8a4d0a771..b08b5281c 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::features::Feature; use codex_protocol::config_types::WebSearchMode; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; @@ -36,7 +37,10 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { let mut builder = test_codex() .with_model(model) // Keep tool expectations stable when the default web_search mode changes. - .with_config(|config| config.web_search_mode = Some(WebSearchMode::Cached)); + .with_config(|config| { + config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); + }); let test = builder .build(&server) .await @@ -62,6 +66,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -77,6 +82,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -93,6 +99,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -109,6 +116,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "web_search".to_string(), "view_image".to_string() ], @@ -124,6 +132,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() @@ -140,6 +149,7 @@ async fn model_selects_expected_tools() { "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), "update_plan".to_string(), + "request_user_input".to_string(), "apply_patch".to_string(), "web_search".to_string(), "view_image".to_string() diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4e87a4364..4b0436412 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -92,6 +92,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { config.model = Some("gpt-5.1-codex-max".to_string()); // Keep tool expectations stable when the default web_search mode changes. config.web_search_mode = Some(WebSearchMode::Cached); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -135,6 +136,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -176,6 +178,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); + config.features.enable(Feature::CollaborationModes); config.model = Some("codex-mini-latest".to_string()); }) .build(&server) @@ -240,6 +243,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -316,6 +320,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -538,6 +543,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -645,6 +651,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; @@ -742,6 +749,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.enable(Feature::CollaborationModes); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs new file mode 100644 index 000000000..3c4405eeb --- /dev/null +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -0,0 +1,155 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::HashMap; + +use codex_core::features::Feature; +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::ReasoningSummary; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +fn call_output(req: &ResponsesRequest, call_id: &str) -> String { + 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"), + }; + match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { + 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 call_id = "user-input-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(); + + 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: None, + }) + .await?; + + let request = wait_for_event_match(&codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, call_id); + assert_eq!(request.questions.len(), 1); + + let mut answers = HashMap::new(); + answers.insert( + "confirm_path".to_string(), + RequestUserInputAnswer { + selected: vec!["yes".to_string()], + other: None, + }, + ); + let response = RequestUserInputResponse { answers }; + codex + .submit(Op::UserInputAnswer { + id: request.turn_id.clone(), + response, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let output_text = call_output(&req, call_id); + let output_json: Value = serde_json::from_str(&output_text)?; + assert_eq!( + output_json, + json!({ + "answers": { + "confirm_path": { "selected": ["yes"], "other": Value::Null } + } + }) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 8f5646fc3..d9bdf83ed 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -28,7 +28,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use serde_json::Value; use serde_json::json; - fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) { let raw = req.function_call_output(call_id); assert_eq!( diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0e4e1ddde..7abdf6fb5 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -68,10 +68,12 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserInput` – Any input from the user to kick off a `Turn` - `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::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command + - `EventMsg::RequestUserInput` – Request user input for a tool call - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 42a83e44d..c4a5c27d9 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -606,7 +606,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::SkillsUpdateAvailable | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) => {} + | EventMsg::ThreadRolledBack(_) + | EventMsg::RequestUserInput(_) => {} } CodexStatus::Running } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 531bf90d2..b268b8f1b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -358,6 +358,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) + | EventMsg::RequestUserInput(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::CollabAgentSpawnBegin(_) diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 7e447533d..9a3f076fb 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -137,8 +137,10 @@ impl McpProcess { let initialized = self.read_jsonrpc_message().await?; let os_info = os_info::get(); + let build_version = env!("CARGO_PKG_VERSION"); + let originator = codex_core::default_client::originator().value; let user_agent = format!( - "codex_cli_rs/0.0.0 ({} {}; {}) {} (elicitation test; 0.0.0)", + "{originator}/{build_version} ({} {}; {}) {} (elicitation test; 0.0.0)", os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 513743c97..ebd4787ba 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -14,4 +14,5 @@ pub mod openai_models; pub mod parse_command; pub mod plan_tool; pub mod protocol; +pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1142f182c..d5accde8e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -24,6 +24,7 @@ use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use mcp_types::CallToolResult; @@ -44,6 +45,7 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; +pub use crate::request_user_input::RequestUserInputEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -191,6 +193,15 @@ pub enum Op { decision: ElicitationAction, }, + /// Resolve a request_user_input tool call. + #[serde(rename = "user_input_answer", alias = "request_user_input_response")] + UserInputAnswer { + /// Turn id for the in-flight request. + id: String, + /// User-provided answers. + response: RequestUserInputResponse, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has @@ -723,6 +734,8 @@ pub enum EventMsg { ExecApprovalRequest(ExecApprovalRequestEvent), + RequestUserInput(RequestUserInputEvent), + ElicitationRequest(ElicitationRequestEvent), ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs new file mode 100644 index 000000000..44ad3b630 --- /dev/null +++ b/codex-rs/protocol/src/request_user_input.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestionOption { + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputArgs { + pub questions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputAnswer { + pub selected: Vec, + pub other: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputResponse { + pub answers: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestUserInputEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + pub questions: Vec, +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 11a0ceac8..2518eb41b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2547,7 +2547,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5e9d6425f..ffc763e21 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2318,7 +2318,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } }