Make user input blocking behavior explicit (#36410)

## Why

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

## What changed

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

## Testing

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

GitOrigin-RevId: 29aade657ef743065ec264376ba567a9b353d7d7
This commit is contained in:
Shijie Rao
2026-08-01 00:38:10 +00:00
committed by copyberry
parent a850875a8e
commit e2c0837923
36 changed files with 578 additions and 133 deletions
@@ -1624,7 +1624,7 @@ pub struct ToolRequestUserInputQuestion {
pub options: Option<Vec<ToolRequestUserInputOption>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// EXPERIMENTAL. Params sent with a request_user_input event.
@@ -1633,11 +1633,41 @@ pub struct ToolRequestUserInputParams {
pub turn_id: String,
pub item_id: String,
pub questions: Vec<ToolRequestUserInputQuestion>,
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(default)]
#[ts(type = "number | null")]
pub auto_resolution_ms: Option<u64>,
}
impl<'de> Deserialize<'de> for ToolRequestUserInputParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireToolRequestUserInputParams {
thread_id: String,
turn_id: String,
item_id: String,
questions: Vec<ToolRequestUserInputQuestion>,
is_blocking: Option<bool>,
auto_resolution_ms: Option<u64>,
}
let wire = WireToolRequestUserInputParams::deserialize(deserializer)?;
Ok(Self {
thread_id: wire.thread_id,
turn_id: wire.turn_id,
item_id: wire.item_id,
questions: wire.questions,
is_blocking: wire.is_blocking.unwrap_or(true),
auto_resolution_ms: wire.auto_resolution_ms,
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4731,6 +4731,7 @@ fn realtime_start_omitted_initial_items_remain_none() {
assert_eq!(params.initial_items, None);
}
#[test]
fn realtime_start_deserializes_client_handoff_channel_prefixes() {
let params = serde_json::from_value::<ThreadRealtimeStartParams>(json!({
@@ -4756,3 +4757,45 @@ fn realtime_start_deserializes_client_handoff_channel_prefixes() {
]))
);
}
#[test]
fn tool_request_user_input_params_default_legacy_missing_is_blocking_to_true() {
let params = serde_json::from_value::<ToolRequestUserInputParams>(json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "call-1",
"questions": [{
"id": "q1",
"header": "Confirm",
"question": "Continue?",
"options": [{
"label": "Yes",
"description": "Continue."
}]
}],
"autoResolutionMs": 60_000
}))
.expect("legacy request_user_input params should deserialize");
assert_eq!(
params,
ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![ToolRequestUserInputQuestion {
id: "q1".to_string(),
header: "Confirm".to_string(),
question: "Continue?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![ToolRequestUserInputOption {
label: "Yes".to_string(),
description: "Continue.".to_string(),
}]),
}],
is_blocking: true,
auto_resolution_ms: Some(60_000),
}
);
}