Skip to content

UserInput类型体系

沿着 TurnInput、媒体快照、Responses 转换和 Core 注入链,理解用户输入如何从协议值变成可执行的 turn 内容。

基于rust-v0.150.0
CodexRustProtocolInput

UserInput类型体系 ​

本文承接Thread与Session标识模型,也会用到Submission与Op总表。UserInput 不是一个“收到字符串就发给模型”的薄 DTO:它同时保存文本富元素、远程媒体、本地媒体、Skill 和 Mention;TurnInputRequest 决定这些内容何时进入 turn;协议层负责可序列化和媒体转换,Core 负责 steering、hook、插件依赖和上下文注入。

阅读这条链时要区分三个结果:输入值被接受,输入值被记录进 pending turn,以及输入值已经变成 Responses API 的 ContentItem。它们由不同函数完成,失败边界也不同。

1. UserInput外壳 ​

源码位置:codex-rs/protocol/src/user_input.rs :: UserInput、TextElement、ByteRange

rust
/// Conservative cap so one user message cannot monopolize a large context window.
pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20;

/// User input
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UserInput {
    Text {
        text: String,
        /// UI-defined spans within `text` that should be treated as special elements.
        #[serde(default)]
        text_elements: Vec<TextElement>,
    },
    Image {
        image_url: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        detail: Option<ImageDetail>,
    },
    LocalImage {
        path: std::path::PathBuf,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        #[ts(optional)]
        detail: Option<ImageDetail>,
    },
    Audio { audio_url: String },
    LocalAudio { path: std::path::PathBuf },
    Skill { name: String, path: std::path::PathBuf },
    Mention { name: String, path: String },
}

#[non_exhaustive] 很关键:调用方必须为未来输入变体保留扩展空间。文本和媒体变体描述“内容”,Skill 与 Mention 描述“选择了什么资源”,后两者不会在协议转换处直接生成模型 content。

TextElement 的范围是 UTF-8 字节偏移,而不是字符索引。它可以有显式 placeholder,也可以在仍能访问原文时从范围切片得到 placeholder。

源码位置:codex-rs/protocol/src/user_input.rs :: TextElement::placeholder、TextElement::map_range

rust
impl TextElement {
    pub fn map_range<F>(&self, map: F) -> Self
    where
        F: FnOnce(ByteRange) -> ByteRange,
    {
        Self {
            byte_range: map(self.byte_range),
            placeholder: self.placeholder.clone(),
        }
    }

    pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> {
        self.placeholder
            .as_deref()
            .or_else(|| text.get(self.byte_range.start..self.byte_range.end))
    }
}

这意味着重写文本时不能只复制 text_elements 数组:如果文本发生了拼接或裁剪,范围必须通过 map_range 重新映射,否则 UI 标记可能指向错误的字节边界。

2. TurnInput与Core ​

UserInput 只是内容元素,真正进入 session queue 的是 TurnInput::UserInput。在 0.150.0 中,普通输入通过带回复的 TurnInputRequest 进入 Core,和 ResponseItem、InterAgentCommunication 明确区分。

源码位置:codex-rs/protocol/src/turn_input.rs :: TurnInput、TurnInputRequest::user_input

rust
/// Input consumed by a regular turn.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum TurnInput {
    UserInput {
        content: Vec<UserInput>,
        client_id: Option<String>,
    },
    ResponseItem(ResponseItem),
    InterAgentCommunication(InterAgentCommunication),
}

pub struct TurnInputRequest {
    pub input: TurnInput,
    pub thread_settings: ThreadSettingsOverrides,
    pub start: TurnStartOptions,
    pub additional_context: BTreeMap<String, AdditionalContextEntry>,
    pub responsesapi_client_metadata: Option<HashMap<String, String>>,
    pub trace: Option<W3cTraceContext>,
}

pub fn user_input(content: Vec<UserInput>) -> Self {
    Self::new(TurnInput::UserInput {
        content,
        client_id: None,
    })
}

请求还携带线程设置、只在新 turn 生效的 start options、额外上下文和 Responses metadata。这样,同一组 UserInput 可以被 start、steer 或 recovery 路径消费,但“内容是什么”和“如何路由”不再混在一个枚举里。

源码位置:codex-rs/protocol/src/turn_input.rs :: TurnInputMode、TurnInputSubmission、NotSubmittedReason

rust
pub enum TurnInputMode {
    StartOrSteer,
    StartIfIdle,
    Steer { expected_turn_id: String },
}

pub enum TurnInputSubmission {
    Started { turn_id: String },
    Steered { turn_id: String },
    NotSubmitted { reason: NotSubmittedReason },
}

