[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:
Celia Chen
2026-06-22 13:55:32 -07:00
committed by GitHub
Unverified
parent ced3e4b9a7
commit cb255c52e9
6 changed files with 286 additions and 0 deletions
+1
View File
@@ -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(&params)?;
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 &params.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, &params).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, &params).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."));
}