Add request_user_input auto-resolution window contract (#27256)

## Why

`request_user_input` is moving beyond its original plan-mode-only
workflow, and future default/goal-mode usage needs a way for the model
to ask helpful but non-blocking questions without forcing the turn to
wait forever. This PR adds an explicit `autoResolutionMs` contract so a
later client/runtime change can auto-resolve unanswered prompts after a
bounded window while leaving truly blocking questions unchanged.

This is contract plumbing only; it does not implement the client-side
timer or auto-selection behavior, and the model-facing description
treats the field as reserved unless the current runtime explicitly
supports auto-resolution.

## What Changed

- Added optional `autoResolutionMs` to the model-facing
`request_user_input` args and core `RequestUserInputEvent`.
- Added model-facing schema text for `autoResolutionMs` while marking it
reserved for runtimes that explicitly support auto-resolution.
- Bounds `autoResolutionMs` to `60_000..=240_000` ms during argument
normalization by clamping out-of-range model-provided values.
- Propagated the field through app-server v2
`ToolRequestUserInputParams`, app-server request forwarding, generated
TypeScript, and JSON schema fixtures.
- Updated app-server, core, protocol, and TUI call sites/tests so
omitted values preserve existing `None`/`null` behavior and coverage
verifies a `Some(60_000)` round trip.

## Verification

- `just test -p codex-app-server-protocol`
- `just test -p codex-core request_user_input`
- `just test -p codex-app-server request_user_input_round_trip`
- `just test -p codex-tui request_user_input`
- `just test -p codex-protocol`
This commit is contained in:
Shijie Rao
2026-06-11 22:30:41 -07:00
committed by GitHub
parent 78bab04116
commit 216ce03031
25 changed files with 243 additions and 16 deletions
@@ -687,6 +687,7 @@ pub(crate) async fn apply_bespoke_event_handling(
turn_id: request.turn_id,
item_id: request.call_id,
questions,
auto_resolution_ms: request.auto_resolution_ms,
};
let (pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::ToolRequestUserInput(params))
@@ -1259,6 +1259,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
auto_resolution_ms: None,
},
))
.await;
@@ -1321,6 +1322,7 @@ mod tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
auto_resolution_ms: None,
},
))
.await;
@@ -1140,6 +1140,7 @@ mod thread_processor_behavior_tests {
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![],
auto_resolution_ms: None,
},
))
.await;
@@ -2,7 +2,6 @@ use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_request_user_input_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
@@ -18,15 +17,46 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ReasoningEffort;
use core_test_support::responses;
use serde_json::json;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn create_request_user_input_sse_response_with_auto_resolution(
call_id: &str,
auto_resolution_ms: u64,
) -> anyhow::Result<String> {
let tool_call_arguments = serde_json::to_string(&json!({
"questions": [{
"id": "confirm_path",
"header": "Confirm",
"question": "Proceed with the plan?",
"options": [{
"label": "Yes (Recommended)",
"description": "Continue the current plan."
}, {
"label": "No",
"description": "Stop and revisit the approach."
}]
}],
"autoResolutionMs": auto_resolution_ms
}))?;
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "request_user_input", &tool_call_arguments),
responses::ev_completed("resp-1"),
]))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn request_user_input_round_trip() -> Result<()> {
let codex_home = tempfile::TempDir::new()?;
let responses = vec![
create_request_user_input_sse_response("call1")?,
create_request_user_input_sse_response_with_auto_resolution(
"call1", /*auto_resolution_ms*/ 60_000,
)?,
create_final_assistant_message_sse_response("done")?,
];
let server = create_mock_responses_server_sequence(responses).await;
@@ -89,6 +119,7 @@ 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));
let resolved_request_id = request_id.clone();
mcp.send_response(
@@ -128,7 +159,6 @@ async fn request_user_input_round_trip() -> Result<()> {
Ok(())
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(