Make user input blocking behavior explicit (#36410)

## Why

Clients need to know whether a `request_user_input` request must wait for an
explicit response or may auto-resolve. Using `autoResolutionMs` as that signal
conflated the blocking decision with timeout policy.

## What changed

- Add required `isBlocking` fields to user input protocol and app-server
  request payloads. Plan-mode requests are blocking, while requests from other
  enabled modes are non-blocking.
- Drive the TUI's auto-resolution behavior from `isBlocking` and remove
  `autoResolutionMs` from the model-facing tool schema.
- Deprecate `autoResolutionMs` while retaining it for compatibility, and treat
  legacy payloads without `isBlocking` as blocking.

## Testing

- Cover mode-derived blocking behavior, legacy deserialization, app-server
  forwarding, delegated requests, and TUI auto-resolution.

GitOrigin-RevId: 29aade657ef743065ec264376ba567a9b353d7d7
This commit is contained in:
Shijie Rao
2026-08-01 00:38:10 +00:00
committed by copyberry
parent a850875a8e
commit e2c0837923
36 changed files with 578 additions and 133 deletions
+2
View File
@@ -1896,6 +1896,7 @@ mod tests {
is_secret: false,
options: Some(vec![]),
}],
is_blocking: true,
auto_resolution_ms: None,
})
.expect("params should serialize"),
@@ -1958,6 +1959,7 @@ mod tests {
is_secret: false,
options: Some(vec![]),
}],
is_blocking: true,
auto_resolution_ms: None,
})
.expect("params should serialize"),
@@ -1732,6 +1732,7 @@
"properties": {
"autoResolutionMs": {
"default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64",
"minimum": 0.0,
"type": [
@@ -1739,6 +1740,9 @@
"null"
]
},
"isBlocking": {
"type": "boolean"
},
"itemId": {
"type": "string"
},
@@ -1756,6 +1760,7 @@
}
},
"required": [
"isBlocking",
"itemId",
"questions",
"threadId",
@@ -59,6 +59,7 @@
"properties": {
"autoResolutionMs": {
"default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64",
"minimum": 0.0,
"type": [
@@ -66,6 +67,9 @@
"null"
]
},
"isBlocking": {
"type": "boolean"
},
"itemId": {
"type": "string"
},
@@ -83,6 +87,7 @@
}
},
"required": [
"isBlocking",
"itemId",
"questions",
"threadId",
@@ -6025,6 +6025,7 @@
"properties": {
"autoResolutionMs": {
"default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64",
"minimum": 0.0,
"type": [
@@ -6032,6 +6033,9 @@
"null"
]
},
"isBlocking": {
"type": "boolean"
},
"itemId": {
"type": "string"
},
@@ -6049,6 +6053,7 @@
}
},
"required": [
"isBlocking",
"itemId",
"questions",
"threadId",
@@ -6,4 +6,8 @@ import type { ToolRequestUserInputQuestion } from "./ToolRequestUserInputQuestio
/**
* EXPERIMENTAL. Params sent with a request_user_input event.
*/
export type ToolRequestUserInputParams = { threadId: string, turnId: string, itemId: string, questions: Array<ToolRequestUserInputQuestion>, autoResolutionMs: number | null, };
export type ToolRequestUserInputParams = { threadId: string, turnId: string, itemId: string, questions: Array<ToolRequestUserInputQuestion>, isBlocking: boolean,
/**
* @deprecated Use `isBlocking` to decide whether the request should block.
*/
autoResolutionMs: number | null, };
@@ -1624,7 +1624,7 @@ pub struct ToolRequestUserInputQuestion {
pub options: Option<Vec<ToolRequestUserInputOption>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Params sent with a request_user_input event.
@@ -1633,11 +1633,41 @@ pub struct ToolRequestUserInputParams {
pub turn_id: String,
pub item_id: String,
pub questions: Vec<ToolRequestUserInputQuestion>,
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(default)]
#[ts(type = "number | null")]
pub auto_resolution_ms: Option<u64>,
}
impl<'de> Deserialize<'de> for ToolRequestUserInputParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireToolRequestUserInputParams {
thread_id: String,
turn_id: String,
item_id: String,
questions: Vec<ToolRequestUserInputQuestion>,
is_blocking: Option<bool>,
auto_resolution_ms: Option<u64>,
}
let wire = WireToolRequestUserInputParams::deserialize(deserializer)?;
Ok(Self {
thread_id: wire.thread_id,
turn_id: wire.turn_id,
item_id: wire.item_id,
questions: wire.questions,
is_blocking: wire.is_blocking.unwrap_or(true),
auto_resolution_ms: wire.auto_resolution_ms,
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4731,6 +4731,7 @@ fn realtime_start_omitted_initial_items_remain_none() {
assert_eq!(params.initial_items, None);
}
#[test]
fn realtime_start_deserializes_client_handoff_channel_prefixes() {
let params = serde_json::from_value::<ThreadRealtimeStartParams>(json!({
@@ -4756,3 +4757,45 @@ fn realtime_start_deserializes_client_handoff_channel_prefixes() {
]))
);
}
#[test]
fn tool_request_user_input_params_default_legacy_missing_is_blocking_to_true() {
let params = serde_json::from_value::<ToolRequestUserInputParams>(json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "call-1",
"questions": [{
"id": "q1",
"header": "Confirm",
"question": "Continue?",
"options": [{
"label": "Yes",
"description": "Continue."
}]
}],
"autoResolutionMs": 60_000
}))
.expect("legacy request_user_input params should deserialize");
assert_eq!(
params,
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![ToolRequestUserInputQuestion {
id: "q1".to_string(),
header: "Confirm".to_string(),
question: "Continue?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![ToolRequestUserInputOption {
label: "Yes".to_string(),
description: "Continue.".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: Some(60_000),
}
);
}
@@ -33,11 +33,8 @@ fn prompt_for_answers_with(
"\n[request_user_input for thread {}, turn {}]",
params.thread_id, params.turn_id
)?;
if let Some(auto_resolution_ms) = params.auto_resolution_ms {
writeln!(
output,
"The app-server may auto-resolve this request after {auto_resolution_ms} ms."
)?;
if !params.is_blocking {
writeln!(output, "This request is non-blocking.")?;
}
let mut answers = HashMap::new();
@@ -43,6 +43,7 @@ fn collects_option_and_free_form_answers() {
options: None,
},
],
is_blocking: false,
auto_resolution_ms: Some(60_000),
};
let mut input = Cursor::new(b"2\ninclude snapshots\n");
@@ -73,7 +74,7 @@ fn collects_option_and_free_form_answers() {
String::from_utf8(output).unwrap(),
concat!(
"\n[request_user_input for thread thread-1, turn turn-1]\n",
"The app-server may auto-resolve this request after 60000 ms.\n",
"This request is non-blocking.\n",
"\nTarget: Which target?\n",
" 1. Core - Inspect core\n",
" 2. TUI - Inspect TUI\n",
@@ -102,6 +103,7 @@ fn retries_invalid_selection_and_collects_other_answer() {
description: "Inspect core".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: None,
};
let mut input = Cursor::new(b"9\no\nSDK wrapper\n");
+2
View File
@@ -1614,6 +1614,8 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
### request_user_input
`item/tool/requestUserInput` includes required `isBlocking`, which indicates whether the client should wait indefinitely for explicit user input. The older `autoResolutionMs` field is deprecated and retained only for compatibility.
When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup.
### Attestation generation
@@ -741,6 +741,7 @@ pub(crate) async fn apply_bespoke_event_handling(
turn_id: request.turn_id,
item_id: request.call_id,
questions,
is_blocking: request.is_blocking,
auto_resolution_ms: request.auto_resolution_ms,
};
let (pending_request_id, rx) = outgoing
@@ -1334,6 +1334,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
is_blocking: true,
auto_resolution_ms: None,
},
))
@@ -1397,6 +1398,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
is_blocking: true,
auto_resolution_ms: None,
},
))
@@ -1170,6 +1170,7 @@ mod thread_processor_behavior_tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
is_blocking: true,
auto_resolution_ms: None,
},
))
@@ -22,10 +22,7 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_request_user_input_sse_response_with_auto_resolution(
call_id: &str,
auto_resolution_ms: u64,
) -> anyhow::Result<String> {
fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<String> {
let tool_call_arguments = serde_json::to_string(&json!({
"questions": [{
"id": "confirm_path",
@@ -38,8 +35,7 @@ fn create_request_user_input_sse_response_with_auto_resolution(
"label": "No",
"description": "Stop and revisit the approach."
}]
}],
"autoResolutionMs": auto_resolution_ms
}]
}))?;
Ok(responses::sse(vec![
@@ -51,11 +47,32 @@ fn create_request_user_input_sse_response_with_auto_resolution(
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn request_user_input_round_trip() -> Result<()> {
request_user_input_round_trip_for_mode(
ModeKind::Plan,
/*enable_default_mode_feature*/ false,
/*expected_is_blocking*/ true,
)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn request_user_input_default_mode_forwards_non_blocking() -> Result<()> {
request_user_input_round_trip_for_mode(
ModeKind::Default,
/*enable_default_mode_feature*/ true,
/*expected_is_blocking*/ false,
)
.await
}
async fn request_user_input_round_trip_for_mode(
mode: ModeKind,
enable_default_mode_feature: bool,
expected_is_blocking: bool,
) -> Result<()> {
let codex_home = tempfile::TempDir::new()?;
let responses = vec![
create_request_user_input_sse_response_with_auto_resolution(
"call1", /*auto_resolution_ms*/ 60_000,
)?,
create_request_user_input_sse_response("call1")?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
@@ -71,6 +88,12 @@ async fn request_user_input_round_trip() -> Result<()> {
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
config: enable_default_mode_feature.then(|| {
std::collections::HashMap::from([(
"features.default_mode_request_user_input".to_string(),
json!(true),
)])
}),
..Default::default()
})
.await?;
@@ -88,7 +111,7 @@ async fn request_user_input_round_trip() -> Result<()> {
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan,
mode,
settings: Settings {
model: "mock-model".to_string(),
reasoning_effort: Some(ReasoningEffort::Medium),
@@ -113,7 +136,8 @@ async fn request_user_input_round_trip() -> Result<()> {
assert_eq!(params.turn_id, turn.id);
assert_eq!(params.item_id, "call1");
assert_eq!(params.questions.len(), 1);
assert_eq!(params.auto_resolution_ms, Some(60_000));
assert_eq!(params.is_blocking, expected_is_blocking);
assert_eq!(params.auto_resolution_ms, None);
let resolved_request_id = request_id.clone();
mcp.send_response(
+1
View File
@@ -726,6 +726,7 @@ async fn handle_request_user_input(
let args = RequestUserInputArgs {
questions: event.questions,
is_blocking: event.is_blocking,
auto_resolution_ms: event.auto_resolution_ms,
};
let response_fut =
+96
View File
@@ -328,6 +328,100 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() {
);
}
#[tokio::test]
async fn handle_request_user_input_preserves_non_blocking_flag_for_round_trip() {
let (parent_session, parent_ctx, rx_events) =
crate::session::tests::make_session_and_context_with_rx().await;
*parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY);
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let io = Arc::new(SessionIo {
tx_sub,
rx_event: rx_events_child,
agent_status,
session_loop_termination: completed_session_loop_termination(),
});
let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::new()));
let cancel_token = CancellationToken::new();
let child_event_id = "child-request-1".to_string();
let parent_user_input_id = parent_ctx.sub_id.clone();
let expected_response = RequestUserInputResponse {
answers: HashMap::from([(
"q1".to_string(),
RequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
)]),
};
let handle = tokio::spawn({
let io = Arc::clone(&io);
let parent_session = Arc::clone(&parent_session);
let parent_ctx = Arc::clone(&parent_ctx);
let pending_mcp_invocations = Arc::clone(&pending_mcp_invocations);
let cancel_token = cancel_token.clone();
let child_event_id = child_event_id.clone();
async move {
handle_request_user_input(
io.as_ref(),
child_event_id,
&parent_session,
&parent_ctx,
&pending_mcp_invocations,
RequestUserInputEvent {
call_id: "child-call-1".to_string(),
turn_id: "child-turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
id: "q1".to_string(),
header: "Confirm".to_string(),
question: "Continue?".to_string(),
is_other: false,
is_secret: false,
options: None,
}],
is_blocking: false,
auto_resolution_ms: None,
},
&cancel_token,
)
.await;
}
});
let request_event = timeout(Duration::from_secs(1), rx_events.recv())
.await
.expect("request_user_input event timed out")
.expect("request_user_input event missing");
let EventMsg::RequestUserInput(request) = request_event.msg else {
panic!("expected RequestUserInput event");
};
assert_eq!(request.call_id, parent_user_input_id);
assert_eq!(request.is_blocking, false);
parent_session
.notify_user_input_response(&parent_user_input_id, expected_response.clone())
.await;
timeout(Duration::from_secs(1), handle)
.await
.expect("handle_request_user_input hung")
.expect("handle_request_user_input join error");
let submission = timeout(Duration::from_secs(1), rx_sub.recv())
.await
.expect("request_user_input response timed out")
.expect("request_user_input response missing");
assert_eq!(
submission.op,
Op::UserInputAnswer {
id: child_event_id,
response: expected_response,
}
);
}
#[tokio::test]
async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_for_reply() {
let (parent_session, parent_ctx, rx_events) =
@@ -494,6 +588,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
is_secret: false,
options: None,
}],
is_blocking: true,
auto_resolution_ms: None,
},
&cancel_token,
@@ -541,6 +636,7 @@ async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
is_secret: false,
options: None,
}],
is_blocking: true,
auto_resolution_ms: None,
};
let response = maybe_auto_review_mcp_request_user_input(
@@ -266,6 +266,7 @@ async fn should_install_mcp_dependencies(
};
let args = RequestUserInputArgs {
questions: vec![question],
is_blocking: true,
auto_resolution_ms: None,
};
let sub_id = &turn_context.sub_id;
+1
View File
@@ -1431,6 +1431,7 @@ async fn maybe_request_mcp_tool_approval(
let args = RequestUserInputArgs {
questions: vec![question],
is_blocking: true,
auto_resolution_ms: None,
};
let response = sess
@@ -172,6 +172,7 @@ async fn request_user_input_holds_an_elicitation_until_response() {
"call-1".to_string(),
RequestUserInputArgs {
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
)
+1
View File
@@ -2663,6 +2663,7 @@ impl Session {
call_id,
turn_id: turn_context.sub_id.clone(),
questions: args.questions,
is_blocking: args.is_blocking,
auto_resolution_ms: args.auto_resolution_ms,
});
turn_context
@@ -5,8 +5,9 @@ use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::RequestUserInputToolArgs;
use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool;
use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_args;
use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_tool_args;
use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description;
use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message;
use crate::tools::registry::CoreToolRuntime;
@@ -62,14 +63,19 @@ impl RequestUserInputHandler {
));
}
let mode = session.collaboration_mode().await.mode;
let mode = turn.collaboration_mode().mode;
if let Some(message) = request_user_input_unavailable_message(mode, &self.available_modes) {
return Err(FunctionCallError::RespondToModel(message));
}
let args: RequestUserInputArgs = parse_arguments(&arguments)?;
let args =
normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?;
let args: RequestUserInputToolArgs = parse_arguments(&arguments)?;
let args = normalize_request_user_input_tool_args(args)
.map_err(FunctionCallError::RespondToModel)?;
let args = RequestUserInputArgs {
questions: args.questions,
is_blocking: mode == ModeKind::Plan,
auto_resolution_ms: None,
};
let response = session
.request_user_input(turn.as_ref(), call_id, args)
.await
@@ -1,13 +1,17 @@
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde::Deserialize;
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;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub(crate) struct RequestUserInputToolArgs {
pub questions: Vec<RequestUserInputQuestion>,
}
pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([
@@ -68,14 +72,7 @@ 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 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),
]);
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
ToolSpec::Function(ResponsesApiTool {
name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
@@ -105,9 +102,9 @@ pub fn request_user_input_unavailable_message(
}
}
pub fn normalize_request_user_input_args(
mut args: RequestUserInputArgs,
) -> Result<RequestUserInputArgs, String> {
pub(crate) fn normalize_request_user_input_tool_args(
mut args: RequestUserInputToolArgs,
) -> Result<RequestUserInputToolArgs, String> {
let missing_options = args
.questions
.iter()
@@ -120,26 +117,13 @@ 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. 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}."
"Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}."
)
}
@@ -29,13 +29,6 @@ fn request_user_input_tool_includes_questions_schema() {
strict: false,
defer_loading: None,
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(
@@ -107,15 +100,17 @@ fn request_user_input_tool_includes_questions_schema() {
),
),
),
]), 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 {
fn normalize_request_user_input_tool_args_sets_other_on_every_question() {
let args = RequestUserInputToolArgs {
questions: vec![RequestUserInputQuestion {
id: "confirm".to_string(),
header: "Confirm".to_string(),
@@ -127,73 +122,35 @@ fn normalize_request_user_input_args_clamps_out_of_range_auto_resolution_ms() {
description: "Continue.".to_string(),
}]),
}],
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS - 1),
};
assert_eq!(
normalize_request_user_input_args(args.clone()),
Ok(RequestUserInputArgs {
normalize_request_user_input_tool_args(args.clone()),
Ok(RequestUserInputToolArgs {
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 {
fn normalize_request_user_input_tool_args_rejects_missing_options() {
let args = RequestUserInputToolArgs {
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(),
}]),
options: None,
}],
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),
})
normalize_request_user_input_tool_args(args),
Err("request_user_input requires non-empty options for every question".to_string())
);
}
@@ -231,10 +188,14 @@ 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. 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()
"Request user input for one to three short questions and wait for the response. 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. 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()
"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()
);
assert_eq!(
request_user_input_tool_description(&[ModeKind::Default]),
"Request user input for one to three short questions and wait for the response. This tool is only available in Default mode.".to_string()
);
}
@@ -1,14 +1,19 @@
use super::*;
use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::ActiveTurn;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::request_user_input::RequestUserInputResponse;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
@@ -69,3 +74,142 @@ async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
)
);
}
#[tokio::test]
async fn request_user_input_sets_non_blocking_outside_plan_mode() {
let (session, turn, events) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let request = tokio::spawn({
let session = Arc::clone(&session);
let turn = Arc::clone(&turn);
async move {
RequestUserInputHandler {
available_modes: vec![ModeKind::Default],
}
.handle(ToolInvocation {
session,
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: codex_tools::ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: json!({
"questions": [{
"header": "Hdr",
"question": "Pick one",
"id": "pick_one",
"options": [
{
"label": "A",
"description": "A"
},
{
"label": "B",
"description": "B"
}
]
}]
})
.to_string(),
},
})
.await
}
});
let event = events.recv().await.expect("request_user_input event");
let EventMsg::RequestUserInput(request_event) = event.msg else {
panic!("expected request_user_input event");
};
assert_eq!(request_event.call_id, "call-1");
assert!(!request_event.is_blocking);
session
.notify_user_input_response(
&request_event.turn_id,
RequestUserInputResponse {
answers: HashMap::new(),
},
)
.await;
request
.await
.expect("request_user_input handler task should finish")
.expect("request_user_input handler should succeed");
}
#[tokio::test]
async fn request_user_input_sets_blocking_from_turn_mode() {
let (session, mut turn, events) = make_session_and_context_with_rx().await;
Arc::get_mut(&mut turn)
.expect("turn context should be uniquely owned")
.mode = ModeKind::Plan;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let request = tokio::spawn({
let session = Arc::clone(&session);
let turn = Arc::clone(&turn);
async move {
RequestUserInputHandler {
available_modes: vec![ModeKind::Plan],
}
.handle(ToolInvocation {
session,
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: codex_tools::ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: json!({
"questions": [{
"header": "Hdr",
"question": "Pick one",
"id": "pick_one",
"options": [
{
"label": "A",
"description": "A"
},
{
"label": "B",
"description": "B"
}
]
}]
})
.to_string(),
},
})
.await
}
});
let event = events.recv().await.expect("request_user_input event");
let EventMsg::RequestUserInput(request_event) = event.msg else {
panic!("expected request_user_input event");
};
assert_eq!(request_event.call_id, "call-1");
assert!(request_event.is_blocking);
session
.notify_user_input_response(
&request_event.turn_id,
RequestUserInputResponse {
answers: HashMap::new(),
},
)
.await;
request
.await
.expect("request_user_input handler task should finish")
.expect("request_user_input handler should succeed");
}
@@ -68,18 +68,10 @@ fn call_output_content_and_success(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> {
request_user_input_round_trip_for_mode(ModeKind::Plan, /*auto_resolution_ms*/ None).await
request_user_input_round_trip_for_mode(ModeKind::Plan).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_emits_auto_resolution_ms() -> anyhow::Result<()> {
request_user_input_round_trip_for_mode(ModeKind::Plan, Some(60_000)).await
}
async fn request_user_input_round_trip_for_mode(
mode: ModeKind,
auto_resolution_ms: Option<u64>,
) -> anyhow::Result<()> {
async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -103,7 +95,8 @@ async fn request_user_input_round_trip_for_mode(
.await?;
let call_id = "user-input-call";
let mut request_args = json!({
let expected_is_blocking = mode == ModeKind::Plan;
let request_args = json!({
"questions": [{
"id": "confirm_path",
"header": "Confirm",
@@ -117,12 +110,6 @@ async fn request_user_input_round_trip_for_mode(
}]
}]
});
if let Some(auto_resolution_ms) = auto_resolution_ms {
let request_args = request_args
.as_object_mut()
.expect("request_user_input args should be a JSON object");
request_args.insert("autoResolutionMs".to_string(), json!(auto_resolution_ms));
}
let request_args = request_args.to_string();
let first_response = sse(vec![
@@ -176,7 +163,8 @@ async fn request_user_input_round_trip_for_mode(
.await;
assert_eq!(request.call_id, call_id);
assert_eq!(request.questions.len(), 1);
assert_eq!(request.auto_resolution_ms, auto_resolution_ms);
assert_eq!(request.is_blocking, expected_is_blocking);
assert_eq!(request.auto_resolution_ms, None);
assert_eq!(request.questions[0].is_other, true);
assert!(
timeout(Duration::from_millis(200), async {
@@ -451,7 +439,7 @@ async fn request_user_input_rejected_in_default_mode_by_default() -> anyhow::Res
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_in_default_mode_with_feature() -> anyhow::Result<()> {
request_user_input_round_trip_for_mode(ModeKind::Default, /*auto_resolution_ms*/ None).await
request_user_input_round_trip_for_mode(ModeKind::Default).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+43 -1
View File
@@ -31,6 +31,11 @@ pub struct RequestUserInputQuestion {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct RequestUserInputArgs {
pub questions: Vec<RequestUserInputQuestion>,
#[serde(rename = "isBlocking")]
#[schemars(rename = "isBlocking")]
#[ts(rename = "isBlocking")]
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(rename = "autoResolutionMs", skip_serializing_if = "Option::is_none")]
#[schemars(rename = "autoResolutionMs")]
pub auto_resolution_ms: Option<u64>,
@@ -46,7 +51,7 @@ pub struct RequestUserInputResponse {
pub answers: HashMap<String, RequestUserInputAnswer>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct RequestUserInputEvent {
/// Responses API call id for the associated tool call, if available.
pub call_id: String,
@@ -55,7 +60,44 @@ pub struct RequestUserInputEvent {
#[serde(default)]
pub turn_id: String,
pub questions: Vec<RequestUserInputQuestion>,
#[serde(rename = "isBlocking")]
#[schemars(rename = "isBlocking")]
#[ts(rename = "isBlocking")]
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(rename = "autoResolutionMs", skip_serializing_if = "Option::is_none")]
#[schemars(rename = "autoResolutionMs")]
pub auto_resolution_ms: Option<u64>,
}
impl<'de> Deserialize<'de> for RequestUserInputEvent {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct WireRequestUserInputEvent {
call_id: String,
#[serde(default)]
turn_id: String,
questions: Vec<RequestUserInputQuestion>,
#[serde(rename = "isBlocking")]
is_blocking: Option<bool>,
#[serde(rename = "autoResolutionMs")]
auto_resolution_ms: Option<u64>,
}
let wire = WireRequestUserInputEvent::deserialize(deserializer)?;
Ok(Self {
call_id: wire.call_id,
turn_id: wire.turn_id,
questions: wire.questions,
is_blocking: wire.is_blocking.unwrap_or(true),
auto_resolution_ms: wire.auto_resolution_ms,
})
}
}
#[cfg(test)]
#[path = "request_user_input_tests.rs"]
mod tests;
@@ -0,0 +1,44 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn request_user_input_event_defaults_legacy_missing_is_blocking_to_true() {
let event: RequestUserInputEvent = serde_json::from_value(json!({
"call_id": "call-1",
"turn_id": "turn-1",
"questions": [{
"id": "q1",
"header": "Confirm",
"question": "Continue?",
"options": [{
"label": "Yes",
"description": "Continue."
}]
}],
"autoResolutionMs": 60_000
}))
.expect("legacy request_user_input event should deserialize");
assert_eq!(
event,
RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
id: "q1".to_string(),
header: "Confirm".to_string(),
question: "Continue?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
label: "Yes".to_string(),
description: "Continue.".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: Some(60_000),
}
);
}
@@ -579,6 +579,7 @@ mod tests {
turn_id: "turn-2".to_string(),
item_id: "tool-1".to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}),
@@ -870,6 +871,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: "tool-1".to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
});
@@ -893,6 +895,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: item_id.to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
});
@@ -595,6 +595,7 @@ mod tests {
turn_id: turn_id.to_string(),
item_id: call_id.to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}
+1
View File
@@ -5546,6 +5546,7 @@ fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str)
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}
@@ -273,15 +273,15 @@ impl RequestUserInputOverlay {
}
fn snooze_auto_resolution(&mut self) {
if self.request.auto_resolution_ms.is_some() {
if !self.request.is_blocking {
self.auto_resolution_snoozed = true;
}
}
fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming {
// The TUI currently treats autoResolutionMs as an enable signal. The
// model-provided duration value is reserved for future runtime policy.
if self.request.auto_resolution_ms.is_none() || self.auto_resolution_snoozed {
// autoResolutionMs is deprecated; isBlocking now controls whether the
// request can auto-resolve using the TUI's fixed grace/countdown policy.
if self.request.is_blocking || self.auto_resolution_snoozed {
return AutoResolutionTiming::Disabled;
}
@@ -1694,6 +1694,7 @@ mod tests {
item_id: "call-1".to_string(),
turn_id: turn_id.to_string(),
questions,
is_blocking: true,
auto_resolution_ms: None,
}
}
@@ -1703,6 +1704,7 @@ mod tests {
questions: Vec<ToolRequestUserInputQuestion>,
) -> ToolRequestUserInputParams {
let mut request = request_event(turn_id, questions);
request.is_blocking = false;
request.auto_resolution_ms = Some(60_000);
request
}
@@ -1770,6 +1772,7 @@ mod tests {
item_id: "call-2".to_string(),
turn_id: "turn-2".to_string(),
questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None,
});
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
@@ -1777,6 +1780,7 @@ mod tests {
item_id: "call-3".to_string(),
turn_id: "turn-3".to_string(),
questions: vec![question_with_options("q3", "Third")],
is_blocking: true,
auto_resolution_ms: None,
});
@@ -1806,6 +1810,39 @@ mod tests {
assert_eq!(overlay.auto_resolution_countdown_text_at(now), None);
}
#[test]
fn auto_resolution_uses_is_blocking_without_auto_resolution_ms() {
let (tx, mut rx) = test_sender();
let mut request = request_event("turn-1", vec![question_with_options("q1", "First")]);
request.is_blocking = false;
assert_eq!(request.auto_resolution_ms, None);
let mut overlay = RequestUserInputOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let now = Instant::now();
overlay.request_started_at = now;
assert_eq!(
overlay.auto_resolution_timing_at(now),
AutoResolutionTiming::HiddenGrace {
remaining: AUTO_RESOLUTION_HIDDEN_GRACE
}
);
let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN;
let due = now + total_timeout;
assert!(overlay.pre_draw_tick(due));
assert!(overlay.done);
let event = rx.try_recv().expect("expected UserInputAnswer event");
let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else {
panic!("expected UserInputAnswer event");
};
assert_eq!(id, "turn-1");
assert_eq!(response.answers, HashMap::new());
}
#[test]
fn auto_resolution_hides_timer_during_grace_period() {
let (tx, _rx) = test_sender();
@@ -2035,6 +2072,7 @@ mod tests {
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None,
},
tx,
@@ -2064,6 +2102,7 @@ mod tests {
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None,
},
tx,
@@ -2076,6 +2115,7 @@ mod tests {
item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None,
});
@@ -2104,6 +2144,7 @@ mod tests {
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None,
},
tx,
@@ -2116,6 +2157,7 @@ mod tests {
item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None,
});
overlay.try_consume_user_input_request(ToolRequestUserInputParams {
@@ -2123,6 +2165,7 @@ mod tests {
item_id: "call-3".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q3", "Third")],
is_blocking: true,
auto_resolution_ms: None,
});
@@ -152,6 +152,7 @@ mod tests {
item_id: call_id.to_string(),
turn_id: turn_id.to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
}
}
@@ -649,6 +649,7 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n
description: "Update only Plan mode.".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: None,
});
@@ -679,6 +680,7 @@ async fn handle_request_user_input_sets_pending_notification() {
description: "Update only Plan mode.".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: None,
});
@@ -2051,6 +2051,7 @@ async fn request_user_input_interrupt_pauses_active_goal_turn() {
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
});