上下文与压缩测试方法
本文收束上下文与压缩系列,面向已经读过 Context归一化算法、AutoCompact触发算法、本地Compact执行与写回 和 远程Compact-V2尝试与降级 的读者。目标不是列出所有测试名称,而是从真实测试源码建立一套可迁移的方法:如何安排输入、选择 harness、观察模型可见结果、写出不变量断言,并标记未覆盖边界。
本文覆盖四层测试:纯函数/状态单元测试、请求级集成测试、v1/v2 parity 测试、rollout resume/fork 测试。当前源码还要求测试关注 ResponseItemEnvelope metadata、WorldState snapshot 和 content kind 标注。取消、远程失败、本地失败和恢复都放在相应层次中讨论;测试不能覆盖 provider 摘要质量、真实网络延迟或所有操作系统上的行为。读完后,读者应能为一个新的 context 变更选择合适的 fixture,解释断言为何足够或不够,并把“看到一个请求”与“状态已提交”区分开。
1. 测试分层
测试层次决定可观察对象。单元测试直接调用纯函数,集成测试通过 TestCodexHarness 驱动真实 Session,parity 测试比较两个实现的规范化结果,恢复测试则把内存结果写入 rollout 后重新启动消费者。
图中“综合结论”不是把四层结果简单相加:每层都有自己的 owner 和盲区。纯函数测试不能证明 HTTP headers,parity 不能证明恢复,resume/fork 也不能证明摘要内容正确。
2. 测试入口
生产代码中的 core/src/*_tests.rs 适合快速验证数据变换;core/tests/all.rs 聚合 suite 集成测试。测试入口不是装饰:如果只运行错误的 Cargo target,得到的 0 tests 不能算验证。
源码位置:codex-rs/core/src/compact_tests.rs :: process_compacted_history_with_test_session
async fn process_compacted_history_with_test_session(
compacted_history: Vec<ResponseItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
let (session, turn_context) = crate::session::tests::make_session_and_context().await;
let turn_context = Arc::new(turn_context);
session
.set_previous_turn_settings(previous_turn_settings.cloned())
.await;
let step_context =
crate::session::step_context::StepContext::for_test(Arc::clone(&turn_context));
let world_state = Arc::new(
session
.build_world_state_for_step(&step_context)
.await
.expect("world state should build"),
);
let initial_context = session
.build_initial_context_with_world_state(&turn_context, world_state.as_ref())
.await;
let initial_context_injection = InitialContextInjection::BeforeLastUserMessage {
world_state,
step_context,
};
let (refreshed, _) = crate::compact_remote::process_compacted_history(
&session,
compacted_history,
&initial_context_injection,
)
.await;
(refreshed, initial_context)
}这个 helper 的价值在于同时返回 refreshed 和 initial_context。断言可以精确区分“远程 compact 输出被保留”和“当前 session 上下文被重新注入”,不会把两者拼在一起后只比较字符串。
3. 输入构造
测试 fixture 应先表达语义,再选择协议 item。user_message、developer_msg、function call 和 tool output 这些小构造器让每个测试只改变一个变量。
源码位置:codex-rs/core/src/compact_tests.rs :: user_message
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}输入应覆盖正常消息和容易误判的消息:session prefix、environment context、legacy warning、developer instruction、summary marker、图片、reasoning、function-call pair。不要只用三条普通 user message 证明过滤算法。
4. 纯函数
纯函数测试的第一原则是断言输出结构和边界,而不是实现细节。collect_annotated_user_messages 的 fixture 同时放入 assistant、session prefix 和真实 user,断言只得到最后一个可见 user message。
源码位置:codex-rs/core/src/compact_tests.rs :: collect_annotated_user_messages_extracts_user_text_only
let items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for project\n\n<INSTRUCTIONS>do things</INSTRUCTIONS>"
.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "real user message".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
let envelopes = items
.into_iter()
.map(ResponseItemEnvelope::new)
.collect::<Vec<_>>();
let collected = collect_annotated_user_messages(&envelopes);
assert_eq!(vec![compacted_user_message("real user message")], collected);这个断言覆盖 prefix 过滤规则,不覆盖 for_prompt 的编码、UI 投影或真实 AGENTS 文件发现。
5. 历史不变量
压缩历史测试应写出不变量:摘要在末尾、旧 developer 不进入结果、mid-turn 初始上下文位于最后真实 user 之前、summary 仍保持最后 item。测试名称只是索引,真正的教材信息在输入和断言之间。
源码位置:codex-rs/core/src/compact_tests.rs :: process_compacted_history_inserts_context_before_last_real_user_message_only
let compacted_history = vec![
user_message("older user"),
user_message(&format!("{SUMMARY_PREFIX}\nsummary text")),
user_message("latest user"),
];
let (refreshed, initial_context) = process_compacted_history_with_test_session(
compacted_history,
/*previous_turn_settings*/ None,
)
.await;
let context_end = refreshed
.iter()
.position(|item| item == initial_context.last().expect("context should not be empty"))
.expect("initial context should be inserted");
assert!(context_end < refreshed.len() - 1);测试中的具体断言还会比较完整向量;学习时要关注位置关系,而不是只关注文本。位置关系是后续模型训练假设和 continuation 行为的可验证接口。
6. 窗口边界
窗口测试必须控制模型窗口和 auto compact limit,否则输入长度与模型目录默认值会同时变化,失败时无法知道是触发器还是 fixture 本身过大。
源码位置:codex-rs/core/tests/suite/compact.rs :: snapshot_request_shape_pre_turn_compaction_context_window_exceeded
let harness = TestCodexHarness::with_builder(
test_codex().with_config(|config| {
config.model_context_window = Some(100);
config.model_auto_compact_token_limit = Some(90);
}),
)
.await?;之后测试通过 mock response 驱动初始 turn、compact 和后续请求,检查 compact 是否在 context window exceeded 前发生,且 replacement history 没有把旧超长工具输出继续带入。
这里的 OldWindow 是失败边界,不是自动恢复保证。测试只能证明本次调用没有安装 replacement;下一次 turn 是否再次触发,由新的 token 状态和触发算法决定。
7. 协议形状
请求级集成测试使用 TestCodexHarness、mock server 和 request log,观察生产代码真正发出的 JSON、headers、事件和 follow-up 请求。它比直接调用 build_* 更接近用户可见行为,但成本也更高。
源码位置:codex-rs/core/tests/suite/compact_remote.rs :: remote_compact_v2_retries_failures_with_stream_retry_budget
let responses_mock = responses::mount_response_sequence(
harness.server(),
vec![
responses::sse_response(responses::sse(vec![
responses::ev_assistant_message("m1", "FIRST_REMOTE_REPLY"),
responses::ev_completed("resp-1"),
])),
ResponseTemplate::new(500).set_body_string("first compact open failed"),
responses::sse_response(responses::sse(vec![
serde_json::json!({
"type": "response.output_item.done",
"item": { "type": "compaction", "encrypted_content": "FAILED" }
})
])),
responses::sse_response(responses::sse(vec![
serde_json::json!({
"type": "response.output_item.done",
"item": { "type": "compaction", "encrypted_content": "RETRIED" }
}),
responses::ev_completed("resp-compact-retry"),
])),
],
)
.await;测试最后检查 request 数量、每次 compact 的 trigger 和 follow-up 中只出现 RETRIED。这证明失败 attempt 的 output 被隔离,不证明服务器在第一次连接失败后没有继续计算。
8. Parity比较
compact_remote_parity.rs 把同一组 scenario 分别跑在 Legacy 和 V2 上,再对 normalized body、replacement history 和 follow-up 做比较。它不是要求 JSON 字节完全相同,而是先移除动态 ID、路径和实现专属字段,再比较语义投影。
源码位置:codex-rs/core/tests/suite/compact_remote_parity.rs :: assert_follow_up_and_history_eq
fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) {
assert_eq!(
legacy.replacement_history,
v2.replacement_history,
"{label}: replacement histories differ",
);
assert_eq!(
legacy.follow_up_body,
v2.follow_up_body,
"{label}: follow-up requests differ",
);
}parity 的输入矩阵包含 assistant-only、reasoning/image、tool mix 和 full mix,并额外覆盖 API key service tier、hook 和不同初始 history。它能发现 v1/v2 的语义漂移,但不能证明两个 endpoint 的延迟、计费或 provider 内部摘要算法相同。
9. 恢复验证
compact 的持久化测试不能只检查当前 request。compact_resume_fork.rs 会先 compact,关闭 thread,再 resume,再 fork,最后比较三个阶段的模型输入前缀。
源码位置:codex-rs/core/tests/suite/compact_resume_fork.rs :: compact_resume_and_fork_preserve_model_history_view
let compact_arr = input_after_compact
.as_array()
.expect("input after compact should be an array");
let resume_arr = input_after_resume
.as_array()
.expect("input after resume should be an array");
let fork_arr = input_after_fork
.as_array()
.expect("input after fork should be an array");
assert!(compact_arr.len() <= resume_arr.len());
assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]);
assert!(compact_arr.len() <= fork_arr.len());
assert_eq!(compact_arr.as_slice(), &fork_arr[..compact_arr.len()]);这个前缀不变量证明 replacement history 是 resume/fork 的共同 base;它不证明所有 rollout metadata 都一致,也不证明 fork 后的新 suffix 与原 thread 共享同一 live lock。
10. 失败取消
失败和取消必须分别构造。HTTP 500、stream closed before completed、多个 compaction output 属于协议/传输失败;PreCompactHookOutcome::Stopped 和 TurnAborted 属于控制面取消。前者可能进入 retry 或 error event,后者不应伪装成模型失败。
源码位置:codex-rs/core/src/compact_remote_v2.rs :: collect_compaction_output
if !saw_completed {
return Err(CodexErr::Stream(
"remote compaction v2 stream closed before response.completed".to_string(),
));
}
if compaction_count != 1 {
return Err(CodexErr::Fatal(format!(
"remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items"
)));
}同一个测试应断言:错误返回、旧 history 仍可用于后续诊断、没有完成事件或 replacement checkpoint。不要只断言 Result::Err,因为那无法证明状态没有部分提交。
11. 断言边界
每个测试说明至少包含四个字段:输入、断言、覆盖范围、未覆盖范围。下面的矩阵可作为新增测试的设计参考:
| 场景 | 输入控制 | 关键断言 | 能证明 | 不能证明 |
|---|---|---|---|---|
| 残缺工具对 | function call 无 output、或 output 无 call | normalize 后成对/丢弃规则 | history 不变量 | provider 解析 |
| 超长 user | 小 token limit + 大文本 | 截断标记、摘要末尾 | 本地预算算法 | tokenizer 精确账单 |
| 远程失败 | 500、断流、错误 output | retry 次数、旧结果丢弃 | 客户端失败隔离 | 服务器资源回收 |
| v1/v2 parity | 相同 scenario 矩阵 | normalized replacement/follow-up 相等 | 语义投影一致 | 延迟、计费、摘要质量 |
| resume/fork | compact 后关闭、恢复、分叉 | 输入前缀和 suffix | 持久化模型视图 | 所有事件顺序 |
| 取消 | pre-hook stop 或取消 token | TurnAborted、无写回 | 控制面中断边界 | provider 端取消即时性 |
这条工作流把测试写作本身变成可复现对象:没有输入控制就无法解释失败,没有结构断言就无法支持状态结论;运行命令时还要确认它确实命中了目标测试。
12. 源码练习
- 从
compact_tests.rs复制一个过滤测试,增加HookPrompt和CompactionTrigger,先写预期历史再运行测试;解释为什么“user role”不等于“真实用户输入”。 - 在 V2 截断测试中把
max_tokens降到 3,记录最新 group、attached notice 和 image-only message 的变化,验证预算是按 group 而不是按数组索引消费。 - 给 parity scenario 增加一个只有 function tool 的输入,比较 v1/v2 replacement history;若差异出现,先定位 normalized projection,再判断是否真是实现回归。
- 在 resume/fork 测试中故意删除 rollout replacement_history 字段,观察恢复器如何退回更早记录;这能区分“测试 fixture 缺字段”和“恢复代码错误”。
- 将远程 500 改成
TurnAborted模拟控制面停止,确认没有触发 fallback、window advance 或 completed event。
13. 可执行验证
在本版本源码对应的 codex-rs workspace 中运行与本文相对应的本地、请求和恢复测试。若测试被环境条件跳过,应说明跳过原因,并区分“未执行”与“断言通过”。
cd codex-rs
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib compact::tests -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --lib compact_remote_v2::tests -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all compact_resume_and_fork_preserve_model_history_view -- --nocapture
RUST_MIN_STACK=16777216 cargo test -p codex-core --test all remote_compaction_parity_manual_transcripts -- --nocapture本地单元测试验证输入输出不变量;请求级测试验证 harness 观察到的 wire shape;恢复和 parity 测试验证跨实现或跨生命周期的投影。没有一条命令单独证明完整的上下文系统正确。
