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 authored and copyberry committed 2026-08-01 00:38:10 +00:00
1 parent a850875a8e
commit e2c0837923
36 files changed
+578 -133

No files matched your search

+2
View File
@@ -1896,6 +1896,7 @@ mod tests {
is_secret: false, is_secret: false,
options: Some(vec![]), options: Some(vec![]),
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}) })
.expect("params should serialize"), .expect("params should serialize"),
@@ -1958,6 +1959,7 @@ mod tests {
is_secret: false, is_secret: false,
options: Some(vec![]), options: Some(vec![]),
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}) })
.expect("params should serialize"), .expect("params should serialize"),
@@ -1732,6 +1732,7 @@
"properties": { "properties": {
"autoResolutionMs": { "autoResolutionMs": {
"default": null, "default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64", "format": "uint64",
"minimum": 0.0, "minimum": 0.0,
"type": [ "type": [
@@ -1739,6 +1740,9 @@
"null" "null"
] ]
}, },
"isBlocking": {
"type": "boolean"
},
"itemId": { "itemId": {
"type": "string" "type": "string"
}, },
@@ -1756,6 +1760,7 @@
} }
}, },
"required": [ "required": [
"isBlocking",
"itemId", "itemId",
"questions", "questions",
"threadId", "threadId",
@@ -59,6 +59,7 @@
"properties": { "properties": {
"autoResolutionMs": { "autoResolutionMs": {
"default": null, "default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64", "format": "uint64",
"minimum": 0.0, "minimum": 0.0,
"type": [ "type": [
@@ -66,6 +67,9 @@
"null" "null"
] ]
}, },
"isBlocking": {
"type": "boolean"
},
"itemId": { "itemId": {
"type": "string" "type": "string"
}, },
@@ -83,6 +87,7 @@
} }
}, },
"required": [ "required": [
"isBlocking",
"itemId", "itemId",
"questions", "questions",
"threadId", "threadId",
@@ -6025,6 +6025,7 @@
"properties": { "properties": {
"autoResolutionMs": { "autoResolutionMs": {
"default": null, "default": null,
"description": "@deprecated Use `isBlocking` to decide whether the request should block.",
"format": "uint64", "format": "uint64",
"minimum": 0.0, "minimum": 0.0,
"type": [ "type": [
@@ -6032,6 +6033,9 @@
"null" "null"
] ]
}, },
"isBlocking": {
"type": "boolean"
},
"itemId": { "itemId": {
"type": "string" "type": "string"
}, },
@@ -6049,6 +6053,7 @@
} }
}, },
"required": [ "required": [
"isBlocking",
"itemId", "itemId",
"questions", "questions",
"threadId", "threadId",
@@ -6,4 +6,8 @@ import type { ToolRequestUserInputQuestion } from "./ToolRequestUserInputQuestio
/** /**
* EXPERIMENTAL. Params sent with a request_user_input event. * 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>>, pub options: Option<Vec<ToolRequestUserInputOption>>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
/// EXPERIMENTAL. Params sent with a request_user_input event. /// EXPERIMENTAL. Params sent with a request_user_input event.
@@ -1633,11 +1633,41 @@ pub struct ToolRequestUserInputParams {
pub turn_id: String, pub turn_id: String,
pub item_id: String, pub item_id: String,
pub questions: Vec<ToolRequestUserInputQuestion>, pub questions: Vec<ToolRequestUserInputQuestion>,
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(default)] #[serde(default)]
#[ts(type = "number | null")] #[ts(type = "number | null")]
pub auto_resolution_ms: Option<u64>, 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)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
@@ -4731,6 +4731,7 @@ fn realtime_start_omitted_initial_items_remain_none() {
assert_eq!(params.initial_items, None); assert_eq!(params.initial_items, None);
} }
#[test] #[test]
fn realtime_start_deserializes_client_handoff_channel_prefixes() { fn realtime_start_deserializes_client_handoff_channel_prefixes() {
let params = serde_json::from_value::<ThreadRealtimeStartParams>(json!({ 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 {}]", "\n[request_user_input for thread {}, turn {}]",
params.thread_id, params.turn_id params.thread_id, params.turn_id
)?; )?;
if let Some(auto_resolution_ms) = params.auto_resolution_ms { if !params.is_blocking {
writeln!( writeln!(output, "This request is non-blocking.")?;
output,
"The app-server may auto-resolve this request after {auto_resolution_ms} ms."
)?;
} }
let mut answers = HashMap::new(); let mut answers = HashMap::new();
@@ -43,6 +43,7 @@ fn collects_option_and_free_form_answers() {
options: None, options: None,
}, },
], ],
is_blocking: false,
auto_resolution_ms: Some(60_000), auto_resolution_ms: Some(60_000),
}; };
let mut input = Cursor::new(b"2\ninclude snapshots\n"); 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(), String::from_utf8(output).unwrap(),
concat!( concat!(
"\n[request_user_input for thread thread-1, turn turn-1]\n", "\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", "\nTarget: Which target?\n",
" 1. Core - Inspect core\n", " 1. Core - Inspect core\n",
" 2. TUI - Inspect TUI\n", " 2. TUI - Inspect TUI\n",
@@ -102,6 +103,7 @@ fn retries_invalid_selection_and_collects_other_answer() {
description: "Inspect core".to_string(), description: "Inspect core".to_string(),
}]), }]),
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}; };
let mut input = Cursor::new(b"9\no\nSDK wrapper\n"); 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 ### 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. 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 ### Attestation generation
@@ -741,6 +741,7 @@ pub(crate) async fn apply_bespoke_event_handling(
turn_id: request.turn_id, turn_id: request.turn_id,
item_id: request.call_id, item_id: request.call_id,
questions, questions,
is_blocking: request.is_blocking,
auto_resolution_ms: request.auto_resolution_ms, auto_resolution_ms: request.auto_resolution_ms,
}; };
let (pending_request_id, rx) = outgoing let (pending_request_id, rx) = outgoing
@@ -1334,6 +1334,7 @@ mod tests {
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
questions: vec![], questions: vec![],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
)) ))
@@ -1397,6 +1398,7 @@ mod tests {
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
questions: vec![], questions: vec![],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
)) ))
@@ -1170,6 +1170,7 @@ mod thread_processor_behavior_tests {
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
questions: vec![], questions: vec![],
is_blocking: true,
auto_resolution_ms: None, 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); const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_request_user_input_sse_response_with_auto_resolution( fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<String> {
call_id: &str,
auto_resolution_ms: u64,
) -> anyhow::Result<String> {
let tool_call_arguments = serde_json::to_string(&json!({ let tool_call_arguments = serde_json::to_string(&json!({
"questions": [{ "questions": [{
"id": "confirm_path", "id": "confirm_path",
@@ -38,8 +35,7 @@ fn create_request_user_input_sse_response_with_auto_resolution(
"label": "No", "label": "No",
"description": "Stop and revisit the approach." "description": "Stop and revisit the approach."
}] }]
}], }]
"autoResolutionMs": auto_resolution_ms
}))?; }))?;
Ok(responses::sse(vec![ 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn request_user_input_round_trip() -> Result<()> { 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 codex_home = tempfile::TempDir::new()?;
let responses = vec![ let responses = vec![
create_request_user_input_sse_response_with_auto_resolution( create_request_user_input_sse_response("call1")?,
"call1", /*auto_resolution_ms*/ 60_000,
)?,
create_final_assistant_message_sse_response("done")?, create_final_assistant_message_sse_response("done")?,
]; ];
let server = create_mock_responses_server_sequence(responses).await; 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 let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams { .start_thread(ThreadStartParams {
model: Some("mock-model".to_string()), 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() ..Default::default()
}) })
.await?; .await?;
@@ -88,7 +111,7 @@ async fn request_user_input_round_trip() -> Result<()> {
model: Some("mock-model".to_string()), model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium), effort: Some(ReasoningEffort::Medium),
collaboration_mode: Some(CollaborationMode { collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Plan, mode,
settings: Settings { settings: Settings {
model: "mock-model".to_string(), model: "mock-model".to_string(),
reasoning_effort: Some(ReasoningEffort::Medium), 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.turn_id, turn.id);
assert_eq!(params.item_id, "call1"); assert_eq!(params.item_id, "call1");
assert_eq!(params.questions.len(), 1); 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(); let resolved_request_id = request_id.clone();
mcp.send_response( mcp.send_response(
+1
View File
@@ -726,6 +726,7 @@ async fn handle_request_user_input(
let args = RequestUserInputArgs { let args = RequestUserInputArgs {
questions: event.questions, questions: event.questions,
is_blocking: event.is_blocking,
auto_resolution_ms: event.auto_resolution_ms, auto_resolution_ms: event.auto_resolution_ms,
}; };
let response_fut = 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] #[tokio::test]
async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_for_reply() { async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_for_reply() {
let (parent_session, parent_ctx, rx_events) = let (parent_session, parent_ctx, rx_events) =
@@ -494,6 +588,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() {
is_secret: false, is_secret: false,
options: None, options: None,
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
&cancel_token, &cancel_token,
@@ -541,6 +636,7 @@ async fn delegated_mcp_user_reviewer_returns_none_without_metadata() {
is_secret: false, is_secret: false,
options: None, options: None,
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}; };
let response = maybe_auto_review_mcp_request_user_input( let response = maybe_auto_review_mcp_request_user_input(
@@ -266,6 +266,7 @@ async fn should_install_mcp_dependencies(
}; };
let args = RequestUserInputArgs { let args = RequestUserInputArgs {
questions: vec![question], questions: vec![question],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}; };
let sub_id = &turn_context.sub_id; let sub_id = &turn_context.sub_id;
+1
View File
@@ -1431,6 +1431,7 @@ async fn maybe_request_mcp_tool_approval(
let args = RequestUserInputArgs { let args = RequestUserInputArgs {
questions: vec![question], questions: vec![question],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}; };
let response = sess let response = sess
@@ -172,6 +172,7 @@ async fn request_user_input_holds_an_elicitation_until_response() {
"call-1".to_string(), "call-1".to_string(),
RequestUserInputArgs { RequestUserInputArgs {
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
) )
+1
View File
@@ -2663,6 +2663,7 @@ impl Session {
call_id, call_id,
turn_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone(),
questions: args.questions, questions: args.questions,
is_blocking: args.is_blocking,
auto_resolution_ms: args.auto_resolution_ms, auto_resolution_ms: args.auto_resolution_ms,
}); });
turn_context turn_context
@@ -5,8 +5,9 @@ use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output; use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments; 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::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::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_tool_description;
use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message; use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message;
use crate::tools::registry::CoreToolRuntime; 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) { if let Some(message) = request_user_input_unavailable_message(mode, &self.available_modes) {
return Err(FunctionCallError::RespondToModel(message)); return Err(FunctionCallError::RespondToModel(message));
} }
let args: RequestUserInputArgs = parse_arguments(&arguments)?; let args: RequestUserInputToolArgs = parse_arguments(&arguments)?;
let args = let args = normalize_request_user_input_tool_args(args)
normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?; .map_err(FunctionCallError::RespondToModel)?;
let args = RequestUserInputArgs {
questions: args.questions,
is_blocking: mode == ModeKind::Plan,
auto_resolution_ms: None,
};
let response = session let response = session
.request_user_input(turn.as_ref(), call_id, args) .request_user_input(turn.as_ref(), call_id, args)
.await .await
@@ -1,13 +1,17 @@
use codex_protocol::config_types::ModeKind; 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::JsonSchema;
use codex_tools::ResponsesApiTool; use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec; use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; 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 { pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([ 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()), Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
); );
let auto_resolution_ms_schema = JsonSchema::number(Some(format!( let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
"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 { ToolSpec::Function(ResponsesApiTool {
name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), 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( pub(crate) fn normalize_request_user_input_tool_args(
mut args: RequestUserInputArgs, mut args: RequestUserInputToolArgs,
) -> Result<RequestUserInputArgs, String> { ) -> Result<RequestUserInputToolArgs, String> {
let missing_options = args let missing_options = args
.questions .questions
.iter() .iter()
@@ -120,26 +117,13 @@ pub fn normalize_request_user_input_args(
question.is_other = true; 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) Ok(args)
} }
pub fn request_user_input_tool_description(available_modes: &[ModeKind]) -> String { pub fn request_user_input_tool_description(available_modes: &[ModeKind]) -> String {
let allowed_modes = format_allowed_modes(available_modes); let allowed_modes = format_allowed_modes(available_modes);
format!( 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, strict: false,
defer_loading: None, 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(), "questions".to_string(),
JsonSchema::array( 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, output_schema: None,
}) })
); );
} }
#[test] #[test]
fn normalize_request_user_input_args_clamps_out_of_range_auto_resolution_ms() { fn normalize_request_user_input_tool_args_sets_other_on_every_question() {
let args = RequestUserInputArgs { let args = RequestUserInputToolArgs {
questions: vec![RequestUserInputQuestion { questions: vec![RequestUserInputQuestion {
id: "confirm".to_string(), id: "confirm".to_string(),
header: "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(), description: "Continue.".to_string(),
}]), }]),
}], }],
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS - 1),
}; };
assert_eq!( assert_eq!(
normalize_request_user_input_args(args.clone()), normalize_request_user_input_tool_args(args.clone()),
Ok(RequestUserInputArgs { Ok(RequestUserInputToolArgs {
questions: vec![RequestUserInputQuestion { questions: vec![RequestUserInputQuestion {
is_other: true, is_other: true,
..args.questions[0].clone() ..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] #[test]
fn normalize_request_user_input_args_accepts_auto_resolution_boundaries() { fn normalize_request_user_input_tool_args_rejects_missing_options() {
let args = RequestUserInputArgs { let args = RequestUserInputToolArgs {
questions: vec![RequestUserInputQuestion { questions: vec![RequestUserInputQuestion {
id: "confirm".to_string(), id: "confirm".to_string(),
header: "Confirm".to_string(), header: "Confirm".to_string(),
question: "Proceed?".to_string(), question: "Proceed?".to_string(),
is_other: false, is_other: false,
is_secret: false, is_secret: false,
options: Some(vec![RequestUserInputQuestionOption { options: None,
label: "Yes (Recommended)".to_string(),
description: "Continue.".to_string(),
}]),
}], }],
auto_resolution_ms: Some(MIN_AUTO_RESOLUTION_MS),
}; };
assert_eq!( assert_eq!(
normalize_request_user_input_args(args.clone()), normalize_request_user_input_tool_args(args),
Ok(RequestUserInputArgs { Err("request_user_input requires non-empty options for every question".to_string())
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),
})
); );
} }
@@ -231,10 +188,14 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
fn request_user_input_tool_description_mentions_available_modes() { fn request_user_input_tool_description_mentions_available_modes() {
assert_eq!( assert_eq!(
request_user_input_tool_description(&default_available_modes()), 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!( assert_eq!(
request_user_input_tool_description(&default_mode_enabled_available_modes()), 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 super::*;
use crate::session::step_context::StepContext; use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context; 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::ToolInvocation;
use crate::tools::context::ToolPayload; use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker; use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::SubAgentSource;
use codex_protocol::request_user_input::RequestUserInputResponse;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { 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_for_mode(mode: ModeKind) -> anyhow::Result<()> {
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<()> {
skip_if_no_network!(Ok(())); skip_if_no_network!(Ok(()));
let server = start_mock_server().await; let server = start_mock_server().await;
@@ -103,7 +95,8 @@ async fn request_user_input_round_trip_for_mode(
.await?; .await?;
let call_id = "user-input-call"; let call_id = "user-input-call";
let mut request_args = json!({ let expected_is_blocking = mode == ModeKind::Plan;
let request_args = json!({
"questions": [{ "questions": [{
"id": "confirm_path", "id": "confirm_path",
"header": "Confirm", "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 request_args = request_args.to_string();
let first_response = sse(vec![ let first_response = sse(vec![
@@ -176,7 +163,8 @@ async fn request_user_input_round_trip_for_mode(
.await; .await;
assert_eq!(request.call_id, call_id); assert_eq!(request.call_id, call_id);
assert_eq!(request.questions.len(), 1); 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_eq!(request.questions[0].is_other, true);
assert!( assert!(
timeout(Duration::from_millis(200), async { 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_in_default_mode_with_feature() -> anyhow::Result<()> { 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)] #[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)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct RequestUserInputArgs { pub struct RequestUserInputArgs {
pub questions: Vec<RequestUserInputQuestion>, 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")] #[serde(rename = "autoResolutionMs", skip_serializing_if = "Option::is_none")]
#[schemars(rename = "autoResolutionMs")] #[schemars(rename = "autoResolutionMs")]
pub auto_resolution_ms: Option<u64>, pub auto_resolution_ms: Option<u64>,
@@ -46,7 +51,7 @@ pub struct RequestUserInputResponse {
pub answers: HashMap<String, RequestUserInputAnswer>, 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 { pub struct RequestUserInputEvent {
/// Responses API call id for the associated tool call, if available. /// Responses API call id for the associated tool call, if available.
pub call_id: String, pub call_id: String,
@@ -55,7 +60,44 @@ pub struct RequestUserInputEvent {
#[serde(default)] #[serde(default)]
pub turn_id: String, pub turn_id: String,
pub questions: Vec<RequestUserInputQuestion>, 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")] #[serde(rename = "autoResolutionMs", skip_serializing_if = "Option::is_none")]
#[schemars(rename = "autoResolutionMs")] #[schemars(rename = "autoResolutionMs")]
pub auto_resolution_ms: Option<u64>, 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(), turn_id: "turn-2".to_string(),
item_id: "tool-1".to_string(), item_id: "tool-1".to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
}), }),
@@ -870,6 +871,7 @@ mod tests {
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
item_id: "tool-1".to_string(), item_id: "tool-1".to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
}); });
@@ -893,6 +895,7 @@ mod tests {
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
item_id: item_id.to_string(), item_id: item_id.to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
}); });
@@ -595,6 +595,7 @@ mod tests {
turn_id: turn_id.to_string(), turn_id: turn_id.to_string(),
item_id: call_id.to_string(), item_id: call_id.to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, 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(), turn_id: turn_id.to_string(),
item_id: item_id.to_string(), item_id: item_id.to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
} }
@@ -273,15 +273,15 @@ impl RequestUserInputOverlay {
} }
fn snooze_auto_resolution(&mut self) { fn snooze_auto_resolution(&mut self) {
if self.request.auto_resolution_ms.is_some() { if !self.request.is_blocking {
self.auto_resolution_snoozed = true; self.auto_resolution_snoozed = true;
} }
} }
fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming { fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming {
// The TUI currently treats autoResolutionMs as an enable signal. The // autoResolutionMs is deprecated; isBlocking now controls whether the
// model-provided duration value is reserved for future runtime policy. // request can auto-resolve using the TUI's fixed grace/countdown policy.
if self.request.auto_resolution_ms.is_none() || self.auto_resolution_snoozed { if self.request.is_blocking || self.auto_resolution_snoozed {
return AutoResolutionTiming::Disabled; return AutoResolutionTiming::Disabled;
} }
@@ -1694,6 +1694,7 @@ mod tests {
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
turn_id: turn_id.to_string(), turn_id: turn_id.to_string(),
questions, questions,
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
} }
} }
@@ -1703,6 +1704,7 @@ mod tests {
questions: Vec<ToolRequestUserInputQuestion>, questions: Vec<ToolRequestUserInputQuestion>,
) -> ToolRequestUserInputParams { ) -> ToolRequestUserInputParams {
let mut request = request_event(turn_id, questions); let mut request = request_event(turn_id, questions);
request.is_blocking = false;
request.auto_resolution_ms = Some(60_000); request.auto_resolution_ms = Some(60_000);
request request
} }
@@ -1770,6 +1772,7 @@ mod tests {
item_id: "call-2".to_string(), item_id: "call-2".to_string(),
turn_id: "turn-2".to_string(), turn_id: "turn-2".to_string(),
questions: vec![question_with_options("q2", "Second")], questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
overlay.try_consume_user_input_request(ToolRequestUserInputParams { overlay.try_consume_user_input_request(ToolRequestUserInputParams {
@@ -1777,6 +1780,7 @@ mod tests {
item_id: "call-3".to_string(), item_id: "call-3".to_string(),
turn_id: "turn-3".to_string(), turn_id: "turn-3".to_string(),
questions: vec![question_with_options("q3", "Third")], questions: vec![question_with_options("q3", "Third")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
@@ -1806,6 +1810,39 @@ mod tests {
assert_eq!(overlay.auto_resolution_countdown_text_at(now), None); 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] #[test]
fn auto_resolution_hides_timer_during_grace_period() { fn auto_resolution_hides_timer_during_grace_period() {
let (tx, _rx) = test_sender(); let (tx, _rx) = test_sender();
@@ -2035,6 +2072,7 @@ mod tests {
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")], questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
tx, tx,
@@ -2064,6 +2102,7 @@ mod tests {
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")], questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
tx, tx,
@@ -2076,6 +2115,7 @@ mod tests {
item_id: "call-2".to_string(), item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")], questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
@@ -2104,6 +2144,7 @@ mod tests {
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q1", "First")], questions: vec![question_with_options("q1", "First")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}, },
tx, tx,
@@ -2116,6 +2157,7 @@ mod tests {
item_id: "call-2".to_string(), item_id: "call-2".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q2", "Second")], questions: vec![question_with_options("q2", "Second")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
overlay.try_consume_user_input_request(ToolRequestUserInputParams { overlay.try_consume_user_input_request(ToolRequestUserInputParams {
@@ -2123,6 +2165,7 @@ mod tests {
item_id: "call-3".to_string(), item_id: "call-3".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: vec![question_with_options("q3", "Third")], questions: vec![question_with_options("q3", "Third")],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
@@ -152,6 +152,7 @@ mod tests {
item_id: call_id.to_string(), item_id: call_id.to_string(),
turn_id: turn_id.to_string(), turn_id: turn_id.to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, 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(), description: "Update only Plan mode.".to_string(),
}]), }]),
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
@@ -679,6 +680,7 @@ async fn handle_request_user_input_sets_pending_notification() {
description: "Update only Plan mode.".to_string(), description: "Update only Plan mode.".to_string(),
}]), }]),
}], }],
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });
@@ -2051,6 +2051,7 @@ async fn request_user_input_interrupt_pauses_active_goal_turn() {
item_id: "call-1".to_string(), item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(), turn_id: "turn-1".to_string(),
questions: Vec::new(), questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None, auto_resolution_ms: None,
}); });