mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add request_user_input auto-resolution window contract (#27256)
## Why `request_user_input` is moving beyond its original plan-mode-only workflow, and future default/goal-mode usage needs a way for the model to ask helpful but non-blocking questions without forcing the turn to wait forever. This PR adds an explicit `autoResolutionMs` contract so a later client/runtime change can auto-resolve unanswered prompts after a bounded window while leaving truly blocking questions unchanged. This is contract plumbing only; it does not implement the client-side timer or auto-selection behavior, and the model-facing description treats the field as reserved unless the current runtime explicitly supports auto-resolution. ## What Changed - Added optional `autoResolutionMs` to the model-facing `request_user_input` args and core `RequestUserInputEvent`. - Added model-facing schema text for `autoResolutionMs` while marking it reserved for runtimes that explicitly support auto-resolution. - Bounds `autoResolutionMs` to `60_000..=240_000` ms during argument normalization by clamping out-of-range model-provided values. - Propagated the field through app-server v2 `ToolRequestUserInputParams`, app-server request forwarding, generated TypeScript, and JSON schema fixtures. - Updated app-server, core, protocol, and TUI call sites/tests so omitted values preserve existing `None`/`null` behavior and coverage verifies a `Some(60_000)` round trip. ## Verification - `just test -p codex-app-server-protocol` - `just test -p codex-core request_user_input` - `just test -p codex-app-server request_user_input_round_trip` - `just test -p codex-tui request_user_input` - `just test -p codex-protocol`
This commit is contained in:
committed by
GitHub
Unverified
parent
78bab04116
commit
216ce03031
@@ -655,6 +655,7 @@ async fn handle_request_user_input(
|
||||
|
||||
let args = RequestUserInputArgs {
|
||||
questions: event.questions,
|
||||
auto_resolution_ms: event.auto_resolution_ms,
|
||||
};
|
||||
let response_fut =
|
||||
parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args);
|
||||
|
||||
@@ -433,6 +433,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
|
||||
is_secret: false,
|
||||
options: None,
|
||||
}],
|
||||
auto_resolution_ms: None,
|
||||
},
|
||||
&cancel_token,
|
||||
)
|
||||
@@ -476,6 +477,7 @@ async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
|
||||
is_secret: false,
|
||||
options: None,
|
||||
}],
|
||||
auto_resolution_ms: None,
|
||||
};
|
||||
let response = maybe_auto_review_mcp_request_user_input(
|
||||
&parent_session,
|
||||
|
||||
@@ -254,6 +254,7 @@ async fn should_install_mcp_dependencies(
|
||||
};
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![question],
|
||||
auto_resolution_ms: None,
|
||||
};
|
||||
let sub_id = &turn_context.sub_id;
|
||||
let call_id = format!("mcp-deps-{sub_id}");
|
||||
|
||||
@@ -1297,6 +1297,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![question],
|
||||
auto_resolution_ms: None,
|
||||
};
|
||||
let response = sess
|
||||
.request_user_input(turn_context.as_ref(), call_id.to_string(), args)
|
||||
|
||||
@@ -2391,6 +2391,7 @@ impl Session {
|
||||
call_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
questions: args.questions,
|
||||
auto_resolution_ms: args.auto_resolution_ms,
|
||||
});
|
||||
turn_context
|
||||
.turn_metadata_state
|
||||
|
||||
@@ -6,6 +6,8 @@ use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
|
||||
pub const MIN_AUTO_RESOLUTION_MS: u64 = 60_000;
|
||||
pub const MAX_AUTO_RESOLUTION_MS: u64 = 240_000;
|
||||
|
||||
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
|
||||
let option_props = BTreeMap::from([
|
||||
@@ -66,7 +68,14 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
|
||||
Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
|
||||
);
|
||||
|
||||
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
|
||||
let auto_resolution_ms_schema = JsonSchema::number(Some(format!(
|
||||
"Optional auto-resolution window in milliseconds, from {MIN_AUTO_RESOLUTION_MS} to {MAX_AUTO_RESOLUTION_MS}. Include this only when the question is useful but non-blocking and continuing with best judgment is acceptable if the user does not answer; omit it when explicit user input is required before continuing. Use {MIN_AUTO_RESOLUTION_MS} for lightly helpful context and up to {MAX_AUTO_RESOLUTION_MS} when the answer would materially unblock better work."
|
||||
)));
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
("questions".to_string(), questions_schema),
|
||||
("autoResolutionMs".to_string(), auto_resolution_ms_schema),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
|
||||
@@ -111,13 +120,26 @@ pub fn normalize_request_user_input_args(
|
||||
question.is_other = true;
|
||||
}
|
||||
|
||||
if let Some(auto_resolution_ms) = args.auto_resolution_ms {
|
||||
let clamped_auto_resolution_ms =
|
||||
auto_resolution_ms.clamp(MIN_AUTO_RESOLUTION_MS, MAX_AUTO_RESOLUTION_MS);
|
||||
if clamped_auto_resolution_ms != auto_resolution_ms {
|
||||
tracing::warn!(
|
||||
auto_resolution_ms,
|
||||
clamped_auto_resolution_ms,
|
||||
"clamped request_user_input autoResolutionMs to supported range"
|
||||
);
|
||||
args.auto_resolution_ms = Some(clamped_auto_resolution_ms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
pub fn request_user_input_tool_description(available_modes: &[ModeKind]) -> String {
|
||||
let allowed_modes = format_allowed_modes(available_modes);
|
||||
format!(
|
||||
"Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}."
|
||||
"Request user input for one to three short questions and wait for the response. Set autoResolutionMs, from {MIN_AUTO_RESOLUTION_MS} to {MAX_AUTO_RESOLUTION_MS} milliseconds, only when the question is useful but non-blocking and continuing with best judgment is acceptable if the user does not answer; omit it when explicit user input is required. This tool is only available in {allowed_modes}."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::*;
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::request_user_input_available_modes;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -26,7 +28,15 @@ fn request_user_input_tool_includes_questions_schema() {
|
||||
description: "Ask the user to choose.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(BTreeMap::from([(
|
||||
parameters: JsonSchema::object(BTreeMap::from([
|
||||
(
|
||||
"autoResolutionMs".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Optional auto-resolution window in milliseconds, from 60000 to 240000. Include this only when the question is useful but non-blocking and continuing with best judgment is acceptable if the user does not answer; omit it when explicit user input is required before continuing. Use 60000 for lightly helpful context and up to 240000 when the answer would materially unblock better work."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"questions".to_string(),
|
||||
JsonSchema::array(
|
||||
JsonSchema::object(
|
||||
@@ -96,12 +106,97 @@ fn request_user_input_tool_includes_questions_schema() {
|
||||
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
|
||||
),
|
||||
),
|
||||
)]), Some(vec!["questions".to_string()]), Some(false.into())),
|
||||
),
|
||||
]), Some(vec!["questions".to_string()]), Some(false.into())),
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_request_user_input_args_clamps_out_of_range_auto_resolution_ms() {
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
id: "confirm".to_string(),
|
||||
header: "Confirm".to_string(),
|
||||
question: "Proceed?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![RequestUserInputQuestionOption {
|
||||
label: "Yes (Recommended)".to_string(),
|
||||
description: "Continue.".to_string(),
|
||||
}]),
|
||||
}],
|
||||
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS - 1),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
normalize_request_user_input_args(args.clone()),
|
||||
Ok(RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
is_other: true,
|
||||
..args.questions[0].clone()
|
||||
}],
|
||||
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_request_user_input_args(RequestUserInputArgs {
|
||||
auto_resolution_ms: Some(MAX_AUTO_RESOLUTION_MS + 1),
|
||||
..args.clone()
|
||||
}),
|
||||
Ok(RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
is_other: true,
|
||||
..args.questions[0].clone()
|
||||
}],
|
||||
auto_resolution_ms: Some(MAX_AUTO_RESOLUTION_MS),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_request_user_input_args_accepts_auto_resolution_boundaries() {
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
id: "confirm".to_string(),
|
||||
header: "Confirm".to_string(),
|
||||
question: "Proceed?".to_string(),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![RequestUserInputQuestionOption {
|
||||
label: "Yes (Recommended)".to_string(),
|
||||
description: "Continue.".to_string(),
|
||||
}]),
|
||||
}],
|
||||
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
normalize_request_user_input_args(args.clone()),
|
||||
Ok(RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
is_other: true,
|
||||
..args.questions[0].clone()
|
||||
}],
|
||||
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_request_user_input_args(RequestUserInputArgs {
|
||||
auto_resolution_ms: Some(MAX_AUTO_RESOLUTION_MS),
|
||||
..args.clone()
|
||||
}),
|
||||
Ok(RequestUserInputArgs {
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
is_other: true,
|
||||
..args.questions[0].clone()
|
||||
}],
|
||||
auto_resolution_ms: Some(MAX_AUTO_RESOLUTION_MS),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
|
||||
assert_eq!(
|
||||
@@ -136,10 +231,10 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
|
||||
fn request_user_input_tool_description_mentions_available_modes() {
|
||||
assert_eq!(
|
||||
request_user_input_tool_description(&default_available_modes()),
|
||||
"Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string()
|
||||
"Request user input for one to three short questions and wait for the response. Set autoResolutionMs, from 60000 to 240000 milliseconds, only when the question is useful but non-blocking and continuing with best judgment is acceptable if the user does not answer; omit it when explicit user input is required. This tool is only available in Plan mode.".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
request_user_input_tool_description(&default_mode_enabled_available_modes()),
|
||||
"Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string()
|
||||
"Request user input for one to three short questions and wait for the response. Set autoResolutionMs, from 60000 to 240000 milliseconds, only when the question is useful but non-blocking and continuing with best judgment is acceptable if the user does not answer; omit it when explicit user input is required. This tool is only available in Default or Plan mode.".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user