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:
Shijie Rao
2026-06-11 22:30:41 -07:00
committed by GitHub
Unverified
parent 78bab04116
commit 216ce03031
25 changed files with 243 additions and 16 deletions
@@ -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()
);
}