pub enum NotSubmittedReason {
    NotIdle,
    PendingTriggerTurn,
    PlanMode,
    NoActiveTurn,
    ExpectedTurnMismatch { expected: String, actual: String },
    ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
    ActiveTurnOutputSchemaMismatch,
    EmptyInput,
}

Started 或 Steered 只表示 Core 接受了输入进行 turn 处理,不表示 hook 已完成、历史已落盘或模型已经采样。对于教材阅读,先沿 TurnInputMode 看路由,再沿 UserInput 看内容转换,顺序不能颠倒。

3. Start与Steer ​

源码位置:codex-rs/core/src/session/turn_input.rs :: handle、handle_recovery

rust
pub(super) async fn handle(
    session: &Arc<Session>,
    request: TurnInputRequest,
    mode: TurnInputMode,
    submission_id: String,
) -> CodexResult<TurnInputSubmission> {
    match mode {
        TurnInputMode::StartOrSteer => start_or_steer(session, request, submission_id).await,
        TurnInputMode::StartIfIdle => {
            start_if_idle(session, request, submission_id, /*is_recovery*/ false).await
        }
        TurnInputMode::Steer { expected_turn_id } => {
            steer(session, request, expected_turn_id, submission_id).await
        }
    }
}

pub(super) async fn handle_recovery(
    session: &Arc<Session>,
    thread_settings: ThreadSettingsOverrides,
    submission_id: String,
) -> CodexResult<TurnInputSubmission> {
    let request = TurnInputRequest::user_input(Vec::new()).with_thread_settings(thread_settings);
    start_if_idle(session, request, submission_id, /*is_recovery*/ true).await
}

recovery 故意构造空的 UserInput,因为它恢复的是既有 turn 的采样,而不是再追加用户文本。空输入在普通 steering 路径可能被拒绝,但在 recovery 分支具有不同语义;不能把 content.is_empty() 作为全局“无效输入”判断。

StartOrSteer 先尝试 steering,只有 NoActiveTurn 才转入新 turn。设置会先通过 PreparedTurnInputSettings::prepare 预览,避免一个最终未提交的输入改变线程配置。

源码位置:codex-rs/core/src/session/turn_input.rs :: start_or_steer、PreparedTurnInputSettings::apply_started、apply_steered

rust
match session
    .steer_input(
        &mut items,
        additional_context.clone(),
        /*expected_turn_id*/ None,
        settings.required_active_final_output_json_schema(),
        client_id.clone(),
        responsesapi_client_metadata.clone(),
        incoming_root_turn_id,
    )
    .await
{
    Ok(turn_id) => {
        settings.apply_steered(session, submission_id).await?;
        Ok(TurnInputSubmission::Steered { turn_id })
    }
    Err(NotSubmittedReason::NoActiveTurn) => {
        let turn_context = settings
            .apply_started(session, submission_id.clone())
            .await?;
        // new-turn metadata and task input are assembled below
        Ok(TurnInputSubmission::Started {
            turn_id: turn_context.sub_id.clone(),
        })
    }
    Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }),
}

新 turn 才会记录 parent_turn_id、root_turn_id 和 final output schema;steer 只更新后续 turn 可见的持久设置,不能修改活动 turn 的上下文。这是 UserInput 内容与 turn lineage 分离的直接体现。

4. 输入进入队列 ​

源码位置:codex-rs/core/src/session/turn_input.rs :: merge_additional_context_input、pending_turn_input

rust
async fn merge_additional_context_input(
    session: &Session,
    additional_context: BTreeMap<String, AdditionalContextEntry>,
) -> Vec<TurnInput> {
    let additional_context_input = {
        let mut state = session.state.lock().await;
        state.additional_context.merge(additional_context)
    };
    additional_context_input
        .into_iter()
        .map(|item| session.annotate_client_response_item(item))
        .map(TurnInput::ResponseItem)
        .collect()
}

fn pending_turn_input(input: SubmittedTurnInput) -> TurnInput {
    match input {
        SubmittedTurnInput::UserInput { content, client_id } => {
            TurnInput::UserInput { content, client_id }
        }
        SubmittedTurnInput::ResponseItem(item) => TurnInput::ResponseItem(item.into()),
        SubmittedTurnInput::InterAgentCommunication(communication) => {
            TurnInput::InterAgentCommunication(communication)
        }
    }
}

额外上下文被转换成 ResponseItem,用户输入仍保持 TurnInput::UserInput。这保证了后面的 hook 和 turn 处理可以知道一段内容是用户提交的,还是 Core 合并进来的上下文。

源码位置:codex-rs/core/src/session/turn_input.rs :: has_nonempty_user_input

