From a2e67a591812187db58f24fc169b0aca6e891e86 Mon Sep 17 00:00:00 2001 From: rafaelj Date: Wed, 20 May 2026 23:18:52 -0400 Subject: [PATCH] Add onboarding interactive tools --- codex-rs/app-server-client/src/lib.rs | 8 + .../src/protocol/common.rs | 12 + .../app-server-protocol/src/protocol/v2.rs | 101 +++++++- .../app-server/src/bespoke_event_handling.rs | 243 ++++++++++++++++++ codex-rs/core/src/codex_delegate_tests.rs | 10 +- codex-rs/core/src/mcp_skill_dependencies.rs | 4 + codex-rs/core/src/mcp_tool_call.rs | 4 + codex-rs/core/src/session/handlers.rs | 26 ++ codex-rs/core/src/session/mod.rs | 137 ++++++++++ codex-rs/core/src/session/turn.rs | 2 + codex-rs/core/src/skills.rs | 4 + codex-rs/core/src/state/turn.rs | 37 +++ codex-rs/core/src/tools/handlers/mod.rs | 4 + .../core/src/tools/handlers/option_picker.rs | 62 +++++ .../handlers/request_user_input_tests.rs | 1 + .../handlers/setup_codex_context_picker.rs | 56 ++++ codex-rs/core/src/tools/spec.rs | 10 + codex-rs/exec/src/lib.rs | 24 ++ codex-rs/features/src/lib.rs | 8 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/protocol/src/lib.rs | 2 + codex-rs/protocol/src/option_picker.rs | 58 +++++ codex-rs/protocol/src/protocol.rs | 23 ++ codex-rs/protocol/src/request_user_input.rs | 35 ++- .../src/setup_codex_context_picker.rs | 29 +++ codex-rs/rollout-trace/src/protocol_event.rs | 4 + codex-rs/rollout/src/policy.rs | 2 + codex-rs/tools/src/lib.rs | 6 + codex-rs/tools/src/option_picker_tool.rs | 65 +++++ codex-rs/tools/src/request_user_input_tool.rs | 32 +-- .../src/request_user_input_tool_tests.rs | 18 -- .../src/setup_codex_context_picker_tool.rs | 17 ++ codex-rs/tools/src/tool_config.rs | 4 + codex-rs/tools/src/tool_registry_plan.rs | 26 ++ .../tools/src/tool_registry_plan_tests.rs | 53 +++- .../tools/src/tool_registry_plan_types.rs | 2 + codex-rs/tui/src/app/app_server_adapter.rs | 6 + codex-rs/tui/src/app/app_server_requests.rs | 32 ++- .../tui/src/app/pending_interactive_replay.rs | 6 +- codex-rs/tui/src/app/replay_filter.rs | 1 + codex-rs/tui/src/app/side.rs | 2 + codex-rs/tui/src/app/tests.rs | 6 +- .../src/bottom_pane/request_user_input/mod.rs | 86 +++++-- codex-rs/tui/src/chatwidget.rs | 7 + codex-rs/tui/src/chatwidget/interrupts.rs | 6 +- .../tui/src/chatwidget/tests/plan_mode.rs | 20 +- 46 files changed, 1201 insertions(+), 102 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/option_picker.rs create mode 100644 codex-rs/core/src/tools/handlers/setup_codex_context_picker.rs create mode 100644 codex-rs/protocol/src/option_picker.rs create mode 100644 codex-rs/protocol/src/setup_codex_context_picker.rs create mode 100644 codex-rs/tools/src/option_picker_tool.rs create mode 100644 codex-rs/tools/src/setup_codex_context_picker_tool.rs diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 9c583ea36b..8e2f248c69 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1742,6 +1742,10 @@ mod tests { id: "question-1".to_string(), header: "Mode".to_string(), question: "Pick one".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![]), @@ -1805,6 +1809,10 @@ mod tests { id: "question-1".to_string(), header: "Mode".to_string(), question: "Pick one".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![]), diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 01985b7bd3..46e73c41c7 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1100,6 +1100,18 @@ server_request_definitions! { response: v2::ToolRequestUserInputResponse, }, + /// EXPERIMENTAL - Request an onboarding option picker. + ToolOptionPicker => "item/tool/requestOptionPicker" { + params: v2::ToolOptionPickerParams, + response: v2::ToolOptionPickerResponse, + }, + + /// EXPERIMENTAL - Request the onboarding context source picker. + ToolSetupCodexContextPicker => "item/tool/requestSetupCodexContextPicker" { + params: v2::ToolSetupCodexContextPickerParams, + response: v2::ToolSetupCodexContextPickerResponse, + }, + /// Request input for an MCP server elicitation. McpServerElicitationRequest => "mcpServer/elicitation/request" { params: v2::McpServerElicitationRequestParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 957a8a2272..c0ba32c399 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -49,6 +49,7 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::option_picker::OptionPickerAction as CoreOptionPickerAction; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; @@ -78,7 +79,6 @@ use codex_protocol::protocol::HookSource as CoreHookSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::ModelVerification as CoreModelVerification; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; -use codex_protocol::request_user_input::RequestUserInputType; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; @@ -102,6 +102,8 @@ use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::request_user_input::RequestUserInputType; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerAction as CoreSetupCodexContextPickerAction; use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -7542,6 +7544,10 @@ pub struct ToolRequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + pub input_type: Option, + pub allow_multiple: Option, + pub optional: Option, + pub placeholder: Option, #[serde(default)] pub is_other: bool, #[serde(default)] @@ -7557,9 +7563,7 @@ pub struct ToolRequestUserInputParams { pub thread_id: String, pub turn_id: String, pub item_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub input_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub option_picker_allow_multiple: Option, pub questions: Vec, } @@ -7580,6 +7584,97 @@ pub struct ToolRequestUserInputResponse { pub answers: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_option_picker. +pub struct ToolOptionPickerOption { + pub label: String, + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_option_picker event. +pub struct ToolOptionPickerParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub question: String, + pub options: Vec, + pub allow_multiple: bool, + pub submit_label: Option, + pub skip_label: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ToolOptionPickerAction { + Submit, + Skip, + Dismiss, +} + +impl From for CoreOptionPickerAction { + fn from(value: ToolOptionPickerAction) -> Self { + match value { + ToolOptionPickerAction::Submit => Self::Submit, + ToolOptionPickerAction::Skip => Self::Skip, + ToolOptionPickerAction::Dismiss => Self::Dismiss, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload from request_option_picker. +pub struct ToolOptionPickerResponse { + pub action: ToolOptionPickerAction, + pub selected_options: Vec, + pub freeform_answer: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a setup_codex_context_picker event. +pub struct ToolSetupCodexContextPickerParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ToolSetupCodexContextPickerAction { + Continue, + Skip, + Dismiss, +} + +impl From for CoreSetupCodexContextPickerAction { + fn from(value: ToolSetupCodexContextPickerAction) -> Self { + match value { + ToolSetupCodexContextPickerAction::Continue => Self::Continue, + ToolSetupCodexContextPickerAction::Skip => Self::Skip, + ToolSetupCodexContextPickerAction::Dismiss => Self::Dismiss, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload from the setup Codex context picker. +pub struct ToolSetupCodexContextPickerResponse { + pub action: ToolSetupCodexContextPickerAction, + pub selected_sources: Vec, +} + #[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 0d68a62480..4076b240f6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -91,10 +91,15 @@ use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::ToolOptionPickerOption; +use codex_app_server_protocol::ToolOptionPickerParams; +use codex_app_server_protocol::ToolOptionPickerResponse; 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::ToolSetupCodexContextPickerParams; +use codex_app_server_protocol::ToolSetupCodexContextPickerResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; @@ -123,6 +128,7 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::option_picker::OptionPickerResponse as CoreOptionPickerResponse; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; @@ -143,6 +149,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerResponse as CoreSetupCodexContextPickerResponse; use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; @@ -823,6 +830,10 @@ pub(crate) async fn apply_bespoke_event_handling( id: question.id, header: question.header, question: question.question, + input_type: question.input_type, + allow_multiple: question.allow_multiple, + optional: question.optional, + placeholder: question.placeholder, is_other: question.is_other, is_secret: question.is_secret, options: question.options.map(|options| { @@ -877,6 +888,99 @@ pub(crate) async fn apply_bespoke_event_handling( } } } + EventMsg::OptionPicker(request) => { + if matches!(api_version, ApiVersion::V2) { + let params = ToolOptionPickerParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + question: request.question, + options: request + .options + .into_iter() + .map(|option| ToolOptionPickerOption { + label: option.label, + description: option.description, + }) + .collect(), + allow_multiple: request.allow_multiple, + submit_label: request.submit_label, + skip_label: request.skip_label, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::ToolOptionPicker(params)) + .await; + tokio::spawn(async move { + on_option_picker_response( + event_turn_id, + pending_request_id, + rx, + conversation, + thread_state, + ) + .await; + }); + } else { + error!( + "request_option_picker is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreOptionPickerResponse { + action: codex_protocol::option_picker::OptionPickerAction::Dismiss, + selected_options: Vec::new(), + freeform_answer: None, + }; + if let Err(err) = conversation + .submit(Op::OptionPickerResponse { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit OptionPickerResponse: {err}"); + } + } + } + EventMsg::SetupCodexContextPicker(request) => { + if matches!(api_version, ApiVersion::V2) { + let params = ToolSetupCodexContextPickerParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + item_id: request.call_id, + }; + let (pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::ToolSetupCodexContextPicker(params)) + .await; + tokio::spawn(async move { + on_setup_codex_context_picker_response( + event_turn_id, + pending_request_id, + rx, + conversation, + thread_state, + ) + .await; + }); + } else { + error!( + "setup_codex_context_picker is only supported on api v2 (call_id: {})", + request.call_id + ); + let empty = CoreSetupCodexContextPickerResponse { + action: codex_protocol::setup_codex_context_picker::SetupCodexContextPickerAction::Dismiss, + selected_source_ids: Vec::new(), + }; + if let Err(err) = conversation + .submit(Op::SetupCodexContextPickerResponse { + id: event_turn_id, + response: empty, + }) + .await + { + error!("failed to submit SetupCodexContextPickerResponse: {err}"); + } + } + } EventMsg::ElicitationRequest(request) => { if matches!(api_version, ApiVersion::V2) { let permission_guard = thread_watch_manager @@ -2597,6 +2701,145 @@ async fn on_request_user_input_response( } } +async fn on_option_picker_response( + event_turn_id: String, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + let empty = || CoreOptionPickerResponse { + action: codex_protocol::option_picker::OptionPickerAction::Dismiss, + selected_options: Vec::new(), + freeform_answer: None, + }; + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + if let Err(err) = conversation + .submit(Op::OptionPickerResponse { + id: event_turn_id, + response: empty(), + }) + .await + { + error!("failed to submit OptionPickerResponse: {err}"); + } + return; + } + Err(err) => { + error!("request failed: {err:?}"); + if let Err(err) = conversation + .submit(Op::OptionPickerResponse { + id: event_turn_id, + response: empty(), + }) + .await + { + error!("failed to submit OptionPickerResponse: {err}"); + } + return; + } + }; + + let response = + serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ToolOptionPickerResponse: {err}"); + ToolOptionPickerResponse { + action: codex_app_server_protocol::ToolOptionPickerAction::Dismiss, + selected_options: Vec::new(), + freeform_answer: None, + } + }); + let response = CoreOptionPickerResponse { + action: response.action.into(), + selected_options: response.selected_options, + freeform_answer: response.freeform_answer, + }; + + if let Err(err) = conversation + .submit(Op::OptionPickerResponse { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit OptionPickerResponse: {err}"); + } +} + +async fn on_setup_codex_context_picker_response( + event_turn_id: String, + pending_request_id: RequestId, + receiver: oneshot::Receiver, + conversation: Arc, + thread_state: Arc>, +) { + let response = receiver.await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + let empty = || CoreSetupCodexContextPickerResponse { + action: codex_protocol::setup_codex_context_picker::SetupCodexContextPickerAction::Dismiss, + selected_source_ids: Vec::new(), + }; + let value = match response { + Ok(Ok(value)) => value, + Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, + Ok(Err(err)) => { + error!("request failed with client error: {err:?}"); + if let Err(err) = conversation + .submit(Op::SetupCodexContextPickerResponse { + id: event_turn_id, + response: empty(), + }) + .await + { + error!("failed to submit SetupCodexContextPickerResponse: {err}"); + } + return; + } + Err(err) => { + error!("request failed: {err:?}"); + if let Err(err) = conversation + .submit(Op::SetupCodexContextPickerResponse { + id: event_turn_id, + response: empty(), + }) + .await + { + error!("failed to submit SetupCodexContextPickerResponse: {err}"); + } + return; + } + }; + + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize ToolSetupCodexContextPickerResponse: {err}"); + ToolSetupCodexContextPickerResponse { + action: codex_app_server_protocol::ToolSetupCodexContextPickerAction::Dismiss, + selected_sources: Vec::new(), + } + }); + let response = CoreSetupCodexContextPickerResponse { + action: response.action.into(), + selected_source_ids: response.selected_sources, + }; + + if let Err(err) = conversation + .submit(Op::SetupCodexContextPickerResponse { + id: event_turn_id, + response, + }) + .await + { + error!("failed to submit SetupCodexContextPickerResponse: {err}"); + } +} + async fn on_mcp_server_elicitation_response( server_name: String, request_id: codex_protocol::mcp::RequestId, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 790abeadd7..7b6d830342 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -416,12 +416,18 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { &RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "child-turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![RequestUserInputQuestion { id: format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_call-1"), header: "Approve app tool call?".to_string(), question: "Allow this app tool?".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: None, diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index c5bbac2daf..c8fc373053 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -233,6 +233,10 @@ async fn should_install_mcp_dependencies( question: format!( "The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?" ), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 4ab55c7d12..bfc95ec443 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1355,6 +1355,10 @@ fn build_mcp_tool_approval_question( id: question_id, header: "Approve app tool call?".to_string(), question, + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(options), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 4965cdb389..62ba6ae982 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -36,6 +36,7 @@ use codex_mcp::collect_mcp_snapshot_from_manager; use codex_mcp::compute_auth_statuses; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; +use codex_protocol::option_picker::OptionPickerResponse; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -457,6 +458,23 @@ pub async fn request_user_input_response( sess.notify_user_input_response(&id, response).await; } +pub async fn option_picker_response( + sess: &Arc, + id: String, + response: OptionPickerResponse, +) { + sess.notify_option_picker_response(&id, response).await; +} + +pub async fn setup_codex_context_picker_response( + sess: &Arc, + id: String, + response: codex_protocol::setup_codex_context_picker::SetupCodexContextPickerResponse, +) { + sess.notify_setup_codex_context_picker_response(&id, response) + .await; +} + pub async fn request_permissions_response( sess: &Arc, id: String, @@ -1086,6 +1104,14 @@ pub(super) async fn submission_loop( request_user_input_response(&sess, id, response).await; false } + Op::OptionPickerResponse { id, response } => { + option_picker_response(&sess, id, response).await; + false + } + Op::SetupCodexContextPickerResponse { id, response } => { + setup_codex_context_picker_response(&sess, id, response).await; + false + } Op::RequestPermissionsResponse { id, response } => { request_permissions_response(&sess, id, response).await; false diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 91cb8a568f..da8cbabdf6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -97,6 +97,9 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::models::format_allow_prefixes; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::option_picker::OptionPickerArgs; +use codex_protocol::option_picker::OptionPickerEvent; +use codex_protocol::option_picker::OptionPickerResponse; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileChange; @@ -121,6 +124,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerEvent; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerResponse; use codex_rmcp_client::ElicitationResponse; use codex_rollout::state_db; use codex_rollout_trace::AgentResultTracePayload; @@ -2180,6 +2185,138 @@ impl Session { } } + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn request_option_picker( + &self, + turn_context: &TurnContext, + call_id: String, + args: OptionPickerArgs, + ) -> 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_option_picker(sub_id, tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending option picker for sub_id: {event_id}"); + } + + let event = EventMsg::OptionPicker(OptionPickerEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + question: args.question, + options: args.options, + allow_multiple: args.allow_multiple, + submit_label: args.submit_label, + skip_label: args.skip_label, + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn notify_option_picker_response( + &self, + sub_id: &str, + response: OptionPickerResponse, + ) { + 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_option_picker(sub_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending option picker found for sub_id: {sub_id}"); + } + } + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn request_setup_codex_context_picker( + &self, + turn_context: &TurnContext, + call_id: String, + ) -> 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_setup_codex_context_picker(sub_id, tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending setup codex context picker for sub_id: {event_id}"); + } + + let event = EventMsg::SetupCodexContextPicker(SetupCodexContextPickerEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + }); + self.send_event(turn_context, event).await; + rx_response.await.ok() + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn checks and turn state updates must remain atomic" + )] + pub async fn notify_setup_codex_context_picker_response( + &self, + sub_id: &str, + response: SetupCodexContextPickerResponse, + ) { + 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_setup_codex_context_picker(sub_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending setup codex context picker found for sub_id: {sub_id}"); + } + } + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 823dcbfc3d..1f0879b800 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1470,6 +1470,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) + | EventMsg::OptionPicker(_) + | EventMsg::SetupCodexContextPicker(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::GuardianAssessment(_) diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 6a5f449470..0d94bd8511 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -125,6 +125,10 @@ async fn request_skill_dependencies( question: format!( "{requirement} This is an experimental internal feature. The value is stored in memory for this session only." ), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: true, options: None, diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 0dd0c09379..f16ec69110 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -11,9 +11,11 @@ use tokio_util::task::AbortOnDropHandle; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::option_picker::OptionPickerResponse; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerResponse; use codex_rmcp_client::ElicitationResponse; use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::RequestId; @@ -111,6 +113,9 @@ pub(crate) struct TurnState { pending_approvals: HashMap>, pending_request_permissions: HashMap, pending_user_input: HashMap>, + pending_option_picker: HashMap>, + pending_setup_codex_context_picker: + HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, pending_dynamic_tools: HashMap>, pending_input: Vec, @@ -148,6 +153,8 @@ impl TurnState { self.pending_approvals.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); + self.pending_option_picker.clear(); + self.pending_setup_codex_context_picker.clear(); self.pending_elicitations.clear(); self.pending_dynamic_tools.clear(); self.pending_input.clear(); @@ -184,6 +191,36 @@ impl TurnState { self.pending_user_input.remove(key) } + pub(crate) fn insert_pending_option_picker( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_option_picker.insert(key, tx) + } + + pub(crate) fn remove_pending_option_picker( + &mut self, + key: &str, + ) -> Option> { + self.pending_option_picker.remove(key) + } + + pub(crate) fn insert_pending_setup_codex_context_picker( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_setup_codex_context_picker.insert(key, tx) + } + + pub(crate) fn remove_pending_setup_codex_context_picker( + &mut self, + key: &str, + ) -> Option> { + self.pending_setup_codex_context_picker.remove(key) + } + pub(crate) fn insert_pending_elicitation( &mut self, server_name: String, diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 0ddd1e5062..112f48b528 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -8,9 +8,11 @@ mod mcp_resource; pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; +mod option_picker; mod plan; mod request_permissions; mod request_user_input; +mod setup_codex_context_picker; mod shell; mod test_sync; mod tool_search; @@ -41,9 +43,11 @@ pub use goal::GoalHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +pub use option_picker::OptionPickerHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_user_input::RequestUserInputHandler; +pub use setup_codex_context_picker::SetupCodexContextPickerHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/option_picker.rs b/codex-rs/core/src/tools/handlers/option_picker.rs new file mode 100644 index 0000000000..9f2ec4d6a1 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/option_picker.rs @@ -0,0 +1,62 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::option_picker::OptionPickerArgs; +use codex_tools::REQUEST_OPTION_PICKER_TOOL_NAME; + +pub struct OptionPickerHandler; + +impl ToolHandler for OptionPickerHandler { + type Output = FunctionToolOutput; + + 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(format!( + "{REQUEST_OPTION_PICKER_TOOL_NAME} handler received unsupported payload" + ))); + } + }; + + if turn.session_source.is_non_root_agent() { + return Err(FunctionCallError::RespondToModel( + "request_option_picker can only be used by the root thread".to_string(), + )); + } + + let args: OptionPickerArgs = parse_arguments(&arguments)?; + let response = session + .request_option_picker(turn.as_ref(), call_id, args) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "{REQUEST_OPTION_PICKER_TOOL_NAME} was cancelled before receiving a response" + )) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {REQUEST_OPTION_PICKER_TOOL_NAME} response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 811e4a6686..2e8b0efb7f 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -24,6 +24,7 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() { let result = RequestUserInputHandler { default_mode_request_user_input: true, + option_picker_enabled: false, } .handle(ToolInvocation { session: Arc::new(session), diff --git a/codex-rs/core/src/tools/handlers/setup_codex_context_picker.rs b/codex-rs/core/src/tools/handlers/setup_codex_context_picker.rs new file mode 100644 index 0000000000..8207d30dc1 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/setup_codex_context_picker.rs @@ -0,0 +1,56 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::setup_codex_context_picker::SetupCodexContextPickerArgs; +use codex_tools::SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME; + +pub struct SetupCodexContextPickerHandler; + +impl ToolHandler for SetupCodexContextPickerHandler { + type Output = FunctionToolOutput; + + 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(format!( + "{SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME} handler received unsupported payload" + ))); + } + }; + + let _: SetupCodexContextPickerArgs = parse_arguments(&arguments)?; + let response = session + .request_setup_codex_context_picker(turn.as_ref(), call_id) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "{SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME} was cancelled before receiving a response" + )) + })?; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize {SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME} response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c167fdbc3c..d2596a9093 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -84,9 +84,11 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; + use crate::tools::handlers::OptionPickerHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; + use crate::tools::handlers::SetupCodexContextPickerHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; @@ -168,6 +170,8 @@ pub(crate) fn build_specs_with_discoverable_tools( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); + let option_picker_handler = Arc::new(OptionPickerHandler); + let setup_codex_context_picker_handler = Arc::new(SetupCodexContextPickerHandler); let deferred_dynamic_tools = dynamic_tools .iter() .filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none())) @@ -241,6 +245,12 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::RequestPermissions => { builder.register_handler(handler.name, request_permissions_handler.clone()); } + ToolHandlerKind::OptionPicker => { + builder.register_handler(handler.name, option_picker_handler.clone()); + } + ToolHandlerKind::SetupCodexContextPicker => { + builder.register_handler(handler.name, setup_codex_context_picker_handler.clone()); + } ToolHandlerKind::RequestUserInput => { builder.register_handler(handler.name, request_user_input_handler.clone()); } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7846536206..60524e9c44 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1486,6 +1486,30 @@ async fn handle_server_request( ) .await } + ServerRequest::ToolOptionPicker { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "request_option_picker is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } + ServerRequest::ToolSetupCodexContextPicker { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "setup_codex_context_picker is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } ServerRequest::DynamicToolCall { request_id, params } => { reject_server_request( client, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index d997cc771f..398eb0fd12 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -188,6 +188,8 @@ pub enum Feature { Steer, /// Allow request_user_input in Default collaboration mode. DefaultModeRequestUserInput, + /// Allow onboarding-only interactive tools. + OnboardingInteractiveTools, /// Enable automatic review for approval prompts. GuardianApproval, /// Enable persisted thread goals and automatic goal continuation. @@ -948,6 +950,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::OnboardingInteractiveTools, + key: "onboarding_interactive_tools", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::GuardianApproval, key: "guardian_approval", diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f759f8bb85..326cd0d1c0 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -381,6 +381,8 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) + | EventMsg::OptionPicker(_) + | EventMsg::SetupCodexContextPicker(_) | EventMsg::RequestPermissions(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 175c92331f..05c60cf8c1 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -19,11 +19,13 @@ pub mod models; pub mod network_policy; pub mod num_format; pub mod openai_models; +pub mod option_picker; pub mod parse_command; pub mod permissions; pub mod plan_tool; pub mod protocol; pub mod request_permissions; pub mod request_user_input; +pub mod setup_codex_context_picker; pub mod shell_environment; pub mod user_input; diff --git a/codex-rs/protocol/src/option_picker.rs b/codex-rs/protocol/src/option_picker.rs new file mode 100644 index 0000000000..553980a659 --- /dev/null +++ b/codex-rs/protocol/src/option_picker.rs @@ -0,0 +1,58 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct OptionPickerOption { + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct OptionPickerArgs { + pub question: String, + pub options: Vec, + #[serde(default)] + pub allow_multiple: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_label: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum OptionPickerAction { + Submit, + Skip, + Dismiss, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct OptionPickerResponse { + pub action: OptionPickerAction, + pub selected_options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub freeform_answer: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct OptionPickerEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + pub turn_id: String, + pub question: String, + pub options: Vec, + pub allow_multiple: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_label: Option, +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9ff684f7c..c9f1564087 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -43,6 +43,7 @@ use crate::models::SandboxEnforcement; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; +use crate::option_picker::OptionPickerResponse; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use crate::request_permissions::RequestPermissionsEvent; @@ -75,6 +76,7 @@ pub use crate::approvals::NetworkApprovalContext; pub use crate::approvals::NetworkApprovalProtocol; pub use crate::approvals::NetworkPolicyAmendment; pub use crate::approvals::NetworkPolicyRuleAction; +pub use crate::option_picker::OptionPickerEvent; pub use crate::permissions::FileSystemAccessMode; pub use crate::permissions::FileSystemPath; pub use crate::permissions::FileSystemSandboxEntry; @@ -85,6 +87,7 @@ pub use crate::permissions::NetworkSandboxPolicy; use crate::permissions::default_read_only_subpaths_for_writable_root; pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; +pub use crate::setup_codex_context_picker::SetupCodexContextPickerEvent; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -698,6 +701,22 @@ pub enum Op { response: RequestUserInputResponse, }, + /// Resolve a request_option_picker tool call. + OptionPickerResponse { + /// Turn id for the in-flight request. + id: String, + /// User-selected option picker response. + response: OptionPickerResponse, + }, + + /// Resolve a setup_codex_context_picker tool call. + SetupCodexContextPickerResponse { + /// Turn id for the in-flight request. + id: String, + /// User-selected onboarding context sources. + response: crate::setup_codex_context_picker::SetupCodexContextPickerResponse, + }, + /// Resolve a request_permissions tool call. RequestPermissionsResponse { /// Call id for the in-flight request. @@ -891,6 +910,8 @@ impl Op { Self::PatchApproval { .. } => "patch_approval", Self::ResolveElicitation { .. } => "resolve_elicitation", Self::UserInputAnswer { .. } => "user_input_answer", + Self::OptionPickerResponse { .. } => "option_picker_response", + Self::SetupCodexContextPickerResponse { .. } => "setup_codex_context_picker_response", Self::RequestPermissionsResponse { .. } => "request_permissions_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", Self::AddToHistory { .. } => "add_to_history", @@ -1422,6 +1443,8 @@ pub enum EventMsg { RequestPermissions(RequestPermissionsEvent), RequestUserInput(RequestUserInputEvent), + OptionPicker(OptionPickerEvent), + SetupCodexContextPicker(SetupCodexContextPickerEvent), DynamicToolCallRequest(DynamicToolCallRequest), diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index a7830b52b9..314817e06d 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -12,6 +12,13 @@ pub enum RequestUserInputType { OptionPicker, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum RequestUserInputPlacement { + Composer, + Inline, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestUserInputQuestionOption { pub label: String, @@ -23,6 +30,22 @@ pub struct RequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + #[serde(rename = "inputType", default, skip_serializing_if = "Option::is_none")] + #[schemars(rename = "inputType")] + #[ts(rename = "inputType")] + pub input_type: Option, + #[serde( + rename = "allowMultiple", + default, + skip_serializing_if = "Option::is_none" + )] + #[schemars(rename = "allowMultiple")] + #[ts(rename = "allowMultiple")] + pub allow_multiple: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub optional: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, #[serde(rename = "isOther", default)] #[schemars(rename = "isOther")] #[ts(rename = "isOther")] @@ -37,11 +60,7 @@ pub struct RequestUserInputQuestion { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestUserInputArgs { - #[serde( - rename = "inputType", - default, - skip_serializing_if = "Option::is_none" - )] + #[serde(rename = "inputType", default, skip_serializing_if = "Option::is_none")] #[schemars(rename = "inputType")] #[ts(rename = "inputType")] pub input_type: Option, @@ -74,11 +93,7 @@ pub struct RequestUserInputEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, - #[serde( - rename = "inputType", - default, - skip_serializing_if = "Option::is_none" - )] + #[serde(rename = "inputType", default, skip_serializing_if = "Option::is_none")] #[schemars(rename = "inputType")] #[ts(rename = "inputType")] pub input_type: Option, diff --git a/codex-rs/protocol/src/setup_codex_context_picker.rs b/codex-rs/protocol/src/setup_codex_context_picker.rs new file mode 100644 index 0000000000..0dd73b604c --- /dev/null +++ b/codex-rs/protocol/src/setup_codex_context_picker.rs @@ -0,0 +1,29 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SetupCodexContextPickerArgs {} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum SetupCodexContextPickerAction { + Continue, + Skip, + Dismiss, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SetupCodexContextPickerResponse { + pub action: SetupCodexContextPickerAction, + pub selected_source_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SetupCodexContextPickerEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + pub turn_id: String, +} diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 2aa5af5af6..100e747805 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -254,6 +254,8 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) + | EventMsg::OptionPicker(_) + | EventMsg::SetupCodexContextPicker(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::ElicitationRequest(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 22615623f3..f4528c42ec 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -152,6 +152,8 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) | EventMsg::RequestUserInput(_) + | EventMsg::OptionPicker(_) + | EventMsg::SetupCodexContextPicker(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 7c638ba675..f694f40d7e 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -12,9 +12,11 @@ mod json_schema; mod local_tool; mod mcp_resource_tool; mod mcp_tool; +mod option_picker_tool; mod plan_tool; mod request_user_input_tool; mod responses_api; +mod setup_codex_context_picker_tool; mod tool_config; mod tool_definition; mod tool_discovery; @@ -79,6 +81,8 @@ pub use mcp_resource_tool::create_list_mcp_resources_tool; pub use mcp_resource_tool::create_read_mcp_resource_tool; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; +pub use option_picker_tool::REQUEST_OPTION_PICKER_TOOL_NAME; +pub use option_picker_tool::create_request_option_picker_tool; pub use plan_tool::create_update_plan_tool; pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME; pub use request_user_input_tool::create_request_user_input_tool; @@ -98,6 +102,8 @@ pub use responses_api::dynamic_tool_to_responses_api_tool; pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; +pub use setup_codex_context_picker_tool::SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME; +pub use setup_codex_context_picker_tool::create_setup_codex_context_picker_tool; pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolUserShellType; pub use tool_config::ToolsConfig; diff --git a/codex-rs/tools/src/option_picker_tool.rs b/codex-rs/tools/src/option_picker_tool.rs new file mode 100644 index 0000000000..67cc7dcac0 --- /dev/null +++ b/codex-rs/tools/src/option_picker_tool.rs @@ -0,0 +1,65 @@ +use crate::JsonSchema; +use crate::ResponsesApiTool; +use crate::ToolSpec; +use std::collections::BTreeMap; + +pub const REQUEST_OPTION_PICKER_TOOL_NAME: &str = "request_option_picker"; + +pub fn create_request_option_picker_tool() -> ToolSpec { + let option_props = BTreeMap::from([ + ( + "label".to_string(), + JsonSchema::string(Some("User-facing option label.".to_string())), + ), + ( + "description".to_string(), + JsonSchema::string(Some( + "Optional short description for the option.".to_string(), + )), + ), + ]); + let properties = BTreeMap::from([ + ( + "question".to_string(), + JsonSchema::string(Some("Question to show the user.".to_string())), + ), + ( + "options".to_string(), + JsonSchema::array( + JsonSchema::object( + option_props, + Some(vec!["label".to_string()]), + Some(false.into()), + ), + Some("Selectable options to show in the picker.".to_string()), + ), + ), + ( + "allowMultiple".to_string(), + JsonSchema::boolean(Some( + "Set true when the user may choose more than one option.".to_string(), + )), + ), + ( + "submitLabel".to_string(), + JsonSchema::string(Some("Optional label for the submit button.".to_string())), + ), + ( + "skipLabel".to_string(), + JsonSchema::string(Some("Optional label for the skip button.".to_string())), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: REQUEST_OPTION_PICKER_TOOL_NAME.to_string(), + description: "Ask the user to choose from a compact list of onboarding options. Use only when setting up Codex from the onboarding tutorial.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["question".to_string(), "options".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} diff --git a/codex-rs/tools/src/request_user_input_tool.rs b/codex-rs/tools/src/request_user_input_tool.rs index b718318fc0..e38f5e143e 100644 --- a/codex-rs/tools/src/request_user_input_tool.rs +++ b/codex-rs/tools/src/request_user_input_tool.rs @@ -4,7 +4,6 @@ use crate::ToolSpec; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; use codex_protocol::request_user_input::RequestUserInputArgs; -use serde_json::json; use std::collections::BTreeMap; pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; @@ -53,7 +52,6 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ), ("options".to_string(), options_schema), ]); - let questions_schema = JsonSchema::array( JsonSchema::object( question_props, @@ -61,33 +59,13 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { "id".to_string(), "header".to_string(), "question".to_string(), - "options".to_string(), ]), Some(false.into()), ), Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), ); - let properties = BTreeMap::from([ - ( - "inputType".to_string(), - JsonSchema::string_enum( - vec![json!("structured"), json!("option_picker")], - Some( - "Optional UI presentation hint. Use `option_picker` when the prompt should render as a selectable option picker; otherwise omit this or use `structured`." - .to_string(), - ), - ), - ), - ( - "optionPickerAllowMultiple".to_string(), - JsonSchema::boolean(Some( - "Optional. For `inputType: option_picker`, set true when multiple choices may be selected." - .to_string(), - )), - ), - ("questions".to_string(), questions_schema), - ]); + let properties = BTreeMap::from([("questions".to_string(), questions_schema)]); ToolSpec::Function(ResponsesApiTool { name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), @@ -120,14 +98,6 @@ pub fn request_user_input_unavailable_message( pub fn normalize_request_user_input_args( mut args: RequestUserInputArgs, ) -> Result { - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err("request_user_input requires non-empty options for every question".to_string()); - } - for question in &mut args.questions { question.is_other = true; } diff --git a/codex-rs/tools/src/request_user_input_tool_tests.rs b/codex-rs/tools/src/request_user_input_tool_tests.rs index 13b18180a2..ad2b327b8b 100644 --- a/codex-rs/tools/src/request_user_input_tool_tests.rs +++ b/codex-rs/tools/src/request_user_input_tool_tests.rs @@ -14,23 +14,6 @@ fn request_user_input_tool_includes_questions_schema() { strict: false, defer_loading: None, parameters: JsonSchema::object(BTreeMap::from([ - ( - "inputType".to_string(), - JsonSchema::string_enum( - vec![json!("structured"), json!("option_picker")], - Some( - "Optional UI presentation hint. Use `option_picker` when the prompt should render as a selectable option picker; otherwise omit this or use `structured`." - .to_string(), - ), - ), - ), - ( - "optionPickerAllowMultiple".to_string(), - JsonSchema::boolean(Some( - "Optional. For `inputType: option_picker`, set true when multiple choices may be selected." - .to_string(), - )), - ), ( "questions".to_string(), JsonSchema::array( @@ -93,7 +76,6 @@ fn request_user_input_tool_includes_questions_schema() { "id".to_string(), "header".to_string(), "question".to_string(), - "options".to_string(), ]), Some(false.into()), ), diff --git a/codex-rs/tools/src/setup_codex_context_picker_tool.rs b/codex-rs/tools/src/setup_codex_context_picker_tool.rs new file mode 100644 index 0000000000..f2bcc33f84 --- /dev/null +++ b/codex-rs/tools/src/setup_codex_context_picker_tool.rs @@ -0,0 +1,17 @@ +use crate::JsonSchema; +use crate::ResponsesApiTool; +use crate::ToolSpec; +use std::collections::BTreeMap; + +pub const SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME: &str = "setup_codex_context_picker"; + +pub fn create_setup_codex_context_picker_tool() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME.to_string(), + description: "Open Codex's onboarding context source picker so the user can connect work apps or add a folder before the assistant continues setup.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), + output_schema: None, + }) +} diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index a218a5530a..02bd4c878c 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -110,6 +110,7 @@ pub struct ToolsConfig { pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, pub default_mode_request_user_input: bool, + pub onboarding_interactive_tools_enabled: bool, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, pub agent_jobs_worker_tools: bool, @@ -148,6 +149,8 @@ impl ToolsConfig { let include_agent_jobs = features.enabled(Feature::SpawnCsv); let include_default_mode_request_user_input = features.enabled(Feature::DefaultModeRequestUserInput); + let include_onboarding_interactive_tools = + features.enabled(Feature::OnboardingInteractiveTools); let include_search_tool = model_info.supports_search_tool && features.enabled(Feature::ToolSearch); let include_tool_suggest = features.enabled(Feature::ToolSuggest) @@ -231,6 +234,7 @@ impl ToolsConfig { max_concurrent_threads_per_session: None, wait_agent_min_timeout_ms: None, default_mode_request_user_input: include_default_mode_request_user_input, + onboarding_interactive_tools_enabled: include_onboarding_interactive_tools, experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, agent_jobs_worker_tools, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index eff3750edd..149c3d2427 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -1,7 +1,9 @@ use crate::CommandToolOptions; +use crate::REQUEST_OPTION_PICKER_TOOL_NAME; use crate::REQUEST_USER_INPUT_TOOL_NAME; use crate::ResponsesApiNamespace; use crate::ResponsesApiNamespaceTool; +use crate::SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME; use crate::ShellToolOptions; use crate::SpawnAgentToolOptions; use crate::TOOL_SEARCH_DEFAULT_LIMIT; @@ -38,11 +40,13 @@ use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; +use crate::create_request_option_picker_tool; use crate::create_request_permissions_tool; use crate::create_request_user_input_tool; use crate::create_resume_agent_tool; use crate::create_send_input_tool_v1; use crate::create_send_message_tool; +use crate::create_setup_codex_context_picker_tool; use crate::create_shell_command_tool; use crate::create_shell_tool; use crate::create_spawn_agent_tool_v1; @@ -251,6 +255,28 @@ pub fn build_tool_registry_plan( ToolHandlerKind::RequestUserInput, ); + if config.onboarding_interactive_tools_enabled { + plan.push_spec( + create_request_option_picker_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler( + REQUEST_OPTION_PICKER_TOOL_NAME, + ToolHandlerKind::OptionPicker, + ); + + plan.push_spec( + create_setup_codex_context_picker_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler( + SETUP_CODEX_CONTEXT_PICKER_TOOL_NAME, + ToolHandlerKind::SetupCodexContextPicker, + ); + } + if config.request_permissions_tool_enabled { plan.push_spec( create_request_permissions_tool(request_permissions_tool_description()), diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 4570034238..4fa4b69ba6 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -88,7 +88,10 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { }), create_write_stdin_tool(), create_update_plan_tool(), - request_user_input_tool_spec(/*default_mode_request_user_input*/ false), + request_user_input_tool_spec( + /*default_mode_request_user_input*/ false, + /*onboarding_interactive_tools_enabled*/ false, + ), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { external_web_access: Some(true), @@ -586,7 +589,10 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, - request_user_input_tool_spec(/*default_mode_request_user_input*/ false) + request_user_input_tool_spec( + /*default_mode_request_user_input*/ false, + /*onboarding_interactive_tools_enabled*/ false, + ) ); features.enable(Feature::DefaultModeRequestUserInput); @@ -609,7 +615,43 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, - request_user_input_tool_spec(/*default_mode_request_user_input*/ true) + request_user_input_tool_spec( + /*default_mode_request_user_input*/ true, + /*onboarding_interactive_tools_enabled*/ false, + ) + ); + + features.enable(Feature::OnboardingInteractiveTools); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); + assert_eq!( + request_user_input_tool.spec, + request_user_input_tool_spec( + /*default_mode_request_user_input*/ true, + /*onboarding_interactive_tools_enabled*/ true, + ) + ); + assert_contains_tool_names( + &tools, + &[ + REQUEST_OPTION_PICKER_TOOL_NAME, + REQUEST_LAUNCH_BRIEF_QUESTIONNAIRE_TOOL_NAME, + ], ); } @@ -2343,7 +2385,10 @@ fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { ); } -fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec { +fn request_user_input_tool_spec( + default_mode_request_user_input: bool, + _onboarding_interactive_tools_enabled: bool, +) -> ToolSpec { create_request_user_input_tool(request_user_input_tool_description( default_mode_request_user_input, )) diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index d22335b614..9c4c9bcee1 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -24,7 +24,9 @@ pub enum ToolHandlerKind { Mcp, McpResource, Plan, + OptionPicker, RequestPermissions, + SetupCodexContextPicker, RequestUserInput, ResumeAgentV1, SendInputV1, diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0a1d01aec5..efed7f4970 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -310,6 +310,12 @@ fn server_request_thread_id(request: &ServerRequest) -> Option { ServerRequest::ToolRequestUserInput { params, .. } => { ThreadId::from_string(¶ms.thread_id).ok() } + ServerRequest::ToolOptionPicker { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } + ServerRequest::ToolSetupCodexContextPicker { params, .. } => { + ThreadId::from_string(¶ms.thread_id).ok() + } ServerRequest::McpServerElicitationRequest { params, .. } => { ThreadId::from_string(¶ms.thread_id).ok() } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 03cecf5c05..4682c82508 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -99,6 +99,18 @@ impl PendingAppServerRequests { }); None } + ServerRequest::ToolOptionPicker { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "Option picker is not available in TUI yet.".to_string(), + }) + } + ServerRequest::ToolSetupCodexContextPicker { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "Setup Codex context picker is not available in TUI yet.".to_string(), + }) + } ServerRequest::McpServerElicitationRequest { request_id, params } => { self.mcp_requests.insert( McpLegacyRequestKey { @@ -333,6 +345,8 @@ impl PendingAppServerRequests { .values() .any(|pending_request_id| pending_request_id == request_id), ServerRequest::DynamicToolCall { .. } + | ServerRequest::ToolOptionPicker { .. } + | ServerRequest::ToolSetupCodexContextPicker { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => true, @@ -524,8 +538,10 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-2".to_string(), item_id: "tool-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), }, }), @@ -819,8 +835,10 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "tool-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), }, }); @@ -843,8 +861,10 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: item_id.to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), }, }); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index e82d5fbf74..c9f16318cd 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -609,8 +609,10 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: turn_id.to_string(), item_id: call_id.to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), }, } diff --git a/codex-rs/tui/src/app/replay_filter.rs b/codex-rs/tui/src/app/replay_filter.rs index 273ca0dd5a..825eda0f3a 100644 --- a/codex-rs/tui/src/app/replay_filter.rs +++ b/codex-rs/tui/src/app/replay_filter.rs @@ -15,6 +15,7 @@ pub(super) fn snapshot_has_pending_interactive_request(snapshot: &ThreadEventSna | ServerRequest::FileChangeRequestApproval { .. } | ServerRequest::McpServerElicitationRequest { .. } | ServerRequest::PermissionsRequestApproval { .. } + | ServerRequest::ToolOptionPicker { .. } | ServerRequest::ToolRequestUserInput { .. } ) ) diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 59f3d71991..8c794db61f 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -92,6 +92,8 @@ impl SideParentStatus { | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval), ServerRequest::DynamicToolCall { .. } + | ServerRequest::ToolOptionPicker { .. } + | ServerRequest::ToolSetupCodexContextPicker { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } => None, } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 27fa54a830..5ce55937aa 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4078,8 +4078,10 @@ fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str) thread_id: thread_id.to_string(), turn_id: turn_id.to_string(), item_id: item_id.to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), }, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index c63e40f98a..d3ad397fbf 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1345,6 +1345,10 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ @@ -1369,6 +1373,10 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: true, is_secret: false, options: Some(vec![ @@ -1393,6 +1401,10 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose the next step for this task.".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ @@ -1423,6 +1435,10 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose one option.".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ @@ -1445,6 +1461,10 @@ mod tests { question: "Choose one option; each hint is intentionally very long to test wrapped scrolling." .to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ @@ -1474,6 +1494,10 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Share details.".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: None, @@ -1487,8 +1511,10 @@ mod tests { RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: turn_id.to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions, } } @@ -1550,15 +1576,19 @@ mod tests { overlay.try_consume_user_input_request(RequestUserInputEvent { call_id: "call-2".to_string(), turn_id: "turn-2".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q2", "Second")], }); overlay.try_consume_user_input_request(RequestUserInputEvent { call_id: "call-3".to_string(), turn_id: "turn-3".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q3", "Third")], }); @@ -1575,8 +1605,10 @@ mod tests { RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q1", "First")], }, tx, @@ -1604,8 +1636,10 @@ mod tests { RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q1", "First")], }, tx, @@ -1616,8 +1650,10 @@ mod tests { overlay.try_consume_user_input_request(RequestUserInputEvent { call_id: "call-2".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q2", "Second")], }); @@ -1644,8 +1680,10 @@ mod tests { RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q1", "First")], }, tx, @@ -1656,15 +1694,19 @@ mod tests { overlay.try_consume_user_input_request(RequestUserInputEvent { call_id: "call-2".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q2", "Second")], }); overlay.try_consume_user_input_request(RequestUserInputEvent { call_id: "call-3".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![question_with_options("q3", "Third")], }); @@ -2873,6 +2915,10 @@ mod tests { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ @@ -2925,6 +2971,10 @@ mod tests { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f5eedfeaf3..229703c1fe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1918,6 +1918,10 @@ fn request_user_input_from_params(params: ToolRequestUserInputParams) -> Request id: question.id, header: question.header, question: question.question, + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: question.is_other, is_secret: question.is_secret, options: question.options.map(|options| { @@ -6990,6 +6994,8 @@ impl ChatWidget { self.on_request_user_input(request_user_input_from_params(params)); } ServerRequest::DynamicToolCall { .. } + | ServerRequest::ToolOptionPicker { .. } + | ServerRequest::ToolSetupCodexContextPicker { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { @@ -7771,6 +7777,7 @@ impl ChatWidget { EventMsg::RequestUserInput(ev) => { self.on_request_user_input(ev); } + EventMsg::OptionPicker(_) => {} EventMsg::RequestPermissions(ev) => { self.on_request_permissions(ev); } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 8bd2863428..be83670cd3 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -161,8 +161,10 @@ mod tests { RequestUserInputEvent { call_id: call_id.to_string(), turn_id: turn_id.to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: Vec::new(), } } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 590b850678..406b66de35 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -585,12 +585,18 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n chat.handle_request_user_input_now(RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![RequestUserInputQuestion { id: "reasoning_scope".to_string(), header: "Reasoning scope".to_string(), question: "Which reasoning scope should I use?".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![RequestUserInputQuestionOption { @@ -615,12 +621,18 @@ async fn handle_request_user_input_sets_pending_notification() { chat.handle_request_user_input_now(RequestUserInputEvent { call_id: "call-1".to_string(), turn_id: "turn-1".to_string(), - input_type: None, - option_picker_allow_multiple: None, + title: None, + submit_label: None, + skip_label: None, + placement: None, questions: vec![RequestUserInputQuestion { id: "reasoning_scope".to_string(), header: "Reasoning scope".to_string(), question: "Which reasoning scope should I use?".to_string(), + input_type: None, + allow_multiple: None, + optional: None, + placeholder: None, is_other: false, is_secret: false, options: Some(vec![RequestUserInputQuestionOption {