RequestUserInput工具
request_user_input 是一个真正会暂停 handler 的交互工具。它不是把问题写入事件后立即返回,而是先把一个 oneshot::Sender<RequestUserInputResponse> 放入当前 turn 的 pending map,再发送 RequestUserInputEvent;handler 会一直等待,直到 UI 或 App Server 通过同一个 turn id 提交回答。问题参数、UI 事件和模型收到的答案是三个不同的数据形状,不能把它们压缩成一个“弹窗回调”。
本文面向已经读过Plan工具、工具取消与超时和ToolLifecycle事件的读者。本文只研究 request_user_input 的结构化问题、模式 gate、holder/oneshot 等待、TUI/App Server 响应和取消清理,不展开 MCP elicitation,也不把它与 request_permissions 的权限状态混为一谈。读完后,读者应能定位一次请求为何不可用、谁拥有等待者、回答如何回到 handler,以及为什么答案缺失时模型仍会收到一个结构化空映射。
1. 可用条件
1.1 配置注册
core tool plan 只有在 experimental_request_user_input_enabled 为 true 时才注册 handler。这个配置默认开启;关闭后,工具既不出现在 visible spec,也不出现在 registry。注册时还会计算当前 features 允许的 mode 列表,并把列表交给 handler 生成动态描述。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_core_utility_tools
if turn_context.config.experimental_request_user_input_enabled {
registry.add_with_exposure(
RequestUserInputHandler {
available_modes: request_user_input_available_modes(features),
},
ToolExposure::DirectModelOnly,
);
}源码位置:codex-rs/tools/src/tool_config.rs :: request_user_input_available_modes
pub fn request_user_input_available_modes(features: &Features) -> Vec<ModeKind> {
TUI_VISIBLE_COLLABORATION_MODES
.into_iter()
.filter(|mode| {
mode.allows_request_user_input()
|| (features.enabled(Feature::DefaultModeRequestUserInput)
&& *mode == ModeKind::Default)
})
.collect()
}默认 feature 只允许 Plan;启用 DefaultModeRequestUserInput 后,Default 也加入列表。这个列表同时影响工具描述和 handler 的运行时可用性,因此不能只改描述文本来打开默认模式。
1.2 三重门槛
一次调用需要同时通过三层检查:配置必须注册 handler;当前 turn mode 必须在 available_modes 中;当前 session source 不能是 sub-agent thread。第三层是运行时所有权限制:用户输入只能由 root thread 发起,子 agent 不能直接把问题发送给共享 UI。
源码位置:codex-rs/core/src/tools/handlers/request_user_input.rs :: RequestUserInputHandler::handle_call
if turn.session_source.is_non_root_agent() {
return Err(FunctionCallError::RespondToModel(
"request_user_input can only be used by the root thread".to_string(),
));
}
let mode = turn.collaboration_mode().mode;
if let Some(message) = request_user_input_unavailable_message(
mode,
&self.available_modes,
) {
return Err(FunctionCallError::RespondToModel(message));
}root-thread检查先于 mode 检查;因此 sub-agent 即使处于允许的 Plan mode,也会先收到 root-thread 错误。
2. 问题契约
2.1 题目结构
协议层把问题拆成题目、选项、答案和整体响应。题目包含 id、短 header、问题文本、is_other、is_secret 和 options;回答不是按数组位置返回,而是用题目 id 映射到 RequestUserInputAnswer。
源码位置:codex-rs/protocol/src/request_user_input.rs :: RequestUserInputQuestion
pub struct RequestUserInputQuestion {
pub id: String,
pub header: String,
pub question: String,
#[serde(rename = "isOther", default)]
pub is_other: bool,
#[serde(rename = "isSecret", default)]
pub is_secret: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<Vec<RequestUserInputQuestionOption>>,
}源码位置:codex-rs/protocol/src/request_user_input.rs :: RequestUserInputResponse
pub struct RequestUserInputAnswer {
pub answers: Vec<String>,
}
pub struct RequestUserInputResponse {
pub answers: HashMap<String, RequestUserInputAnswer>,
}HashMap<String, ...> 允许客户端按 id 返回任意子集;core 不在 notify_user_input_response 中检查每个 id 是否存在,也不要求每个问题都回答。是否允许提交未回答问题,是 TUI overlay 的交互策略。
RequestUserInputEvent 使用自定义反序列化保持旧 rollout 兼容:缺失 turn_id 时得到空字符串,缺失 isBlocking 时默认 true。 autoResolutionMs 仍可读取但已 deprecated。这个兼容层服务历史事件,不改变新 handler 显式写入的字段。
2.2 Schema限制
工具 schema 要求每题提供 options 字段,描述建议 1~3 题、每题 2~3 个互斥选项、不要自行添加 Other。schema 里的短标题、选项数量和互斥性主要是给模型的指导;真正由 handler 强制的运行时限制更少。
源码位置:codex-rs/core/src/tools/handlers/request_user_input_spec.rs :: create_request_user_input_tool
let question_props = BTreeMap::from([
(
"id".to_string(),
JsonSchema::string(Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
)),
),
(
"header".to_string(),
JsonSchema::string(Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
)),
),
(
"question".to_string(),
JsonSchema::string(Some(
"Single-sentence prompt shown to the user.".to_string(),
)),
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::array(
JsonSchema::object(
question_props,
Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
Some(false.into()),
),
Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
);2.3 规范化
normalize_request_user_input_tool_args 只强制每个问题有非空 options,并把所有题目的 is_other 改成 true。它不会强制题目数量、id 唯一、header 长度、选项互斥或选项数量。
源码位置:codex-rs/core/src/tools/handlers/request_user_input_spec.rs :: normalize_request_user_input_tool_args
pub(crate) fn normalize_request_user_input_tool_args(
mut args: RequestUserInputToolArgs,
) -> Result<RequestUserInputToolArgs, String> {
let missing_options = args
.questions
.iter()
.any(|question| question.options.as_ref().is_none_or(Vec::is_empty));
if missing_options {
return Err(
"request_user_input requires non-empty options for every question".to_string(),
);
}
for question in &mut args.questions {
question.is_other = true;
}
Ok(args)
}因此客户端总能看到 is_other=true,即使模型没有在 JSON 中提供它;TUI 会据此追加自己的自由输入选项。
3. Handler等待
3.1 构造请求
handler 解析并规范化后,把 RequestUserInputToolArgs 转成 protocol 的 RequestUserInputArgs。is_blocking 不由模型参数提供,而是由当前 mode 是否为 Plan 派生;auto_resolution_ms 当前由 handler 固定为 None。
源码位置:codex-rs/core/src/tools/handlers/request_user_input.rs :: RequestUserInputHandler::handle_call
let args: RequestUserInputToolArgs = parse_arguments(&arguments)?;
let args = normalize_request_user_input_tool_args(args)
.map_err(FunctionCallError::RespondToModel)?;
let args = RequestUserInputArgs {
questions: args.questions,
is_blocking: mode == ModeKind::Plan,
auto_resolution_ms: None,
};Plan mode 的 blocking 标记会让客户端把请求视为当前 turn 的阻塞式交互;Default mode 如果通过 feature 打开,事件仍会标为 non-blocking。这里的 blocking 是事件/UI 语义,不是 oneshot 是否等待:两种模式的 handler 都会等待响应。
3.2 Holder注册
源码位置:codex-rs/core/src/session/mod.rs :: Session::request_user_input
pub async fn request_user_input(
&self,
turn_context: &TurnContext,
call_id: String,
args: RequestUserInputArgs,
) -> Option<RequestUserInputResponse> {
let _elicitation = self.services.elicitations.register();
let sub_id = turn_context.sub_id.clone();
let (tx_response, rx_response) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_user_input(sub_id, tx_response)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending user input for sub_id: {event_id}");
}等待者的 key 是 turn_context.sub_id,不是 call_id。同一个 turn 同时注册第二个请求会覆盖第一个 sender,并写 warning;这意味着当前 pending map 不支持同一 sub_id 下多个独立 request 并行等待。
3.3 事件屏障
holder 注册后,session 才发送事件并等待 oneshot。mark_user_input_requested_during_turn 写入 turn metadata,供后续 turn 处理读取;send_event(...).await 完成后,UI/App Server 才能看到请求,随后 rx_response.await 才有机会解除 handler。
源码位置:codex-rs/core/src/session/mod.rs :: Session::request_user_input
let event = EventMsg::RequestUserInput(RequestUserInputEvent {
call_id,
turn_id: turn_context.sub_id.clone(),
questions: args.questions,
is_blocking: args.is_blocking,
auto_resolution_ms: args.auto_resolution_ms,
});
turn_context
.turn_metadata_state
.mark_user_input_requested_during_turn();
self.send_event(turn_context, event).await;
rx_response.await.ok()事件发送成功不代表用户已经回答;它只完成“请求已公开”的屏障。响应通道的 owner 仍是当前 turn state。
4. 回答提交
4.1 Core入口
客户端提交回答后,session 通过 notify_user_input_response 在当前 active turn 中查找 sub_id,对 sender 发送 response。没有匹配 holder 时只记录 warning,不创建一个迟到答案缓存。
源码位置:codex-rs/core/src/session/mod.rs :: Session::notify_user_input_response
pub async fn notify_user_input_response(
&self,
sub_id: &str,
response: RequestUserInputResponse,
) {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_user_input(sub_id)
}
None => None,
}
};
match entry {
Some(tx_response) => {
tx_response.send(response).ok();
}
None => {
warn!("No pending user input found for sub_id: {sub_id}");
}
}
}4.2 Op路由
core session 的 Op::UserInputAnswer 将 id 和 response 路由到上述入口。App Server 和 TUI 最终都提交这个 Op,而不是直接接触 oneshot sender。
源码位置:codex-rs/core/src/session/handlers.rs :: Op::UserInputAnswer
Op::UserInputAnswer { id, response } => {
request_user_input_response(&sess, id, response).await;
false
}答案映射里的 key 仍由客户端保留;core 只传递 HashMap<String, RequestUserInputAnswer>,不按问题数组顺序重排或过滤未知 id。
4.3 Handler收束
handler 收到 response 后只做一次 JSON 序列化,把它作为普通 FunctionToolOutput 文本返回给模型。它不会再次发送事件,也不会把答案写入独立的 user-input store。
源码位置:codex-rs/core/src/tools/handlers/request_user_input.rs :: RequestUserInputHandler::handle_call
let response = session
.request_user_input(turn.as_ref(), call_id, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"request_user_input was cancelled before receiving a response".to_string(),
)
})?;
let content = serde_json::to_string(&response).map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize request_user_input response: {err}"
))
})?;
Ok(boxed_tool_output(FunctionToolOutput::from_text(
content,
Some(true),
)))5. App Server
5.1 请求转换
App Server 收到 core 的 EventMsg::RequestUserInput 后,先向 thread watch manager 增加 pending user-input 计数,再把 core question 转换成 v2 ToolRequestUserInputParams,通过 server request 发给客户端。
源码位置:codex-rs/app-server/src/bespoke_event_handling.rs :: EventMsg::RequestUserInput
let user_input_guard = thread_watch_manager
.note_user_input_requested(&conversation_id.to_string())
.await;
let questions = request
.questions
.into_iter()
.map(|question| ToolRequestUserInputQuestion {
id: question.id,
header: question.header,
question: question.question,
is_other: question.is_other,
is_secret: question.is_secret,
options: question.options.map(|options| {
options
.into_iter()
.map(|option| ToolRequestUserInputOption {
label: option.label,
description: option.description,
})
.collect()
}),
})
.collect();
let params = ToolRequestUserInputParams {
thread_id: conversation_id.to_string(),
turn_id: request.turn_id,
item_id: request.call_id,
questions,
is_blocking: request.is_blocking,
auto_resolution_ms: request.auto_resolution_ms,
};item_id 承接 core 的 call_id,但响应匹配的 pending core holder 仍使用 turn_id;App Server 自己还维护一个 request id,用于把客户端 RPC response 与等待任务关联。
5.2 响应任务
App Server 为每个 server request 启动 on_request_user_input_response。普通客户端错误、receiver 关闭或响应 JSON 反序列化失败 时,它构造空答案映射并提交 Op::UserInputAnswer,让 core 的 oneshot 正常结束。若错误表示 Turn 已经切换或结束,则直接返回, 不再向旧 Turn 提交空答案;core holder 由 Turn cleanup 负责清除。
源码位置:codex-rs/app-server/src/bespoke_event_handling.rs :: on_request_user_input_response
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(user_input_guard);
let value = match response {
Ok(Ok(value)) => value,
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response: empty,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
return;
}
Err(err) => {
error!("request failed: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response: empty,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
return;
}
};5.3 多请求边界
App Server 为每个事件分配独立 request id,TUI interrupt queue 也按 item_id 区分待处理提示;但 core 的 pending_user_input map 仍以 turn sub_id 为唯一 key。同一个 core Turn 注册第二个请求会覆盖旧 sender。因此客户端可以排队展示 多个 server request,不代表 core 支持同一 Turn 内多个独立 handler 同时稳定等待。
6. TUI交互
6.1 队列入口
TUI 收到 ToolRequestUserInputParams 后先交给 InterruptManager。如果当前还有更高优先级的 approval 或另一个交互 overlay,request 会进入队列;解决某个 request 时,队列只移除匹配 call_id 的项,避免误删同 turn 的其他问题。
源码位置:codex-rs/tui/src/chatwidget/interrupts.rs :: InterruptManager
pub(crate) fn push_user_input(&mut self, ev: ToolRequestUserInputParams) {
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
}
pub(crate) fn remove_resolved_prompt(
&mut self,
request: &ResolvedAppServerRequest,
) -> bool {
let original_len = self.queue.len();
self.queue
.retain(|queued| !queued.matches_resolved_prompt(request));
self.queue.len() != original_len
}6.2 Overlay状态
RequestUserInputOverlay 为每道题保存选项滚动位置、notes draft、是否提交和 notes 是否可见。选项焦点与 notes 焦点是两个状态;在 options 上输入文字会跳转到 notes,提交键在题目之间移动,最后一题才构造整体 response。
源码位置:codex-rs/tui/src/bottom_pane/request_user_input/mod.rs :: RequestUserInputOverlay
pub(crate) struct RequestUserInputOverlay {
app_event_tx: AppEventSender,
request: ToolRequestUserInputParams,
queue: VecDeque<ToolRequestUserInputParams>,
composer: ChatComposer,
answers: Vec<AnswerState>,
current_idx: usize,
focus: Focus,
done: bool,
pending_submission_draft: Option<ComposerDraft>,
confirm_unanswered: Option<ScrollState>,
request_started_at: Instant,
auto_resolution_snoozed: bool,
composer_submit_keys: Vec<KeyBinding>,
composer_submit_hint: Option<ShortcutHint>,
interrupt_turn_keys: Vec<KeyBinding>,
interrupt_turn_hint: Option<ShortcutHint>,
list_keymap: ListKeymap,
}提交、Turn interrupt 和列表导航现在来自 runtime keymap,而不是 overlay 内写死的按键。它改变交互入口,不改变 core response 结构;无论使用哪个绑定,最终仍构造相同的 answer map。
6.3 答案构造
提交时 TUI 将选中的 option label 放入 answers;notes 非空时追加 user_note: ...。每个问题都插入一个 map entry,即使答案列表为空;这解释了为什么“跳过所有问题”仍可能得到 question id 到空数组的映射。
源码位置:codex-rs/tui/src/bottom_pane/request_user_input/mod.rs :: RequestUserInputOverlay::submit_answers
let selected_label = selected_idx
.and_then(|selected_idx| Self::option_label_for_index(question, selected_idx));
let mut answer_list = selected_label.into_iter().collect::<Vec<_>>();
if !notes.is_empty() {
answer_list.push(format!("user_note: {notes}"));
}
answers.insert(
question.id.clone(),
ToolRequestUserInputAnswer {
answers: answer_list,
},
);随后 TUI 同时发送 UserInputAnswer command 和 history cell;后者是显示记录,不是 core holder 的响应确认。
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
ToolRequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
answers,
interrupted: false,
},
)));6.4 Secret输入
is_secret 只影响 TUI 输入框渲染:render 层读取当前题目的 is_secret,决定是否隐藏文本。core 协议和 answer map 仍传递字符串;该标志不是加密存储或服务端脱敏保证。
7. 阻塞与收束
7.1 Blocking标记
handler 将 is_blocking 设置为 mode == ModeKind::Plan。RequestUserInputEvent 和 App Server params 都携带这个标记;TUI overlay 依据它决定是否允许自动收束计时。它不会改变 core oneshot 的等待方式:handler 始终等待响应。
协议反序列化还保留兼容边界:旧事件缺少 isBlocking 时默认 true;autoResolutionMs 已标记 deprecated,客户端应以 isBlocking 判断请求是否阻塞。当前 handler 仍显式写入两者,因此新事件不依赖该默认值。
7.2 Auto resolution
当前 handler 固定 auto_resolution_ms=None,但该字段已经 deprecated。TUI 不再使用它决定倒计时,而只读取 is_blocking: Plan mode 的 blocking 请求永不自动收束;Default mode 的 non-blocking 请求使用 TUI 固定的 60 秒 hidden grace 加 60 秒 visible countdown。任何按键或粘贴交互都会 snooze 本次自动收束。到期后 submit_empty_auto_resolution 发送空 map。
源码位置:codex-rs/tui/src/bottom_pane/request_user_input/mod.rs :: RequestUserInputOverlay::auto_resolution_timing_at
if self.request.is_blocking || self.auto_resolution_snoozed {
return AutoResolutionTiming::Disabled;
}
let elapsed = now.saturating_duration_since(self.request_started_at);
if elapsed < AUTO_RESOLUTION_HIDDEN_GRACE {
return AutoResolutionTiming::HiddenGrace {
remaining: AUTO_RESOLUTION_HIDDEN_GRACE.saturating_sub(elapsed),
};
}
let visible_elapsed = elapsed.saturating_sub(AUTO_RESOLUTION_HIDDEN_GRACE);
if visible_elapsed < AUTO_RESOLUTION_VISIBLE_COUNTDOWN {
return AutoResolutionTiming::VisibleCountdown {
remaining: AUTO_RESOLUTION_VISIBLE_COUNTDOWN.saturating_sub(visible_elapsed),
};
}
AutoResolutionTiming::Due源码位置:codex-rs/tui/src/bottom_pane/request_user_input/mod.rs :: RequestUserInputOverlay::submit_empty_auto_resolution
fn submit_empty_auto_resolution(&mut self, now: Instant) {
self.confirm_unanswered = None;
let answers: HashMap<String, ToolRequestUserInputAnswer> = HashMap::new();
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
ToolRequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
answers,
interrupted: false,
},
)));
self.advance_queue_or_complete_at(now);
}7.3 空回答
App Server 的客户端错误、receiver 关闭或反序列化失败也会提交空 answers map。空 map 是“请求已解除但没有可用答案”,不是 core 认为用户选择了某个默认选项。
8. 取消与清理
8.1 Holder清空
turn 结束或清理 pending input 时,TurnInputQueue::clear_pending 调用 TurnState::clear_pending_waiters,直接清空 pending_user_input sender。对仍在等待的 handler 来说,oneshot receiver 返回 Err,handler 转成“cancelled before receiving a response”。
源码位置:codex-rs/core/src/session/input_queue.rs :: TurnInputQueue::clear_pending
pub(crate) async fn clear_pending(&self, active_turn: &ActiveTurn) {
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.clear_pending_waiters();
turn_state.pending_input.items.clear();
}8.2 Elicitation暂停
Session::request_user_input 在注册 sender 前创建 ElicitationRegistration。它递增 session 级 outstanding count,使 subscribe_elicitation_pause_state 变为 true;sender 被响应、handler 被取消或函数离开作用域时 registration drop,计数归零后才恢复 false。多个并发 elicitation 共用计数,不会因一个请求完成就提前解除暂停。
源码位置:codex-rs/core/src/elicitation.rs :: ElicitationService
pub(crate) fn register(&self) -> ElicitationRegistration {
self.increment();
ElicitationRegistration {
service: self.clone(),
}
}
impl Drop for ElicitationRegistration {
fn drop(&mut self) {
self.service.decrement();
}
}8.3 覆盖与迟到回答
同一 sub_id 新请求会覆盖旧 sender;旧 handler 的 receiver 被 drop,旧请求随后得到取消错误。迟到回答如果 map 中已经没有 sender,只写 warning,不会唤醒新请求或保存到下一轮。
9. 生命周期
9.1 Control工具
RequestUserInputHandler 现在声明为 builtin control tool。registry 从工具开始到 handler 返回期间持有 ControlToolCallGuard,成功回答记录 Completed,模式/root-thread/参数错误记录 Failed,PreToolUse 阻断记录 Rejected,Turn 取消导致 future 被 drop 时保持 Interrupted。
源码位置:codex-rs/core/src/tools/handlers/request_user_input.rs :: CoreToolRuntime for RequestUserInputHandler
impl CoreToolRuntime for RequestUserInputHandler {
fn is_builtin_control_tool(&self) -> bool {
true
}
}9.2 Hook边界
handler 使用默认 function Hook 投影。PreToolUse 发生在 holder 注册和事件发送之前,可以阻断或重写 questions;PostToolUse 只有在用户回答、handler 序列化 response 并返回成功后才运行。PostToolUse block 可以拒绝答案 JSON 回灌给模型,但不能撤销 用户已经完成的交互或 TUI history cell。
9.3 等待期间
handler 已经通过 registry 的普通工具生命周期开始通知,但在 session.request_user_input(...).await 中保持未完成。App Server thread watch 用 pending_user_input_requests 计数显示等待状态;TUI 将 ambient notification 置为 Waiting。
9.4 响应之后
收到答案后,handler 返回成功 FunctionToolOutput,普通 registry lifecycle 才能完成;模型随后在 function output 中看到序列化 JSON。TUI history cell 的插入与 App Server 的 pending request 解除是客户端行为,不是 core 答案 store。
9.5 取消竞争
如果用户中断 turn,turn cleanup 清空 pending sender,handler 返回 cancellation error;如果客户端响应与 cleanup 同时发生,谁先从 map 移除 sender 谁拥有结果,另一方只能看到 no pending warning。这个 remove-then-send 顺序避免同一回答被两个消费者接收。
10. 测试验证
10.1 模式与规范化
源码位置:codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs :: normalize_request_user_input_tool_args_sets_other_on_every_question
测试输入显式设置 is_other=false 的问题,断言 normalize 后变为 true;相邻测试输入 options 缺失,断言返回非空 options 错误。它证明 handler 的规范化边界,不证明 schema 描述中的 1~3 题被运行时强制。
源码位置:codex-rs/core/src/tools/handlers/request_user_input_spec_tests.rs :: request_user_input_unavailable_messages_respect_default_mode_feature_flag
测试关闭和打开 DefaultModeRequestUserInput feature,分别断言 available modes 为 Plan、以及 Default + Plan。它证明 feature 影响模式列表,不证明 config disabled 时 registry gate,后者由 spec plan 测试覆盖。
10.2 Handler等待
源码位置:codex-rs/core/src/tools/handlers/request_user_input_tests.rs :: request_user_input_sets_non_blocking_outside_plan_mode
测试启动 handler,等待 EventMsg::RequestUserInput,断言 call id 和 is_blocking=false,再通过 session notify response 解除 handler。Plan mode 对应测试断言 is_blocking=true。它证明 blocking 是 mode 派生并且事件与 oneshot 相连。
源码位置:codex-rs/core/src/tools/handlers/request_user_input_tests.rs :: multi_agent_v2_request_user_input_rejects_subagent_threads
测试把 turn source 改为 ThreadSpawn sub-agent,断言 root-thread错误,并且不会进入等待状态或发送事件。
10.3 Holder与客户端
源码位置:codex-rs/core/src/session/elicitation_holders_tests.rs :: request_user_input_holds_an_elicitation_until_response
测试直接调用 session request,等待事件和 pause state=true,再提交 response,最后等待 pause state=false。它证明 ElicitationRegistration 的 RAII 计数覆盖等待区间。
源码位置:codex-rs/tui/src/bottom_pane/request_user_input/mod.rs :: auto_resolution_uses_is_blocking_without_auto_resolution_ms
测试构造 is_blocking=false 且 auto_resolution_ms=None 的 request,推进固定 grace/countdown,断言自动收束生效。相邻 auto_resolution_expiry_emits_empty_answer 再断言到期发送空 answers。它们证明 isBlocking 已取代 deprecated timer 字段。
10.4 可执行检查
rg -n "request_user_input|pending_user_input|notify_user_input_response|ElicitationRegistration" codex-rs/core/src codex-rs/protocol/src
cargo test -p codex-core request_user_input_sets_non_blocking_outside_plan_mode
cargo test -p codex-core request_user_input_sets_blocking_from_turn_mode
cargo test -p codex-core multi_agent_v2_request_user_input_rejects_subagent_threads
cargo test -p codex-core request_user_input_holds_an_elicitation_until_response
cargo test -p codex-core tools::handlers::request_user_input_spec::tests::request_user_input_unavailable_messages_respect_default_mode_feature_flag
cargo test -p codex-tui bottom_pane::request_user_input::tests::auto_resolution_uses_is_blocking_without_auto_resolution_ms这些命令覆盖 feature/mode gate、handler 事件等待、root-thread限制、holder pause 和 TUI 自动空回答;它们不会证明客户端 UI 一定能显示所有自定义 is_secret 题目,也不会把空 answers 解释为用户选择了默认选项。
11. 诊断路径
遇到工具不可见,先查 experimental_request_user_input_enabled 和 request_user_input_available_modes;遇到“unavailable”错误,查当前 ModeKind 和 handler 的 available modes;遇到事件已显示但模型不继续,查 pending_user_input 的 key 是否使用 turn sub_id、客户端是否提交 UserInputAnswer,以及 active turn 是否已被 cleanup。若 TUI 显示空答案,区分用户点击 Proceed、auto-resolution 到期、客户端 RPC 失败和 holder 已被清空这四种来源,它们最终都可能表现为相同的空 map,但日志和生命周期不同。