rust
fn has_nonempty_user_input(input: &SubmittedTurnInput) -> bool {
    matches!(input, SubmittedTurnInput::UserInput { content, .. } if !content.is_empty())
}

这个判断只用于路由决策,不能替代媒体读取、文本长度、Skill 存在性或 Mention 权限检查。

5. 文本与富元素 ​

源码位置:codex-rs/protocol/src/items.rs :: UserMessageItem::new、message、text_elements

rust
impl UserMessageItem {
    pub fn new(content: &[UserInput]) -> Self {
        Self {
            id: new_item_id(),
            client_id: None,
            content: content.to_vec(),
        }
    }

    pub fn message(&self) -> String {
        self.content
            .iter()
            .map(|c| match c {
                UserInput::Text { text, .. } => text.clone(),
                _ => String::new(),
            })
            .collect::<Vec<String>>()
            .join("")
    }

    pub fn text_elements(&self) -> Vec<TextElement> {
        let mut out = Vec::new();
        let mut offset = 0usize;
        for input in &self.content {
            if let UserInput::Text {
                text,
                text_elements,
                ..
            } = input
            {
                for elem in text_elements {
                    let byte_range = ByteRange {
                        start: offset + elem.byte_range.start,
                        end: offset + elem.byte_range.end,
                    };
                    out.push(TextElement::new(
                        byte_range,
                        elem.placeholder(text).map(str::to_string),
                    ));
                }
                offset += text.len();
            }
        }
        out
    }
}

message() 只拼接文本,媒体和 Skill 不会被伪装成文本;text_elements() 则把每个文本块的局部字节范围加上累计 offset,映射到拼接后的消息。这里的 offset 是字节数,因为 ByteRange 的契约就是 UTF-8 字节范围。

6. 远程媒体与默认值 ​

源码位置:codex-rs/protocol/src/models.rs :: ResponseInputItem::from_user_input

rust
pub fn from_user_input(
    items: Vec<UserInput>,
    local_image_preparation: LocalImagePreparation,
) -> Self {
    let mut image_index = 0;
    let mut audio_index = 0;
    Self::Message {
        role: "user".to_string(),
        content: items
            .into_iter()
            .flat_map(|c| match c {
                UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
                UserInput::Image { image_url, detail, .. } => {
                    image_index += 1;
                    let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
                    vec![ContentItem::InputImage {
                        image_url,
                        detail: Some(detail),
                    }]
                }
                UserInput::Audio { audio_url } => {
                    audio_index += 1;
                    vec![ContentItem::InputAudio { audio_url }]
                }
                // local media and non-content variants continue below
                _ => Vec::new(),
            })
            .collect::<Vec<ContentItem>>(),
        phase: None,
    }
}

远程图片已经是 data URL 或其他可转发 URI,因此只补默认 detail;音频直接形成 InputAudio。text_elements 在这个转换中被有意忽略,因为它是 UI/历史标记,不是 Responses content 的独立消息。

7. 本地媒体路径 ​

本地媒体有两个不同 owner。协议模型转换会读取路径并生成可发送的 content 或错误占位;更早的 snapshot_local_user_input 则把本地值改写成便携的 Image/Audio,让后续请求不再依赖原始路径。

源码位置:codex-rs/protocol/src/local_media.rs :: snapshot_local_user_input

rust
pub fn snapshot_local_user_input(input: &mut UserInput) -> io::Result<()> {
    match input {
        UserInput::LocalImage { path, detail } => {
            let image_detail = *detail;
            let mode = match image_detail {
                Some(ImageDetail::Original) => PromptImageMode::Original,
                Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) | None => {
                    PromptImageMode::ResizeToFit
                }
            };
            let file_bytes = read_bounded_local_media(path, MAX_PROMPT_IMAGE_INPUT_BYTES, "image")?;
            let image = load_for_prompt_bytes(path, file_bytes, mode)
                .map_err(io::Error::other)?;
            *input = UserInput::Image {
                image_url: image.into_data_url(),
                detail: image_detail,
            };
        }
        UserInput::LocalAudio { path } => {
            let mime = audio_mime_for_path(path).ok_or_else(|| {
                io::Error::new(io::ErrorKind::InvalidData, "unsupported audio format")
            })?;
            let file_bytes = read_bounded_local_media(path, MAX_PROMPT_AUDIO_INPUT_BYTES, "audio")?;
            *input = UserInput::Audio {
                audio_url: data_url_from_bytes(mime, &file_bytes),
            };
        }
        UserInput::Text { .. }
        | UserInput::Image { .. }
        | UserInput::Audio { .. }
        | UserInput::Skill { .. }
        | UserInput::Mention { .. } => {}
    }
    Ok(())
}

