diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index ae383762f3..df8a8ab1ba 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -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"), diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 3397e2178f..5451a4fd46 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json b/codex-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json index 947371fd10..04ee2de635 100644 --- a/codex-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json +++ b/codex-rs/app-server-protocol/schema/json/ToolRequestUserInputParams.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9a9539e9a2..3d2546ce29 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index f13010e0a4..3b9ab31772 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 5aba2c8c42..d4dcd693c4 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolRequestUserInputParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolRequestUserInputParams.ts index 73ff8b6786..135389ff1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolRequestUserInputParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolRequestUserInputParams.ts @@ -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, autoResolutionMs: number | null, }; +export type ToolRequestUserInputParams = { threadId: string, turnId: string, itemId: string, questions: Array, isBlocking: boolean, +/** + * @deprecated Use `isBlocking` to decide whether the request should block. + */ +autoResolutionMs: number | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index c25209c21b..a98ec3f3b2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1624,7 +1624,7 @@ pub struct ToolRequestUserInputQuestion { pub options: Option>, } -#[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, + 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, } +impl<'de> Deserialize<'de> for ToolRequestUserInputParams { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WireToolRequestUserInputParams { + thread_id: String, + turn_id: String, + item_id: String, + questions: Vec, + is_blocking: Option, + auto_resolution_ms: Option, + } + + 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/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 6027f470f1..6091b76323 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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::(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::(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), + } + ); +} diff --git a/codex-rs/app-server-test-client/src/request_user_input.rs b/codex-rs/app-server-test-client/src/request_user_input.rs index 0302f33600..1bec667cfc 100644 --- a/codex-rs/app-server-test-client/src/request_user_input.rs +++ b/codex-rs/app-server-test-client/src/request_user_input.rs @@ -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(); diff --git a/codex-rs/app-server-test-client/src/request_user_input_tests.rs b/codex-rs/app-server-test-client/src/request_user_input_tests.rs index d8b2a4699d..d7c68b829b 100644 --- a/codex-rs/app-server-test-client/src/request_user_input_tests.rs +++ b/codex-rs/app-server-test-client/src/request_user_input_tests.rs @@ -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"); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 55addd1cab..109852c551 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index e5a24f20ca..e8ee2e5dc6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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 diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index cbd8a7f412..be22cbe314 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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, }, )) diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 9f6dd8d9e2..cf491c8f74 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -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, }, )) diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 5e3b99f44d..8daa098a33 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -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 { +fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result { 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( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 7c9d30dcde..ce6c698579 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -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 = diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 24d2ab7901..1c05a45aff 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -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( diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 6f4e84a5d7..0ff3904599 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -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; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 1d776a18ae..f0c2e2bbb5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -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 diff --git a/codex-rs/core/src/session/elicitation_holders_tests.rs b/codex-rs/core/src/session/elicitation_holders_tests.rs index 83158e96b5..d4061176e0 100644 --- a/codex-rs/core/src/session/elicitation_holders_tests.rs +++ b/codex-rs/core/src/session/elicitation_holders_tests.rs @@ -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, }, ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 170dbce5a9..9660434f4b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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 diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 4f26865a68..92e36fc668 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -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 diff --git a/codex-rs/core/src/tools/handlers/request_user_input_spec.rs b/codex-rs/core/src/tools/handlers/request_user_input_spec.rs index 0eba5eefd6..ede0d85bea 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_spec.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_spec.rs @@ -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, +} 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 { +pub(crate) fn normalize_request_user_input_tool_args( + mut args: RequestUserInputToolArgs, +) -> Result { 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}." ) } diff --git a/codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs index 2b532cd97a..65864eac2c 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs @@ -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() ); } diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index efea95e9cb..ce213e392e 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -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"); +} diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1348a87186..f0bc84f66a 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -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, -) -> 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)] diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index dd52c5e6f6..379d9ba1c1 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -31,6 +31,11 @@ pub struct RequestUserInputQuestion { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestUserInputArgs { pub questions: Vec, + #[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, @@ -46,7 +51,7 @@ pub struct RequestUserInputResponse { pub answers: HashMap, } -#[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, + #[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, } + +impl<'de> Deserialize<'de> for RequestUserInputEvent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireRequestUserInputEvent { + call_id: String, + #[serde(default)] + turn_id: String, + questions: Vec, + #[serde(rename = "isBlocking")] + is_blocking: Option, + #[serde(rename = "autoResolutionMs")] + auto_resolution_ms: Option, + } + + 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; diff --git a/codex-rs/protocol/src/request_user_input_tests.rs b/codex-rs/protocol/src/request_user_input_tests.rs new file mode 100644 index 0000000000..d3c80c31c6 --- /dev/null +++ b/codex-rs/protocol/src/request_user_input_tests.rs @@ -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), + } + ); +} diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 0b72897685..b36f1d4a38 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -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, }, }); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index a45c0757c5..09fd8f2840 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -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, }, } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 4bd7a3655f..c319cc35de 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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, }, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index bf1fa5fa8a..87fb8c0708 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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, ) -> 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, }); diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 33edbc997a..d71f7b6ce5 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -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, } } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 079864104e..98a832335c 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -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, }); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index b103c21a91..1fe5065eeb 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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, });