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
/// 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
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
/// 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
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
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
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
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
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
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
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
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 分支
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 分支
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
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
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
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
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
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 负责恢复和附件索引。