图片受 MAX_PROMPT_IMAGE_INPUT_BYTES 限制,音频受 50 MiB 限制,且音频扩展名只接受 wav、mp3、m4a、webm、ogg。函数在成功后替换原变体;失败时返回错误,调用方仍可保留原始 LocalImage 或 LocalAudio。

ResponseInputItem::from_user_input 的 LocalImagePreparation::Process 和 Defer 是另一层选择:它决定图片如何变成带标签的 content,并不等价于 snapshot_local_user_input 是否已经执行。排查图片问题时,先确认调用的是哪一条路径。

8. 媒体错误占位 ​

源码位置:codex-rs/protocol/src/models.rs :: LocalImage、LocalAudio 分支

rust
UserInput::LocalImage { path, detail, .. } => {
    image_index += 1;
    let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
    match std::fs::read(&path) {
        Ok(file_bytes) => match local_image_preparation {
            LocalImagePreparation::Process => {
                local_image_content_items_with_label_number(
                    &path,
                    file_bytes,
                    Some(image_index),
                    detail,
                )
            }
            LocalImagePreparation::Defer => local_image_content_items(
                &path,
                data_url_from_bytes("application/octet-stream", &file_bytes),
                Some(image_index),
                detail,
            ),
        },
        Err(err) => vec![local_media_error_placeholder(
            &path,
            err,
            LocalMediaKind::Image,
        )],
    }
}
UserInput::LocalAudio { path } => {
    audio_index += 1;
    match std::fs::read(&path) {
        Ok(file_bytes) => local_audio_content_items(&path, &file_bytes, audio_index),
        Err(err) => vec![local_media_error_placeholder(
            &path,
            err,
            LocalMediaKind::Audio,
        )],
    }
}

这条转换路径的设计是“单个文件读取失败生成可解释的文本占位”,而不是让整个 ResponseInputItem panic。占位信息包含媒体类型、路径和错误原因,因此模型请求仍然可观察;但它当然不等价于成功附加了原文件。

9. Skill与Mention ​

源码位置:codex-rs/protocol/src/models.rs :: Skill、Mention 分支

rust
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(),
// Tool bodies are injected later in core

协议层返回空 content 并不表示用户选择丢失。Core 会从 UserInput 中收集显式 Skill、插件和 Mention,检查当前 session 的能力快照,再决定需要的 MCP server、扩展输入和注入提示。

源码位置:codex-rs/core/src/session/turn.rs :: required_mcp_servers_for_input

rust
let messages = user_input
    .iter()
    .filter_map(|input| match input {
        UserInput::Text { text, .. } => Some(text.clone()),
        _ => None,
    })
    .collect::<Vec<_>>();
let mentions = collect_tool_mentions_from_messages(&messages);
let paths = user_input
    .iter()
    .filter_map(|input| match input {
        UserInput::Mention { path, .. } => Some(path.clone()),
        _ => None,
    })
    .chain(mentions.paths);
required_servers.extend(paths.filter_map(|path| {
    path.strip_prefix("mcp://")
        .filter(|server| !server.is_empty())
        .map(str::to_string)
}));

这里同时读取两种来源:文本中的工具 mention,以及结构化 UserInput::Mention。它们最终都可能影响 required MCP servers,但不应被合并成一段普通文本。

源码位置:codex-rs/core/src/session/turn.rs :: build_skills_and_plugins

rust
let extension_injection_items =
    build_extension_turn_input_items(sess, step_context, user_input, cancellation_token)
        .await?;
let mentioned_skills =
    collect_explicit_skill_mentions(user_input, skills_outcome, &connector_slug_counts);
maybe_prompt_and_install_mcp_dependencies(
    sess,
    turn_context,
    cancellation_token,
    &mentioned_skills,
    Some(sess.mcp_elicitation_reviewer()),
)
.await;

插件和 Skill 的注入发生在 Core turn 构建阶段,并且依赖当前认证、能力快照和审查器。看到协议 content 为空时,应继续读这条注入链,而不是把空数组当作 bug。

10. Hook看到的输入 ​

源码位置:codex-rs/core/src/session/turn.rs :: run_hooks_and_record_inputs、turn_user_input

