mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
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:
1 parent
a850875a8e
commit
e2c0837923
36 files changed
+578
-133
No files matched your search
@@ -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",
|
||||||
|
|||||||
+5
@@ -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",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+5
-1
@@ -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");
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user