Skip to content

RequestUserInput工具

追踪 request_user_input 的规格、模式门槛、pending holder、客户端响应和取消清理。

基于rust-v0.150.0
CodexRustToolsUser Input

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 的响应确认。

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 可执行检查 ​

bash
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,但日志和生命周期不同。