From e3f7223f549cfa03484a53eaef2235be568dd4db Mon Sep 17 00:00:00 2001 From: shijie-openai Date: Fri, 16 Jan 2026 23:20:59 -0800 Subject: [PATCH] WIP: RequestUserInput tool --- .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 44 +++++++ .../app-server/src/bespoke_event_handling.rs | 118 ++++++++++++++++++ codex-rs/core/plan_mode_prompt.md | 22 ++-- codex-rs/core/src/codex.rs | 72 +++++++++++ codex-rs/core/src/codex_delegate.rs | 69 ++++++++++ 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 | 111 ++++++++++++++++ codex-rs/core/tests/suite/model_tools.rs | 6 + codex-rs/core/tests/suite/prompt_caching.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 | 12 ++ codex-rs/protocol/src/request_user_input.rs | 49 ++++++++ codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui2/src/chatwidget.rs | 3 +- 22 files changed, 594 insertions(+), 14 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/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 bce2e48c97..9d15d6a97d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -505,6 +505,12 @@ server_request_definitions! { response: v2::FileChangeRequestApprovalResponse, }, + /// 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 c0caa7ea32..e6a8adb2de 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2263,6 +2263,50 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ToolRequestUserInputOption { + pub value: String, + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +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/")] +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/")] +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/")] +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/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0b2520098d..26be97b507 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,58 @@ 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 { + value: option.value, + 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::RequestUserInputResponse { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit RequestUserInputResponse: {err}"); + } + } + } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { let notification = construct_mcp_tool_call_notification( @@ -1347,6 +1405,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::RequestUserInputResponse { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit RequestUserInputResponse: {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::RequestUserInputResponse { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit RequestUserInputResponse: {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/core/plan_mode_prompt.md b/codex-rs/core/plan_mode_prompt.md index 5689f3a178..3ecbeb915b 100644 --- a/codex-rs/core/plan_mode_prompt.md +++ b/codex-rs/core/plan_mode_prompt.md @@ -1,5 +1,5 @@ ## Plan Mode -You are now in **Plan Mode**. Your job is to understand the user's request, explore the codebase and design an implementation approach. +You are now in **Plan Mode**. Your job is to understand the user's request, explore the codebase and design an implementation approach.The finalize plan should include enough context and executable right away instead of having to do another round of exploration. YOU SHOULD NEVER IMPLEMENT ANY CODE CHANGE. ## What happens in Plan Mode In Plan Mode, you will: @@ -13,10 +13,20 @@ In Plan Mode, you will: - Key functions/modules - Sequencing - Testing/verification -- **Write the plan into `PLAN.md`**, then present a concise summary to the user for approval +- **DO NOT INCLUDE PLAN as the first step** given that we are generating a plan for execution. - The final output should always start with `***Here is the plan***` and then following the ouput format below exactly. +## Editing rule (required) +As you work, keep the plan up to date: +- Update the plan **as soon as new information changes the approach** +- Mark completed steps by checking boxes (`[x]`) +- Add/remove steps when scope changes + +## Using `RequestUserInput` in Plan Mode +Use `RequestUserInput` only when you are genuinely blocked on a decision that materially changes the plan (requirements, trade-offs, rollout/risk posture). Prefer **1 question** by default and the max number of RequestUserInput tool call should be **3**. +Do **not** use `RequestUserInput` to ask “is my plan ready?” or “should I proceed?” + ## Plan ouput format (required) — MUST MATCH *exactly* Use this exact Markdown structure so it can be parsed and updated reliably: @@ -57,11 +67,3 @@ Use this exact Markdown structure so it can be parsed and updated reliably: ## Notes - ``` - -## Editing rule (required) -As you work, keep `PLAN.md` up to date: -- Update the plan **as soon as new information changes the approach** -- Mark completed steps by checking boxes (`[x]`) -- Add/remove steps when scope changes -- Edit using **`apply_patch`** (preferred) so changes are minimal, reviewable, and don’t clobber user edits - diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e191bf7de8..92e67bd22d 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; @@ -119,6 +121,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; @@ -1279,6 +1282,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; @@ -1900,6 +1960,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::PatchApproval { id, decision } => { handlers::patch_approval(&sess, id, decision).await; } + Op::RequestUserInputResponse { id, response } => { + handlers::request_user_input_response(&sess, id, response).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -1989,6 +2052,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; @@ -2189,6 +2253,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 49409f8e84..4946d97381 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,57 @@ 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::RequestUserInputResponse { 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 fafdc83102..2d4a79cc22 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 ddc9efafea..06db2dd524 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; @@ -67,6 +68,7 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_user_input: HashMap>, pending_input: Vec, } @@ -88,9 +90,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 ab8123df1e..8b63c9567f 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 0000000000..f63b11d235 --- /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 9d272a5e6f..c0b8b9bcb0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -532,6 +532,101 @@ fn create_wait_tool() -> ToolSpec { }) } +fn create_request_user_input_tool() -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "value".to_string(), + JsonSchema::String { + description: Some("Machine-readable value (snake_case).".to_string()), + }, + ); + 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)\". Do not include an \"Other\" option." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec![ + "value".to_string(), + "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 (1-3). Prefer 1 unless multiple independent decisions block progress." + .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 +1235,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 +1253,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 +1294,9 @@ pub(crate) fn build_specs( builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); + 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 => { @@ -1430,6 +1530,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), @@ -1546,6 +1647,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1565,6 +1667,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1585,6 +1688,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1605,6 +1709,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1624,6 +1729,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1642,6 +1748,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1661,6 +1768,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "web_search", "view_image", ], @@ -1679,6 +1787,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1699,6 +1808,7 @@ mod tests { "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "request_user_input", "apply_patch", "web_search", "view_image", @@ -1719,6 +1829,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/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 8a4d0a7717..c98f95f26d 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -62,6 +62,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 +78,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 +95,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 +112,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 +128,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 +145,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 093932512f..3960643fa8 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -135,6 +135,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", diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 0e4e1ddde3..30494e20aa 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::RequestUserInputResponse` – 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 42a83e44d1..c4a5c27d9c 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 531bf90d23..b268b8f1b4 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 7e447533d0..9a3f076fb1 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 513743c97f..ebd4787ba4 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 29eabe2bfa..f72df6adb3 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. @@ -189,6 +191,14 @@ pub enum Op { decision: ElicitationAction, }, + /// Resolve a request_user_input tool call. + RequestUserInputResponse { + /// 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 @@ -721,6 +731,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 0000000000..6c7c59e33f --- /dev/null +++ b/codex-rs/protocol/src/request_user_input.rs @@ -0,0 +1,49 @@ +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 value: String, + 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 bab27efd0a..90a8ed37cd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2479,7 +2479,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 117d320a06..9f212aac22 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2245,7 +2245,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::RequestUserInput(_) => {} } }