mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] handle request_user_input in app-server test client (#29476)
## Why `codex-app-server-test-client` previously treated `item/tool/requestUserInput` as an unsupported server request and terminated the connection. That made it impossible to use the client for end-to-end testing of interactive turns: an operator could observe the request, but could not answer it and confirm that the same turn resumed. ## What changed - Handle `ToolRequestUserInput` server requests in the test client's central request dispatcher. - Render numbered terminal choices, accept exact option labels, support free-form `Other` and text-only questions, and collect multiple answers. - Send a protocol-native `ToolRequestUserInputResponse` and continue streaming the active turn. - Fail clearly when interactive input is requested without a terminal. - Document the interactive behavior and add focused tests for option selection, free-form answers, multiple questions, and invalid-selection retries. ## Testing - `just test -p codex-app-server-test-client` - `just bazel-lock-check` - Manually exercised the app-server flow, selected `TUI`, observed `serverRequest/resolved`, and verified that the same turn completed with the selected answer.
This commit is contained in:
committed by
GitHub
Unverified
parent
ced3e4b9a7
commit
cb255c52e9
Generated
+1
@@ -2136,6 +2136,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-utils-cli",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -26,3 +26,6 @@ uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -18,6 +18,10 @@ cargo run -p codex-app-server-test-client -- \
|
||||
cargo run -p codex-app-server-test-client -- model-list
|
||||
```
|
||||
|
||||
`send-message` and `send-message-v2` handle `request_user_input` server requests interactively.
|
||||
When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered)
|
||||
and the client will send the response and continue streaming the same turn.
|
||||
|
||||
## Testing Plugin Analytics
|
||||
|
||||
The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin
|
||||
|
||||
@@ -91,6 +91,7 @@ mod loopback_responses_server;
|
||||
mod plugin_analytics_capture;
|
||||
mod plugin_analytics_mutation_smoke;
|
||||
mod plugin_analytics_smoke;
|
||||
mod request_user_input;
|
||||
|
||||
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
|
||||
// v2 item deltas.
|
||||
@@ -2040,6 +2041,10 @@ impl CodexClient {
|
||||
ServerRequest::FileChangeRequestApproval { request_id, params } => {
|
||||
self.approve_file_change_request(request_id, params)?;
|
||||
}
|
||||
ServerRequest::ToolRequestUserInput { request_id, params } => {
|
||||
let response = request_user_input::prompt_for_answers(¶ms)?;
|
||||
self.send_server_request_response(request_id, &response)?;
|
||||
}
|
||||
other => {
|
||||
bail!("received unsupported server request: {other:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_app_server_protocol::ToolRequestUserInputAnswer;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
|
||||
pub(super) fn prompt_for_answers(
|
||||
params: &ToolRequestUserInputParams,
|
||||
) -> Result<ToolRequestUserInputResponse> {
|
||||
let stdin = io::stdin();
|
||||
if !stdin.is_terminal() {
|
||||
bail!("request_user_input requires an interactive stdin terminal");
|
||||
}
|
||||
|
||||
let stdout = io::stdout();
|
||||
prompt_for_answers_with(&mut stdin.lock(), &mut stdout.lock(), params)
|
||||
}
|
||||
|
||||
fn prompt_for_answers_with(
|
||||
input: &mut impl BufRead,
|
||||
output: &mut impl Write,
|
||||
params: &ToolRequestUserInputParams,
|
||||
) -> Result<ToolRequestUserInputResponse> {
|
||||
writeln!(
|
||||
output,
|
||||
"\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."
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut answers = HashMap::new();
|
||||
for question in ¶ms.questions {
|
||||
writeln!(output, "\n{}: {}", question.header, question.question)?;
|
||||
let options = question
|
||||
.options
|
||||
.as_deref()
|
||||
.filter(|options| !options.is_empty());
|
||||
let answer_values = if let Some(options) = options {
|
||||
for (index, option) in options.iter().enumerate() {
|
||||
writeln!(
|
||||
output,
|
||||
" {}. {} - {}",
|
||||
index + 1,
|
||||
option.label,
|
||||
option.description
|
||||
)?;
|
||||
}
|
||||
if question.is_other {
|
||||
writeln!(output, " o. Other (free-form)")?;
|
||||
}
|
||||
|
||||
loop {
|
||||
if question.is_other {
|
||||
write!(output, "Choose 1-{} or o: ", options.len())?;
|
||||
} else {
|
||||
write!(output, "Choose 1-{}: ", options.len())?;
|
||||
}
|
||||
output.flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
if input
|
||||
.read_line(&mut line)
|
||||
.context("failed to read request_user_input selection")?
|
||||
== 0
|
||||
{
|
||||
bail!("stdin closed while waiting for request_user_input selection");
|
||||
}
|
||||
let selection = line.trim();
|
||||
|
||||
if let Ok(index) = selection.parse::<usize>()
|
||||
&& let Some(option) = index.checked_sub(1).and_then(|index| options.get(index))
|
||||
{
|
||||
break vec![option.label.clone()];
|
||||
}
|
||||
|
||||
if let Some(option) = options
|
||||
.iter()
|
||||
.find(|option| option.label.eq_ignore_ascii_case(selection))
|
||||
{
|
||||
break vec![option.label.clone()];
|
||||
}
|
||||
|
||||
if question.is_other && selection.eq_ignore_ascii_case("o") {
|
||||
write!(output, "Other: ")?;
|
||||
output.flush()?;
|
||||
line.clear();
|
||||
if input
|
||||
.read_line(&mut line)
|
||||
.context("failed to read request_user_input free-form answer")?
|
||||
== 0
|
||||
{
|
||||
bail!("stdin closed while waiting for request_user_input free-form answer");
|
||||
}
|
||||
let answer = line.trim();
|
||||
if !answer.is_empty() {
|
||||
break vec![format!("user_note: {answer}")];
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(output, "Invalid selection; try again.")?;
|
||||
}
|
||||
} else {
|
||||
loop {
|
||||
write!(output, "Answer: ")?;
|
||||
output.flush()?;
|
||||
|
||||
let mut line = String::new();
|
||||
if input
|
||||
.read_line(&mut line)
|
||||
.context("failed to read request_user_input answer")?
|
||||
== 0
|
||||
{
|
||||
bail!("stdin closed while waiting for request_user_input answer");
|
||||
}
|
||||
let answer = line.trim();
|
||||
if !answer.is_empty() {
|
||||
break vec![format!("user_note: {answer}")];
|
||||
}
|
||||
writeln!(output, "Answer cannot be empty; try again.")?;
|
||||
}
|
||||
};
|
||||
|
||||
answers.insert(
|
||||
question.id.clone(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: answer_values,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ToolRequestUserInputResponse { answers })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_user_input_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use codex_app_server_protocol::ToolRequestUserInputAnswer;
|
||||
use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::prompt_for_answers_with;
|
||||
|
||||
#[test]
|
||||
fn collects_option_and_free_form_answers() {
|
||||
let params = ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
questions: vec![
|
||||
ToolRequestUserInputQuestion {
|
||||
id: "target".to_string(),
|
||||
header: "Target".to_string(),
|
||||
question: "Which target?".to_string(),
|
||||
is_other: true,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
ToolRequestUserInputOption {
|
||||
label: "Core".to_string(),
|
||||
description: "Inspect core".to_string(),
|
||||
},
|
||||
ToolRequestUserInputOption {
|
||||
label: "TUI".to_string(),
|
||||
description: "Inspect TUI".to_string(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
ToolRequestUserInputQuestion {
|
||||
id: "details".to_string(),
|
||||
header: "Details".to_string(),
|
||||
question: "Anything else?".to_string(),
|
||||
is_other: true,
|
||||
is_secret: false,
|
||||
options: None,
|
||||
},
|
||||
],
|
||||
auto_resolution_ms: Some(60_000),
|
||||
};
|
||||
let mut input = Cursor::new(b"2\ninclude snapshots\n");
|
||||
let mut output = Vec::new();
|
||||
|
||||
let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
ToolRequestUserInputResponse {
|
||||
answers: HashMap::from([
|
||||
(
|
||||
"target".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["TUI".to_string()],
|
||||
},
|
||||
),
|
||||
(
|
||||
"details".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["user_note: include snapshots".to_string()],
|
||||
},
|
||||
),
|
||||
]),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
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",
|
||||
"\nTarget: Which target?\n",
|
||||
" 1. Core - Inspect core\n",
|
||||
" 2. TUI - Inspect TUI\n",
|
||||
" o. Other (free-form)\n",
|
||||
"Choose 1-2 or o: ",
|
||||
"\nDetails: Anything else?\n",
|
||||
"Answer: ",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_invalid_selection_and_collects_other_answer() {
|
||||
let params = ToolRequestUserInputParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "item-1".to_string(),
|
||||
questions: vec![ToolRequestUserInputQuestion {
|
||||
id: "target".to_string(),
|
||||
header: "Target".to_string(),
|
||||
question: "Which target?".to_string(),
|
||||
is_other: true,
|
||||
is_secret: false,
|
||||
options: Some(vec![ToolRequestUserInputOption {
|
||||
label: "Core".to_string(),
|
||||
description: "Inspect core".to_string(),
|
||||
}]),
|
||||
}],
|
||||
auto_resolution_ms: None,
|
||||
};
|
||||
let mut input = Cursor::new(b"9\no\nSDK wrapper\n");
|
||||
let mut output = Vec::new();
|
||||
|
||||
let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
ToolRequestUserInputResponse {
|
||||
answers: HashMap::from([(
|
||||
"target".to_string(),
|
||||
ToolRequestUserInputAnswer {
|
||||
answers: vec!["user_note: SDK wrapper".to_string()],
|
||||
},
|
||||
)]),
|
||||
}
|
||||
);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("Invalid selection; try again."));
|
||||
}
|
||||
Reference in New Issue
Block a user