rust
pub(crate) async fn run_hooks_and_record_inputs(
    sess: &Arc<Session>,
    turn_context: &Arc<TurnContext>,
    input: &[TurnInput],
    persist_context: PersistContext,
) -> bool {
    let mut blocked_input = false;
    let mut accepted_user_input = false;
    for input_item in input {
        let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await;
        if hook_outcome.should_stop {
            blocked_input = true;
            record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await;
        } else {
            if matches!(input_item, TurnInput::UserInput { content, .. } if !content.is_empty()) {
                accepted_user_input = true;
            }
            record_pending_input(
                sess,
                turn_context,
                input_item.clone(),
                hook_outcome.additional_contexts,
                persist_context,
            )
            .await;
        }
    }
    blocked_input && !accepted_user_input
}

fn turn_user_input(input: &[TurnInput]) -> Vec<UserInput> {
    input
        .iter()
        .filter_map(|item| match item {
            TurnInput::UserInput { content, .. } => Some(content.as_slice()),
            TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => None,
        })
        .flatten()
        .cloned()
        .collect()
}

hook 的阻断结果和“是否存在非空用户输入”共同决定 turn 是否停止;ResponseItem 和 agent communication 不会被 turn_user_input 当作用户输入。这个分类会影响 Skill/MCP 解析、遥测和历史记录,所以不能只看最终模型请求。

11. 历史与附件提取 ​

源码位置:codex-rs/protocol/src/items.rs :: UserMessageItem::image_urls、image_details、local_image_paths、audio_urls、local_audio_paths

rust
pub fn image_urls(&self) -> Vec<String> {
    self.content
        .iter()
        .filter_map(|c| match c {
            UserInput::Image { image_url, .. } => Some(image_url.clone()),
            _ => None,
        })
        .collect()
}

pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
    self.content
        .iter()
        .filter_map(|c| match c {
            UserInput::LocalImage { path, .. } => Some(path.clone()),
            _ => None,
        })
        .collect()
}

pub fn audio_urls(&self) -> Vec<String> {
    self.content
        .iter()
        .filter_map(|c| match c {
            UserInput::Audio { audio_url } => Some(audio_url.clone()),
            _ => None,
        })
        .collect()
}

历史 item 保留远程 URL 与本地路径的区别,因此恢复或重建 UI 时仍能知道附件来源。image_details() 还会裁掉尾部默认 detail,避免把“未显式指定默认值”误写成多余的 wire 字段。

12. 测试与边界 ​

协议测试覆盖远程图片默认 detail、音频映射、本地音频标签、不可读媒体占位、无效格式和本地图片 detail;local_media_tests 另外覆盖本地图片快照、支持的音频 MIME、无效媒体保持原值和超限文件在读取前被拒绝。

源码位置:codex-rs/protocol/src/models.rs :: serializes_image_user_input_without_tags、serializes_audio_user_input_without_tags、replaces_unreadable_local_audio_with_placeholder、local_image_user_input_preserves_requested_detail

源码位置:codex-rs/protocol/src/local_media_tests.rs :: snapshots_local_image_user_input_with_requested_detail、rejects_invalid_local_media_without_changing_user_input、rejects_oversized_local_media_without_reading_it

text
cd codex-rs
cargo test -p codex-protocol serializes_image_user_input_without_tags -- --nocapture --test-threads=1
cargo test -p codex-protocol serializes_audio_user_input_without_tags -- --nocapture --test-threads=1
cargo test -p codex-protocol snapshots_local_image_user_input_with_requested_detail -- --nocapture --test-threads=1
cargo test -p codex-protocol rejects_invalid_local_media_without_changing_user_input -- --nocapture --test-threads=1
cargo test -p codex-protocol rejects_oversized_local_media_without_reading_it -- --nocapture --test-threads=1

这些测试证明协议值到 content 的映射和本地媒体的边界处理;不能证明远端模型接受每种 MIME、插件一定可用、Skill 文件一定存在,或 hook/审批之后 turn 一定会采样。

13. 源码定位练习 ​

遇到“文本发送了但富元素消失”,先看 TextElement 的字节范围和 UserMessageItem::text_elements,再看历史或 UI 消费者是否只调用了 message()。遇到“图片不显示”,先区分 Image、LocalImage 和是否走过 snapshot_local_user_input,然后检查 ResponseInputItem::from_user_input 的占位分支。

遇到“选择 Skill 或 Mention 后模型没有对应能力”,不要只看 ResponseInputItem;沿 turn_user_input、required_mcp_servers_for_input、build_skills_and_plugins 继续追踪。遇到“输入被接受但没有新 turn”,回到 TurnInputMode 和 NotSubmittedReason,因为内容转换与 turn 路由是两个独立阶段。

读完这条链后,UserInput 的每个变体都有清晰的 owner:协议类型保存语义,Core 决定生命周期,媒体 helper 负责可移植快照,Responses 转换负责 wire content,历史 item 负责恢复和附件索引。