ContextFragments模型
ContextualUserFragment 的名字容易造成两个误解:它既不只生成 user 消息,也不负责全部上下文管理。 在当前实现中,它是一个很薄的传输协议:实现者声明 role、marker、body 和消息隔离要求,默认方法 负责渲染并转换成 ResponseItem。合并由 context_manager::updates 完成,去重由具体状态所有者或 WorldState snapshot 完成,UI 过滤和 rollback 清理又各有独立识别器。
本文面向已经理解 ContextItem角色语义、 ContextHistory读写 和 Context变更语义 的读者。本文分析 fragment 从来源对象到模型请求、 事件映射和回滚边界的完整路径,不重复讲每种 AGENTS、权限或插件片段的业务内容。读完后应能实现一个 新 fragment,并判断它是否需要 marker、应使用哪个 role、怎样避免重复注入,以及为什么它可能对模型 可见却不应显示成用户消息。
1. 协议边界
一个 fragment 从产生到被消费,会经过多个职责不同的层。trait 只覆盖其中第一层。
这条链解释了为什么“实现了 matches_text”不等于“自动去重”。matches_text 主要服务后续识别;是否 生成新 fragment,必须由来源状态、WorldState section 或专用 store 决定。
2. 渲染契约
trait 位于独立的 codex-context-fragments crate,而不是 core 内部。这样 extension 和 core 可以共享同一 消息协议,不必依赖整个 Session 实现。
源码位置:codex-rs/context-fragments/src/fragment.rs :: ContextualUserFragment。
/// Context payload that is injected as a message fragment.
///
/// Implementations own the response role and provide the exact fragment body.
/// Marked fragments also provide start/end markers used to recognize injected
/// context later. `render()` concatenates markers and body without adding
/// separators, so implementations should include any whitespace they need
/// between tags in `body()`. Unmarked fragments should leave both markers empty,
/// in which case the default helpers render only the body and never match
/// arbitrary text.
pub trait ContextualUserFragment {
fn role(&self) -> &'static str;
fn content_kind(&self) -> ContentItemKind;
/// Whether this fragment must be recorded as its own response item.
fn requires_separate_message(&self) -> bool {
false
}
fn markers(&self) -> (&'static str, &'static str);
fn body(&self) -> String;
fn type_markers() -> (&'static str, &'static str)
where
Self: Sized;
fn matches_text(text: &str) -> bool
where
Self: Sized,
{
let (start_marker, end_marker) = Self::type_markers();
matches_marked_text(start_marker, end_marker, text)
}
fn render_fragment(&self) -> RenderedFragment {
RenderedFragment::new(
self.role(),
AnnotatedContent::input_text(self.render(), self.content_kind()),
)
}这些方法分别回答不同问题:role 决定请求角色,content_kind 为内容附加稳定分类,markers 与 body 决定实例文本,type_markers 让静态识别器无需构造实例,requires_separate_message 决定能否与相邻同 role fragment 合并,render_fragment 则把 role、文本和分类封装为 RenderedFragment。名称中的 “User”是历史命名,实际实现可以返回 developer。
源码位置:codex-rs/context-fragments/src/fragment.rs :: render、into、into_response_input_item。
fn render(&self) -> String {
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{start_marker}{body}{end_marker}")
}
fn into(self) -> ResponseItem
where
Self: Sized,
{
ResponseItem::Message {
id: None,
role: self.role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
fn into_response_input_item(self) -> ResponseInputItem
where
Self: Sized,
{
ResponseInputItem::Message {
role: self.role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}render 不自动插入换行;片段若需要 marker 内换行,必须在 body 自己提供。into 生成可记录的 ResponseItem,into_response_input_item 则用于还没进入历史的输入管线。两者共享同一渲染结果,但生命周期 不同。
3. 标记识别
默认识别只检查首尾 marker,并容忍前后空白与 ASCII 大小写差异。
源码位置:codex-rs/context-fragments/src/fragment.rs :: matches_marked_text。
pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
if start_marker.is_empty() || end_marker.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
starts_with_marker && ends_with_marker
}空 marker 必须返回 false,否则任意普通文本都会被识别成上下文。动态 marker 不能直接使用这个默认算法, 例如 additional context 的 key 是标签名的一部分,需要覆盖 matches_text。
源码位置:codex-rs/context-fragments/src/additional_context.rs :: AdditionalContextUserFragment::matches_text。
fn matches_text(text: &str) -> bool {
let trimmed = text.trim();
let Some(rest) = trimmed.strip_prefix(ADDITIONAL_CONTEXT_START_MARKER_PREFIX) else {
return false;
};
let Some((key, value_and_close)) = rest.split_once(ADDITIONAL_CONTEXT_END_MARKER_SUFFIX)
else {
return false;
};
value_and_close.ends_with(&format!("</external_{key}>"))
}这段逻辑要求开始标签中的 key 与结束标签一致。单独的 <external_api> 没有成对结束标签,因此仍是普通 用户文本;测试专门固定了这个边界。
4. 角色分流
同一个 trait 同时承载 user 和 developer fragment。最清楚的例子是 additional context:不受信任的外部 状态使用 user role,并包裹成 <external_key>;应用可信状态使用 developer role,并保持普通 <key>。
源码位置:codex-rs/context-fragments/src/additional_context.rs :: AdditionalContextUserFragment、AdditionalContextDeveloperFragment。
impl ContextualUserFragment for AdditionalContextUserFragment {
fn role(&self) -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
ADDITIONAL_CONTEXT_START_MARKER_PREFIX,
ADDITIONAL_CONTEXT_END_MARKER_SUFFIX,
)
}
fn body(&self) -> String {
additional_context_body(&self.key, &self.value)
}
}
impl ContextualUserFragment for AdditionalContextDeveloperFragment {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
additional_context_developer_body(&self.key, &self.value)
}
}这里的 role 不是展示属性,而是模型请求中的权限层级。Application 数据进入 developer message, Untrusted 数据只能作为 user context。是否信任由协议入口的 AdditionalContextKind 决定,不能通过 key 名称猜测。
5. 消息合并
WorldState diff 可能一次产生多个 Box<dyn ContextualUserFragment>。merge_contextual_fragments 只合并 相邻、同 role、且双方都允许合并的 fragment;不同 role 或 standalone fragment 会切断分组。
源码位置:codex-rs/core/src/context_manager/updates.rs :: merge_contextual_fragments。
#[derive(Clone, Copy, PartialEq, Eq)]
enum MessageGroup {
Standalone,
Mergeable,
}
pub(crate) fn merge_contextual_fragments(
fragments: Vec<Box<dyn ContextualUserFragment>>,
) -> Vec<ResponseItem> {
let mut messages: Vec<(&str, MessageGroup, Vec<RenderedFragment>)> = Vec::with_capacity(fragments.len());
for fragment in fragments {
let group = if fragment.requires_separate_message() {
MessageGroup::Standalone
} else {
MessageGroup::Mergeable
};
let rendered = fragment.render_fragment();
let role = rendered.role();
match messages.last_mut() {
Some((previous_role, previous_group, text_sections))
if *previous_role == role
&& *previous_group == MessageGroup::Mergeable
&& group == MessageGroup::Mergeable =>
{
text_sections.push(rendered);
}
_ => messages.push((role, group, vec![rendered])),
}
}
messages
.into_iter()
.filter_map(|(role, _, text_sections)| build_text_message(role, text_sections))
.collect()
}“合并”不是把字符串连接成一个 InputText。每个 fragment 仍成为独立的 ContentItem::InputText,并保留 自己的 ContentItemKind;这些 content item 被放进同一个顶层 ResponseItem::Message,由 RenderedFragment 携带 role 与标注一起进入 builder。
源码位置:codex-rs/core/src/context_manager/updates.rs :: build_text_message。
fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<ResponseItem> {
if text_sections.is_empty() {
return None;
}
let content = text_sections
.into_iter()
.map(|text| ContentItem::InputText { text })
.collect();
Some(ResponseItem::Message {
id: None,
role: role.to_string(),
content,
phase: None,
internal_chat_message_metadata_passthrough: None,
})
}ModelSwitchInstructions、PersonalitySpecInstructions 和 ImageResizeNotice 等实现会返回 requires_separate_message() == true。这保证模型切换、人格变更或图像缩放说明拥有独立消息边界,而不是 被挤进相邻设置更新。
6. 初始拼装
full context 构造不是简单调用 merge_contextual_fragments。它还处理模型切换必须前置、无 marker 的 standalone developer section、MultiAgent mode 独立 item,以及 Guardian policy 隔离等顺序约束。
源码位置:codex-rs/core/src/session/mod.rs :: build_initial_context_with_world_state。
// Render the active mode after the usage hint so it can override that hint.
let mut initial_multi_agent_mode = None;
for fragment in world_state.render_full() {
match fragment.role() {
"developer"
if fragment.markers().0 == ModelSwitchInstructions::type_markers().0 =>
{
// New-model instructions must precede the rest of the developer context.
developer_sections.insert(0, fragment.render());
}
"developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => {
initial_multi_agent_mode = Some(fragment);
}
"developer"
if fragment.requires_separate_message() && fragment.markers().0.is_empty() =>
{
separate_developer_sections.push(fragment.render());
}
"developer" => developer_sections.push(fragment.render()),
"user" => contextual_user_sections.push(fragment.render()),
_ => {}
}
}
let mut items = Vec::with_capacity(4);
if let Some(developer_message) =
crate::context_manager::updates::build_developer_update_item(developer_sections)
{
items.push(developer_message);
}
for section in separate_developer_sections {
if let Some(developer_message) =
crate::context_manager::updates::build_developer_update_item(vec![section])
{
items.push(developer_message);
}
}
if let Some(initial_multi_agent_mode) = initial_multi_agent_mode {
items.push(initial_multi_agent_mode.into_boxed_response_item());
}
if let Some(contextual_user_message) =
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
{
items.push(contextual_user_message);
}因此 fragment 在 full context 和 steady-state diff 中可能经过不同的分组入口,但最终都变成有明确 role 和 content 边界的 ResponseItem。新窗口或 compaction 会把 full context items 直接安装进 replacement history,所以构造结束还会为缺少 turn ID 的 item 补上当前 sub_id。
7. 状态去重
trait 没有缓存,也不知道“上一轮发过什么”。WorldState fragment 的去重发生在 section snapshot 与 render_diff 之间。每个 section 只把比较所需字段写入 snapshot,并根据 Absent、Unknown 或 Known 决定是否生成 fragment。
源码位置:codex-rs/core/src/context/world_state/mod.rs :: WorldStateSection、PreviousSectionState。
/// What is known about a section's previously model-visible state.
pub(crate) enum PreviousSectionState<'a, T> {
/// No persisted snapshot or matching fragment exists in retained history.
Absent,
/// Retained history contains the section, but its typed snapshot is unavailable.
Unknown,
/// The exact persisted snapshot is available.
Known(&'a T),
}
pub(crate) trait WorldStateSection: Send + Sync + 'static {
const ID: &'static str;
type Snapshot: DeserializeOwned + Serialize;
fn snapshot(&self) -> Self::Snapshot;
fn should_persist(&self) -> bool {
true
}
fn matches_legacy_fragment(_role: &str, _text: &str) -> bool {
false
}
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn has_retained_fragment_matcher() -> bool {
false
}
fn matches_retained_fragment(_role: &str, _text: &str) -> bool {
false
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>>;
}有 snapshot 时使用精确比较;只有旧历史没有 typed snapshot 时,才通过 legacy marker 判断为 Unknown。 如果 section 声明历史中的 fragment 必须仍然存在,snapshot 存在但 fragment 已被裁掉时,会退回 Absent 并重新注入。
源码位置:codex-rs/core/src/context/world_state/mod.rs :: render_history_diff。
pub(crate) fn render_history_diff(
&self,
previous: Option<&WorldStateSnapshot>,
items: &[ResponseItem],
) -> Vec<Box<dyn ContextualUserFragment>> {
self.render_with(|id, section| {
if let Some(previous) = previous.and_then(|previous| previous.sections.get(id)) {
if section.has_retained_fragment_matcher() && !has_retained_fragment(items, section)
{
PreviousSectionState::Absent
} else {
PreviousSectionState::Known(previous)
}
} else if has_legacy_fragment(items, section) {
PreviousSectionState::Unknown
} else {
PreviousSectionState::Absent
}
})
}steady-state 的完整顺序是:构造新 WorldState → 对前一 snapshot 生成 fragment → 合并 fragment → 写入历史 → 更新 baseline → 持久化 merge patch。patch 在模型可见历史之后记录,避免 rollout 声称状态已经推进, 但对应上下文还没有写入。
源码位置:codex-rs/core/src/session/mod.rs :: record_step_world_state_if_changed。
let world_state = Arc::new(self.build_world_state_for_step(step_context).await?);
let previous_snapshot = previous_world_state.snapshot();
let world_state_snapshot = world_state.snapshot();
let world_state_item = world_state_snapshot
.merge_patch_from(&previous_snapshot)
.map(WorldStateItem::patch);
let items = crate::context_manager::updates::merge_contextual_fragments(
world_state.render_diff(&previous_snapshot),
);
if !items.is_empty() {
self.record_conversation_items(turn_context, &items).await;
}
self.state
.lock()
.await
.history
.set_world_state_baseline(world_state_snapshot);
if let Some(world_state_item) = world_state_item {
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
.await;
}8. 外部上下文
Additional context 没有进入 WorldState,而是由 AdditionalContextStore 按 key 和完整 entry 去重。新输入 只为新增或变化的键生成 fragment;随后 store 用本次完整 map 替换旧值,因此移除的键不会生成“删除 fragment”,只是后续不再新增该值。
源码位置:codex-rs/core/src/state/additional_context.rs :: AdditionalContextStore::merge。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AdditionalContextStore {
values: BTreeMap<String, AdditionalContextEntry>,
}
impl AdditionalContextStore {
pub(crate) fn merge(
&mut self,
values: BTreeMap<String, AdditionalContextEntry>,
) -> Vec<ResponseInputItem> {
let fragments = values
.iter()
.filter(|(key, value)| self.values.get(*key) != Some(*value))
.map(|(key, entry)| match entry.kind {
AdditionalContextKind::Untrusted => {
AdditionalContextUserFragment::new(key.clone(), entry.value.clone())
.into_response_input_item()
}
AdditionalContextKind::Application => {
AdditionalContextDeveloperFragment::new(key.clone(), entry.value.clone())
.into_response_input_item()
}
})
.collect();
self.values = values;
fragments
}
}无论新 Turn 还是 steer,都先调用同一个 store,再把产生的 ResponseInputItem 转成 pending input,并放在 真实用户输入之前。相同 entry 在连续 Turn 中不会再次插入,但第一次写入的历史条目仍会随历史保留,所以 模型继续看得到它。
9. 展示过滤
模型可见不等于 UI 应把它显示为用户发言。user fragment 使用显式 matcher 注册表;只有已知 fragment 或 合法 hook prompt 会被判定为 contextual user content。
源码位置:codex-rs/core/src/context/contextual_user_message.rs :: matcher registry、is_contextual_user_fragment。
const CONTEXTUAL_USER_FRAGMENT_MATCHERS: &[fn(&str) -> bool] = &[
UserInstructions::matches_text,
EnvironmentsState::matches_text,
AdditionalContextUserFragment::matches_text,
SkillInstructions::matches_text,
UserShellCommand::matches_text,
TurnAborted::matches_text,
SubagentNotification::matches_text,
InternalModelContextFragment::matches_text,
RecommendedPluginsInstructions::matches_text,
LegacyUnifiedExecProcessLimitWarning::matches_text,
LegacyApplyPatchExecCommandWarning::matches_text,
LegacyModelMismatchWarning::matches_text,
];
fn is_standard_contextual_user_text(text: &str) -> bool {
CONTEXTUAL_USER_FRAGMENT_MATCHERS
.iter()
.any(|matches_text| matches_text(text))
}
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
let ContentItem::InputText { text } = content_item else {
return false;
};
parse_hook_prompt_fragment(text).is_some() || is_standard_contextual_user_text(text)
}注册表是保守的:任意 <project_context> 不会因为“长得像 XML”就被隐藏。新增 user-role fragment 如果 需要从普通 TurnItem::UserMessage 中排除,必须同步加入 matcher;否则模型仍能看到它,但 UI 会误认为 这是用户输入。
源码位置:codex-rs/core/src/event_mapping.rs :: parse_user_message。
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
if is_contextual_user_message_content(message) {
return None;
}
let mut content: Vec<UserInput> = Vec::new();
for (idx, content_item) in message.iter().enumerate() {
match content_item {
ContentItem::InputText { text } => {
let is_image_label = ((is_local_image_open_tag_text(text)
|| is_image_open_tag_text(text))
&& matches!(message.get(idx + 1), Some(ContentItem::InputImage { .. })))
|| (idx > 0
&& (is_local_image_close_tag_text(text) || is_image_close_tag_text(text))
&& matches!(message.get(idx - 1), Some(ContentItem::InputImage { .. })));
let is_audio_label = ((is_local_audio_open_tag_text(text)
|| is_audio_open_tag_text(text))
&& matches!(message.get(idx + 1), Some(ContentItem::InputAudio { .. })))
|| (idx > 0
&& (is_local_audio_close_tag_text(text) || is_audio_close_tag_text(text))
&& matches!(message.get(idx - 1), Some(ContentItem::InputAudio { .. })));
if is_image_label || is_audio_label {
continue;
}只要一个 message 的任一 content item 被识别为 contextual user fragment,整个 message 就不解析成普通 user item。这也是合并边界必须谨慎的原因:不能把真实用户输入和隐藏 fragment 随意塞进同一消息。
10. Hook例外
Hook prompt 同样不作为普通用户消息展示,但它不是完全隐藏。解析器只允许一个 message 包含合法 hook fragment 和其他已知 contextual fragment;出现任意普通文本就拒绝整体解析。
源码位置:codex-rs/core/src/context/contextual_user_message.rs :: parse_visible_hook_prompt_message。
pub(crate) fn parse_visible_hook_prompt_message(
id: Option<&str>,
content: &[ContentItem],
) -> Option<HookPromptItem> {
let mut fragments = Vec::new();
for content_item in content {
let ContentItem::InputText { text } = content_item else {
return None;
};
if let Some(fragment) = parse_hook_prompt_fragment(text) {
fragments.push(fragment);
continue;
}
if is_standard_contextual_user_text(text) {
continue;
}
return None;
}
if fragments.is_empty() {
return None;
}
Some(HookPromptItem::from_fragments(id, fragments))
}因此 Hook prompt 是“可见的结构化 TurnItem”,其他 contextual user fragment 是“模型可见、事件层隐藏”的 脚手架。两者共用识别入口,却有不同展示结果。
11. 回滚清理
rollback 按普通用户 Turn 切历史时,还要向前删除紧贴该 Turn 的上下文更新,否则撤销了用户输入却保留 了专属于该输入的权限、环境或外部上下文。developer 和 user fragment 使用两套识别器。
源码位置:codex-rs/core/src/context_manager/history.rs :: trim_pre_turn_context_updates。
fn trim_pre_turn_context_updates(
&mut self,
snapshot: &[ResponseItem],
first_instruction_turn_idx: usize,
mut cut_idx: usize,
) -> usize {
while cut_idx > first_instruction_turn_idx {
match &snapshot[cut_idx - 1] {
ResponseItem::Message { role, content, .. }
if role == "developer" && is_contextual_dev_message_content(content) =>
{
if has_non_contextual_dev_message_content(content) {
// Mixed `build_initial_context` bundles are not reconstructible from
// steady-state diffs once trimmed, so the next real turn must fully
// reinject context instead of diffing against a stale baseline.
self.reference_context_item = None;
}
cut_idx -= 1;
}
ResponseItem::Message { role, content, .. }
if role == "user" && is_contextual_user_message_content(content) =>
{
cut_idx -= 1;
}
_ => break,
}
}
cut_idx
}最关键的失败恢复是 mixed developer bundle:初始 context 可能把可回滚 fragment 和持久 developer 文本 放在同一 message。整体删除后无法从 steady-state diff 重建其中的持久部分,所以必须把 reference_context_item 清空,让下一真实 Turn 执行 full reinjection。
developer 识别不是调用所有 trait matcher,而是维护 rollback 可删除的稳定前缀集合。这包括权限、模型 切换、apps、协作模式、工具、token/window 和 rollout budget 等;没有列入集合的 developer 文本被视为 非 contextual 内容。
源码位置:codex-rs/core/src/event_mapping.rs :: CONTEXTUAL_DEVELOPER_PREFIXES、is_contextual_dev_fragment。
const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
"<permissions instructions>",
APPROVED_COMMAND_PREFIX_SAVED_MESSAGE_PREFIX,
"<model_switch>",
APPS_INSTRUCTIONS_OPEN_TAG,
COLLABORATION_MODE_OPEN_TAG,
MULTI_AGENT_MODE_OPEN_TAG,
ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG,
"<git_attribution>",
PLUGINS_INSTRUCTIONS_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
TOOLS_OPEN_TAG,
"<personality_spec>",
// Keep recognizing token-budget wrappers persisted by older versions.
"<token_budget>",
CONTEXT_WINDOW_OPEN_TAG,
CONTEXT_WINDOW_GUIDANCE_OPEN_TAG,
"<rollout_budget>",
];
fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool {
let ContentItem::InputText { text } = content_item else {
return false;
};
let trimmed = text.trim_start();
CONTEXTUAL_DEVELOPER_PREFIXES.iter().any(|prefix| {
trimmed
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
})
}12. 扩展入口
extension contributor 返回的是 Box<dyn ContextualUserFragment>,Session 使用 object-safe 的 into_boxed_response_item 转换。这说明扩展可以贡献新类型,却仍必须服从 role、marker、消息边界和后续 识别规则。
源码位置:codex-rs/core/src/session/turn.rs :: build_turn_context_contribution_items。
let mut items = Vec::new();
for contributor in contributors {
let contributed_fragments = contributor
.contribute(
input.clone(),
Some(Arc::clone(&extension_metrics)),
&sess.services.session_extension_data,
&sess.services.thread_extension_data,
turn_context.extension_data.as_ref(),
)
.or_cancel(cancellation_token)
.await
.ok()?;
items.extend(
contributed_fragments
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item),
);
}
Some(items)如果扩展使用 user role 且希望 UI 隐藏,就需要 core 认识它的 marker,或使用已有的 InternalModelContextFragment。后者限制 source 为 [a-z][a-z0-9_]*,避免把未转义属性注入 marker, 同时保留可追踪的来源标签。
13. Fragment测试
contextual_user_message_tests 覆盖三组反向边界:已知环境、AGENTS、子代理和 internal context 能被识别; 任意 <project_context> 不能被隐藏;非法大写 internal source 也不能伪装成内部上下文。Hook roundtrip 测试 使用包含引号、& 和尖括号的文本,断言解析后恢复原值。它证明 marker 识别与转义往返,不证明 fragment 正文可信。
additional_context_is_model_visible_but_not_a_user_message_item 输入一个 Untrusted browser entry、一个 Application automation entry 和真实用户文本,断言模型请求分别出现 user-role <external_browser_info>、developer-role <automation_info>,事件中的 UserMessage 只保留真实输入。 external_context_like_user_text_remains_a_user_message_item 则输入未闭合的 <external_api>,证明前缀相似 不足以触发隐藏。
additional_context_is_deduplicated_between_turns_while_retained 连续两 Turn 提交相同 map,断言第二次请求 只有第一次的 context history,没有新增重复 fragment。它证明 store 的 entry 去重与历史保留,不证明旧值 会被物理删除;历史清理仍由 compaction、rollback 等机制决定。
drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles 构造包含 contextual 权限和 持久 plugin 文本的混合 developer message,回滚后断言 message 被删除且 reference_context_item 变为 None。这证明下一 Turn 必须 full reinjection,避免对已经不存在的 bundle 做增量 diff。
14. 接入清单
实现新 fragment 时,可以沿下面的实际依赖顺序检查:
- 在
ContextualUserFragment实现中选择 role、marker、body 和是否 standalone。 - 确认来源状态在哪里比较旧值;不要把去重塞进无状态的
render。 - 若属于 WorldState,实现稳定 section ID、最小 snapshot 和三态
render_diff。 - 若是 user-role 隐藏上下文,补充 matcher 和事件映射测试。
- 若应随 rollback 删除,补充 developer prefix 或 user matcher,并测试 mixed bundle 恢复。
- 用真实请求断言 role、消息顺序、重复 Turn 和删除/回滚行为。
可以从以下只读搜索开始复核这条链:
rg -n "ContextualUserFragment|merge_contextual_fragments|render_history_diff|AdditionalContextStore" \
codex-rs/context-fragments codex-rs/core/src/context codex-rs/core/src/context_manager \
codex-rs/core/src/state如果一个新 fragment 只通过了 render() 单元测试,还不能认为接入完成。至少还要证明它进入正确 role、 遵守消息隔离、不会在稳定状态重复、不会冒充真实用户输入,并在 rollback 或历史缺失后恢复到正确基线。
