Add onboarding interactive tools

This commit is contained in:
rafaelj
2026-05-20 23:18:52 -04:00
parent cc6bed0884
commit a2e67a5918
46 changed files with 1201 additions and 102 deletions
+8
View File
@@ -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![]),
@@ -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,
@@ -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<RequestUserInputType>,
pub allow_multiple: Option<bool>,
pub optional: Option<bool>,
pub placeholder: Option<String>,
#[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<RequestUserInputType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub option_picker_allow_multiple: Option<bool>,
pub questions: Vec<ToolRequestUserInputQuestion>,
}
@@ -7580,6 +7584,97 @@ pub struct ToolRequestUserInputResponse {
pub answers: HashMap<String, ToolRequestUserInputAnswer>,
}
#[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<String>,
}
#[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<ToolOptionPickerOption>,
pub allow_multiple: bool,
pub submit_label: Option<String>,
pub skip_label: Option<String>,
}
#[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<ToolOptionPickerAction> 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<String>,
pub freeform_answer: Option<String>,
}
#[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<ToolSetupCodexContextPickerAction> 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<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -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<ClientRequestResult>,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
) {
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::<ToolOptionPickerResponse>(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<ClientRequestResult>,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
) {
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::<ToolSetupCodexContextPickerResponse>(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,
+8 -2
View File
@@ -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,
@@ -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![
+4
View File
@@ -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),
+26
View File
@@ -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<Session>,
id: String,
response: OptionPickerResponse,
) {
sess.notify_option_picker_response(&id, response).await;
}
pub async fn setup_codex_context_picker_response(
sess: &Arc<Session>,
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<Session>,
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
+137
View File
@@ -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<OptionPickerResponse> {
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<SetupCodexContextPickerResponse> {
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"
+2
View File
@@ -1470,6 +1470,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::OptionPicker(_)
| EventMsg::SetupCodexContextPicker(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::GuardianAssessment(_)
+4
View File
@@ -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,
+37
View File
@@ -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<String, oneshot::Sender<ReviewDecision>>,
pending_request_permissions: HashMap<String, PendingRequestPermissions>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_option_picker: HashMap<String, oneshot::Sender<OptionPickerResponse>>,
pending_setup_codex_context_picker:
HashMap<String, oneshot::Sender<SetupCodexContextPickerResponse>>,
pending_elicitations: HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
@@ -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<OptionPickerResponse>,
) -> Option<oneshot::Sender<OptionPickerResponse>> {
self.pending_option_picker.insert(key, tx)
}
pub(crate) fn remove_pending_option_picker(
&mut self,
key: &str,
) -> Option<oneshot::Sender<OptionPickerResponse>> {
self.pending_option_picker.remove(key)
}
pub(crate) fn insert_pending_setup_codex_context_picker(
&mut self,
key: String,
tx: oneshot::Sender<SetupCodexContextPickerResponse>,
) -> Option<oneshot::Sender<SetupCodexContextPickerResponse>> {
self.pending_setup_codex_context_picker.insert(key, tx)
}
pub(crate) fn remove_pending_setup_codex_context_picker(
&mut self,
key: &str,
) -> Option<oneshot::Sender<SetupCodexContextPickerResponse>> {
self.pending_setup_codex_context_picker.remove(key)
}
pub(crate) fn insert_pending_elicitation(
&mut self,
server_name: String,
+4
View File
@@ -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;
@@ -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<Self::Output, FunctionCallError> {
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)))
}
}
@@ -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),
@@ -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<Self::Output, FunctionCallError> {
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)))
}
}
+10
View File
@@ -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());
}
+24
View File
@@ -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,
+8
View File
@@ -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",
@@ -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(_)
+2
View File
@@ -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;
+58
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct OptionPickerArgs {
pub question: String,
pub options: Vec<OptionPickerOption>,
#[serde(default)]
pub allow_multiple: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_label: Option<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform_answer: Option<String>,
}
#[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<OptionPickerOption>,
pub allow_multiple: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_label: Option<String>,
}
+23
View File
@@ -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),
+25 -10
View File
@@ -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<RequestUserInputType>,
#[serde(
rename = "allowMultiple",
default,
skip_serializing_if = "Option::is_none"
)]
#[schemars(rename = "allowMultiple")]
#[ts(rename = "allowMultiple")]
pub allow_multiple: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub optional: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[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<RequestUserInputType>,
@@ -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<RequestUserInputType>,
@@ -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<String>,
}
#[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,
}
@@ -254,6 +254,8 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTr
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::OptionPicker(_)
| EventMsg::SetupCodexContextPicker(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::ElicitationRequest(_)
@@ -335,6 +337,8 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::OptionPicker(_)
| EventMsg::SetupCodexContextPicker(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::ElicitationRequest(_)
+2
View File
@@ -152,6 +152,8 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::RequestUserInput(_)
| EventMsg::OptionPicker(_)
| EventMsg::SetupCodexContextPicker(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::BackgroundEvent(_)
+6
View File
@@ -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;
+65
View File
@@ -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,
})
}
+1 -31
View File
@@ -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<RequestUserInputArgs, String> {
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;
}
@@ -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()),
),
@@ -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,
})
}
+4
View File
@@ -110,6 +110,7 @@ pub struct ToolsConfig {
pub max_concurrent_threads_per_session: Option<usize>,
pub wait_agent_min_timeout_ms: Option<i64>,
pub default_mode_request_user_input: bool,
pub onboarding_interactive_tools_enabled: bool,
pub experimental_supported_tools: Vec<String>,
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,
+26
View File
@@ -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()),
+49 -4
View File
@@ -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,
))
@@ -24,7 +24,9 @@ pub enum ToolHandlerKind {
Mcp,
McpResource,
Plan,
OptionPicker,
RequestPermissions,
SetupCodexContextPicker,
RequestUserInput,
ResumeAgentV1,
SendInputV1,
@@ -310,6 +310,12 @@ fn server_request_thread_id(request: &ServerRequest) -> Option<ThreadId> {
ServerRequest::ToolRequestUserInput { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ToolOptionPicker { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ToolSetupCodexContextPicker { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::McpServerElicitationRequest { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
+26 -6
View File
@@ -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(),
},
});
@@ -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(),
},
}
+1
View File
@@ -15,6 +15,7 @@ pub(super) fn snapshot_has_pending_interactive_request(snapshot: &ThreadEventSna
| ServerRequest::FileChangeRequestApproval { .. }
| ServerRequest::McpServerElicitationRequest { .. }
| ServerRequest::PermissionsRequestApproval { .. }
| ServerRequest::ToolOptionPicker { .. }
| ServerRequest::ToolRequestUserInput { .. }
)
)
+2
View File
@@ -92,6 +92,8 @@ impl SideParentStatus {
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval),
ServerRequest::DynamicToolCall { .. }
| ServerRequest::ToolOptionPicker { .. }
| ServerRequest::ToolSetupCodexContextPicker { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
}
}
+4 -2
View File
@@ -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(),
},
}
@@ -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![
+7
View File
@@ -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);
}
+4 -2
View File
@@ -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(),
}
}
+16 -4
View File
@@ -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 {