From ebc5a5e2642bad4a48798df79bd8b7e59252ef28 Mon Sep 17 00:00:00 2001 From: GGGBB <49966523+GGGBB@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:55:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=96=B0=E5=A2=9E=20opt-in?= =?UTF-8?q?=E3=80=8Cprompt=20=E5=89=8D=E7=BC=80=E7=A8=B3=E5=AE=9A=E5=B1=82?= =?UTF-8?q?=E3=80=8D=EF=BC=8C=E6=A0=B9=E6=B2=BB=E9=80=90=E8=BD=AE=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=9D=97=E9=87=8D=E5=86=99=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E6=B7=B1=E7=BC=93=E5=AD=98=E5=89=8D=E7=BC=80=E5=88=86=E5=8F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit per-profile 开关 stabilizePromptPrefix(serde 与 standardOpenaiProtocol 同策略, 默认关=转发逐字节不变,有快照测试守住)。开启后对发往上游的 body 做: - 剥离成对 image_resize_notice(整条删除/夹心保留前后文/骨架消息清空内容); - system 头部块规范序重排(文本段保相对序在前、具名块字典序钉尾;含具名块时 文本段空白按块间分隔归一,无具名块纯文本段逐字节透传;确定性+幂等); - 轮次标记 只钉在 user 收尾的请求上,工具收尾只幂等解包; - 附件轮样板归一 + open_page 块内容钉定;时区锚块同名折叠(dedup-last); - 压缩轮回填会话 tools(缓存防毒化门:tools:[] 永不写回)+ 前缀对齐 (514-msg 流式压缩按指令签名检出,搬离 system 区指令复制件); - system 区 turn_aborted 注入块摘除,改挂末条 user 前(语义即瞬态提示); - 回放 web_search_call 历史项(query/sources 透传;被剥离时如实告知), 杜绝多 Agent 会话失忆重搜与空 FINAL; - 前缀分叉探针(已见指纹集合模式,多 Agent 共键乒乓静默), 基线落诊断日志私有目录并强制 0600,不再写 world-writable /tmp。 取证背景见 issue #2448。本提交为 rebase 到 main@v1.7.7 的 squash(原分支 15 个提交,含维护者三条 Request changes 修复:轮次标记仅 user 收尾钉定、 基线迁私有目录 0600、规范序空白语义分桶)。 全量 cargo test -p codex-plus-core:1746 passed / 0 failed。 --- crates/codex-plus-core/src/ccs_import.rs | 1 + .../codex-plus-core/src/compaction_align.rs | 607 ++++++++++ .../codex-plus-core/src/compaction_tools.rs | 109 ++ crates/codex-plus-core/src/lib.rs | 3 + .../codex-plus-core/src/prefix_divergence.rs | 318 +++++ crates/codex-plus-core/src/protocol_proxy.rs | 1079 ++++++++++++++++- crates/codex-plus-core/src/provider_import.rs | 1 + crates/codex-plus-core/src/settings.rs | 41 + crates/codex-plus-core/tests/launcher.rs | 1 + .../codex-plus-core/tests/protocol_proxy.rs | 809 +++++++++++- 10 files changed, 2958 insertions(+), 11 deletions(-) create mode 100644 crates/codex-plus-core/src/compaction_align.rs create mode 100644 crates/codex-plus-core/src/compaction_tools.rs create mode 100644 crates/codex-plus-core/src/prefix_divergence.rs diff --git a/crates/codex-plus-core/src/ccs_import.rs b/crates/codex-plus-core/src/ccs_import.rs index cc5b78b41..fc9d8ef0f 100644 --- a/crates/codex-plus-core/src/ccs_import.rs +++ b/crates/codex-plus-core/src/ccs_import.rs @@ -173,6 +173,7 @@ pub fn relay_profile_from_ccs( custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, } } diff --git a/crates/codex-plus-core/src/compaction_align.rs b/crates/codex-plus-core/src/compaction_align.rs new file mode 100644 index 000000000..e742264c9 --- /dev/null +++ b/crates/codex-plus-core/src/compaction_align.rs @@ -0,0 +1,607 @@ +// 压缩轮「前缀对齐」(「prompt 前缀稳定层」的组成部分,opt-in 同开关)。 +// +// 背景(取证定案,见 /tmp/compact_prefix_align_heartbeat.log 与 COMPACT_PREFILL.md): +// codex++ 客户端的自动压缩(auto compact)不走 /responses/compact,也不带 +// compaction_trigger 控制项,而是把摘要指令原样追加在 input 末尾(user 角色) +// 并发送 `tools: []`。既有 compact 判定(路径 / compaction_trigger)对这一形态 +// 双双落空:回填与改写全不触发,tools 由整块变空使 token 前缀在 chat 模板的 +// tools 序列化区(第 0 块)分叉,压缩轮整段冷算(实测 216130 tokens @625tok/s +// ≈ 5.7 分钟),还把 tools=[] 毒化进 compaction_tools 的回填缓存。 +// +// 本模块在 ChatCompletions 转换层入口对该请求体做「前缀对齐」: +// 1) 摘要指令必须唯一且落在消息序列末尾(user 角色):system 区 +// (顶层 instructions / system|developer 输入项 / 首位 user 项)里的指令 +// 实例搬到末尾,并携带客户端原始字节; +// 2) tools 缺失或为空时回填主会话最近一轮的 tools 字节(同 prompt_cache_key, +// 序列化逐字节一致,前缀纯追加); +// 3) 除指令实例与 tools 外的一切字节不动;历史中段散落的指令实例(上一轮 +// 压缩被中断的残留)属于既有前缀,绝不移动; +// 4) 压缩轮本体不得写 remember_tools 缓存(本模块检出即由调用方跳过记录); +// 5) 响应封装不动:客户端把压缩摘要当普通回合收下并自行截断历史。 +use crate::compaction_tools; +use serde_json::{json, Value}; + +/// 压缩摘要指令的首行签名:codex prompts/templates/compact/prompt.md 与 codex++ +/// 客户端均以该句起头(客户端文本比 relay 的 COMPACTION_SUMMARY_INSTRUCTION 常 +/// 量多一个行尾换行,故一律用签名前缀匹配并保留客户端原始字节, +/// 绝不做等值替换)。 +pub const COMPACTION_INSTRUCTION_SIGNATURE: &str = + "You are performing a CONTEXT CHECKPOINT COMPACTION"; +/// 指令体的收尾句:用于在「指令混进大块文本」时圈定字节区域;找不到收尾句 +/// 就不删(宁可保留分叉,也不腐蚀 system 正文)。 +const COMPACTION_INSTRUCTION_TAIL: &str = "seamlessly continue the work."; +/// 补挂末尾指令时的兜底文本:仅当所有检出实例的原文都不可用时才会使用 +//(正常路径都携带客户端原始字节)。与 protocol_proxy 的 +/// COMPACTION_SUMMARY_INSTRUCTION 保持同文(openai/codex prompts/templates/compact/prompt.md)。 +const FALLBACK_INSTRUCTION_TEXT: &str = "You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.\n\nInclude:\n- Current progress and key decisions made\n- Important context, constraints, or user preferences\n- What remains to be done (clear next steps)\n- Any critical data, examples, or references needed to continue\n\nBe concise, structured, and focused on helping the next LLM seamlessly continue the work."; + +/// 前缀对齐统计,供诊断事件 protocol_proxy.prefix_stabilizer_compaction_appended。 +#[derive(Debug, Default, Clone, Copy)] +pub struct CompactionAlignReport { + /// 检出的指令实例总数 = moved + 末尾原位保留(0/1)。 + pub instances: usize, + /// 搬离原位的实例数(顶层 instructions / system|developer 项 / 首位 user 项)。 + pub moved: usize, + /// 末尾补挂 user 指令条数(0/1)。 + pub appended: usize, + /// 是否回填了主会话 tools 字节。 + pub tools_backfilled: bool, + /// system 区混排文本里找到签名但圈不出完整区域:放弃删改(只回填 tools)。 + pub skipped_mixed: bool, +} + +fn starts_signature(text: &str) -> bool { + text.trim_start().starts_with(COMPACTION_INSTRUCTION_SIGNATURE) +} + +/// 文本里是否存在「行首即签名」的行:这是指令实例的锚点特征。 +/// 行中提及(如日志引用)不算,避免把普通轮误判成压缩轮。 +fn has_line_anchored_signature(text: &str) -> bool { + text.lines().any(|line| line.trim_start().starts_with(COMPACTION_INSTRUCTION_SIGNATURE)) +} + +fn find_newline(haystack: &[u8], from: usize) -> Option { + haystack[from..] + .iter() + .position(|byte| *byte == b'\n') + .map(|offset| offset + from) +} + +/// 在一段文本里圈定指令的字节区域:从签名所在行的行首起,逐行向下扫到包含 +/// 收尾句的那一行为止,并顺带吞掉紧随其后的一个换行,避免给 system 留空行。 +/// 找不到签名或收尾句返回 None(放弃删改)。 +fn find_instruction_span(text: &str) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let mut line_start = 0_usize; + loop { + let line_end = find_newline(bytes, line_start).unwrap_or(bytes.len()); + if text[line_start..line_end] + .trim_start() + .starts_with(COMPACTION_INSTRUCTION_SIGNATURE) + { + let mut end = line_end; + loop { + if text[line_start..end].contains(COMPACTION_INSTRUCTION_TAIL) { + let end = if bytes.get(end) == Some(&b'\n') { end + 1 } else { end }; + return Some((line_start, end)); + } + if end >= bytes.len() { + return None; + } + end = find_newline(bytes, end + 1).unwrap_or(bytes.len()); + } + } + if line_end >= bytes.len() { + return None; + } + line_start = line_end + 1; + } +} + +/// 对单个字符串执行指令切除。返回 (摘出的指令原文, 切除后的剩余文本, 是否 +/// 「见签名但圈不出区域」)。剩余文本为 None 表示整段应删除。 +fn excise_text(text: &str) -> (Option, Option, bool) { + match find_instruction_span(text) { + Some((start, end)) => { + let removed = text[start..end].to_string(); + let rest = format!("{}{}", &text[..start], &text[end..]); + ( + Some(removed), + if rest.trim().is_empty() { + None + } else { + Some(rest) + }, + false, + ) + } + None => ( + None, + Some(text.to_string()), + has_line_anchored_signature(text), + ), + } +} + +/// 对 content(字符串或分片数组)执行切除,返回 (切除后新值, 首个摘出文本, +/// 是否遇到无法圈定的混排签名, 是否有切除)。新值为 None 表示整段应删除。 +fn excise_content(content: &Value) -> (Option, Option, bool, bool) { + let mut removed_first: Option = None; + let mut skipped = false; + match content { + Value::String(text) => { + let (removed, rest, without_tail) = excise_text(text); + if without_tail { + skipped = true; + } + let cut = removed.is_some(); + (rest.map(|text| json!(text)), removed, skipped, cut) + } + Value::Array(parts) => { + let mut kept: Vec = Vec::with_capacity(parts.len()); + for part in parts { + let Some(text) = part.get("text").and_then(Value::as_str) else { + kept.push(part.clone()); + continue; + }; + let (removed, rest, without_tail) = excise_text(text); + if without_tail { + skipped = true; + kept.push(part.clone()); + continue; + } + let Some(removed) = removed else { + kept.push(part.clone()); + continue; + }; + removed_first.get_or_insert(removed); + if let Some(rest) = rest { + let mut residue = part.clone(); + residue["text"] = json!(rest); + kept.push(residue); + } + } + let cut = removed_first.is_some(); + if kept.is_empty() { + (None, removed_first, skipped, cut) + } else { + (Some(json!(kept)), removed_first, skipped, cut) + } + } + _ => (Some(content.clone()), None, false, false), + } +} + +/// content 是否以指令签名起头(整条 content 就是指令实例的形态)。 +fn content_starts_signature(content: Option<&Value>) -> bool { + match content { + Some(Value::String(text)) => starts_signature(text), + Some(Value::Array(parts)) => parts + .first() + .and_then(|part| part.get("text").and_then(Value::as_str)) + .is_some_and(starts_signature), + _ => false, + } +} + +fn content_may_contain_instruction(content: Option<&Value>) -> bool { + match content { + Some(Value::String(text)) => has_line_anchored_signature(text), + Some(Value::Array(parts)) => parts.iter().any(|part| { + part.get("text") + .and_then(Value::as_str) + .is_some_and(has_line_anchored_signature) + }), + _ => false, + } +} + +fn is_message_item(item: &Value) -> bool { + item.get("type").and_then(Value::as_str) == Some("message") +} + +fn item_role(item: &Value) -> &str { + item.get("role").and_then(Value::as_str).unwrap_or("") +} + +fn first_text_of(parts: &[Value]) -> String { + parts + .first() + .and_then(|part| part.get("text").and_then(Value::as_str)) + .unwrap_or("") + .to_string() +} + +/// 整项即指令的消息项:返回指令原文(字符串 content 或分片数组单片且整片即指令)。 +/// 分片数组要求恰有一片文本,混排项走 excise 通道,不在此列。 +fn whole_item_instruction_text(item: &Value) -> Option { + match item.get("content") { + Some(Value::String(text)) if starts_signature(text.trim()) => Some(text.clone()), + Some(Value::Array(parts)) if parts.len() == 1 => { + let text = parts[0].get("text").and_then(Value::as_str)?; + starts_signature(text).then(|| text.to_string()) + } + _ => None, + } +} + +fn tail_instruction_item(text: &str) -> Value { + json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": text }] + }) +} + +/// 末尾项是否恰为一条 user 角色、整项即指令的消息。 +fn tail_is_user_instruction(items: &[Value]) -> bool { + items.last().is_some_and(|item| { + is_message_item(item) && item_role(item) == "user" + && whole_item_instruction_text(item).is_some() + }) +} + +/// 压缩轮「前缀对齐」入口。未检出任何指令实例时返回 None(请求体原样放行, +/// 调用方照常走 remember_tools)。检出实例后: +/// - system 区(顶层 instructions / system|developer 输入项)与首位 user 项里的 +/// 指令实例搬离原位,历史其余项与字节一律不动; +/// - 保证末尾恰有一条 user 指令(缺则补挂,携带客户端原始字节); +/// - tools 缺失或空数组时回填主会话最近一轮 tools 字节(逐字节一致)。 +pub fn align_compaction_request(request: &Value) -> Option<(Value, CompactionAlignReport)> { + let mut report = CompactionAlignReport::default(); + let mut aligned = request.clone(); + let mut moved_text: Option = None; + + // 1) 顶层 instructions(字符串或分片数组)里混入的指令实例。 + if let Some(instructions) = aligned.get("instructions").cloned() { + let (rebuilt, removed, skipped, cut) = excise_content(&instructions); + if cut { + let replacement = rebuilt.unwrap_or_else(|| { + if instructions.is_string() { + json!("") + } else { + json!([]) + } + }); + aligned["instructions"] = replacement; + if let Some(text) = removed { + report.moved += 1; + moved_text.get_or_insert(text); + } + } + if skipped { + report.skipped_mixed = true; + } + } + + // 2) input 项。 + if let Some(items) = aligned.get_mut("input").and_then(Value::as_array_mut) { + if !items.is_empty() { + // 2a) system|developer 输入项(含末尾):整项即指令则整项摘除; + // 混排文本按字节区域切除、其余字节原样保留。 + let mut cursor = 0_usize; + while cursor < items.len() { + let item = &items[cursor]; + if is_message_item(item) + && matches!(item_role(item), "system" | "developer") + && content_may_contain_instruction(item.get("content")) + { + let content = item.get("content").cloned().unwrap_or(Value::Null); + let (rebuilt, removed, skipped, cut) = excise_content(&content); + if skipped { + report.skipped_mixed = true; + } + if cut { + report.moved += 1; + if let Some(text) = removed { + moved_text.get_or_insert(text); + } + match rebuilt { + Some(value) => items[cursor]["content"] = value, + None => { + items.remove(cursor); + continue; + } + } + } + } + cursor += 1; + } + + // 2b) 首位 user 指令实例(「指令进消息头部」形态):整项摘除, + // 改由末尾补挂。历史中段(下标 ≥1)的实例属于既有前缀,不动。 + if items.len() >= 2 { + let head_text = items + .first() + .filter(|item| is_message_item(item) && item_role(item) == "user") + .and_then(whole_item_instruction_text); + if let Some(text) = head_text { + items.remove(0); + moved_text.get_or_insert(text); + report.moved += 1; + } + } + + // 2c) 末尾保证恰有一条 user 指令:原位已有则字节不动;有搬移且末尾 + // 缺失时补挂一条(优先客户端原始字节,其次 relay 兜底文本)。 + let tail_present = tail_is_user_instruction(items); + if report.moved > 0 && !tail_present { + let text = moved_text + .clone() + .unwrap_or_else(|| FALLBACK_INSTRUCTION_TEXT.to_string()); + items.push(tail_instruction_item(&text)); + report.appended = 1; + } + if tail_is_user_instruction(items) { + report.instances += 1; + } + } + } + + if report.moved == 0 && report.instances == 0 && !report.skipped_mixed { + return None; + } + report.instances += report.moved; + + // 3) tools 缺失或空数组:回填主会话最近一轮 tools 字节(逐字节一致)。 + let tools_empty = match aligned.get("tools") { + None => true, + Some(Value::Array(tools)) => tools.is_empty(), + Some(_) => false, + }; + if tools_empty { + if let Some(tools) = compaction_tools::preserved_tools(request) { + aligned["tools"] = tools; + report.tools_backfilled = true; + } + } + + Some((aligned, report)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn instruction_text() -> String { + // 与 codex++ 客户端语料(/tmp/compact_forensics seq=3)一致:含行尾换行。 + format!("{FALLBACK_INSTRUCTION_TEXT}\n") + } + + fn base_tools() -> Value { + json!([{"type": "function", "name": "exec_command", "parameters": {"type": "object"}}]) + } + + fn message(role: &str, text: &str) -> Value { + json!({ "type": "message", "role": role, "content": [{ "type": "input_text", "text": text }] }) + } + + fn remember(key: &str) { + compaction_tools::remember_tools( + &json!({"prompt_cache_key": key, "tools": base_tools()}), + ); + } + + #[test] + fn tail_user_instruction_backfills_tools_without_touching_history() { + // 现网 514-msg 形态:指令在末尾 user,tools 为空数组。 + let key = "align-thread-1"; + remember(key); + let mut compaction = json!({ + "prompt_cache_key": key, + "tools": base_tools(), + "input": [message("developer", "base system"), message("user", "hi")], + }); + compaction["tools"] = json!([]); + compaction["input"] + .as_array_mut() + .unwrap() + .push(message("user", &instruction_text())); + let history_before = compaction["input"].clone(); + let (aligned, report) = align_compaction_request(&compaction).expect("必须检出"); + assert_eq!(report.moved, 0, "末尾原位不搬运"); + assert_eq!(report.appended, 0); + assert_eq!(report.instances, 1); + assert!(report.tools_backfilled); + // 历史逐项逐字节一致,仅 tools 被回填。 + assert_eq!(aligned["input"], history_before); + assert_eq!( + serde_json::to_string(&aligned["tools"]).unwrap(), + serde_json::to_string(&base_tools()).unwrap() + ); + } + + #[test] + fn instruction_in_system_head_is_moved_to_tail() { + // 防御形态:指令整条塞在头部 system 项,指令以外字节必须逐字节保留。 + let key = "align-thread-2"; + remember(key); + let normal = json!({ + "prompt_cache_key": key, + "tools": base_tools(), + "input": [message("system", "主会话系统块"), message("user", "历史消息")], + }); + let mut compaction = normal.clone(); + compaction["tools"] = json!([]); + compaction["input"] + .as_array_mut() + .unwrap() + .insert(0, message("system", &instruction_text())); + let (aligned, report) = align_compaction_request(&compaction).expect("必须检出"); + assert_eq!(report.moved, 1); + assert_eq!(report.appended, 1); + assert!(report.tools_backfilled); + let items = aligned["input"].as_array().unwrap(); + assert_eq!(items.len(), 3, "摘除头部指令项,末尾补挂一条"); + assert_eq!(items[0], normal["input"].as_array().unwrap()[0]); + assert_eq!(items[1], normal["input"].as_array().unwrap()[1]); + assert_eq!(item_role(&items[2]), "user"); + assert_eq!( + whole_item_instruction_text(&items[2]).unwrap(), + instruction_text(), + "补挂指令必须携带客户端原始字节" + ); + } + + #[test] + fn instruction_mixed_into_developer_block_is_cut_byte_exact() { + // 指令混排进 developer 大块文本:切除区域外字节必须原样。 + let key = "align-thread-3"; + let block = format!("前段固定文本\n{}后段固定文本\n", instruction_text()); + remember(key); + let request = json!({ + "prompt_cache_key": key, + "tools": [], + "input": [message("developer", &block), message("user", "hi")], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert_eq!(report.moved, 1); + assert_eq!(report.appended, 1); + let cut = first_text_of(aligned["input"][0]["content"].as_array().unwrap()); + assert_eq!(cut, "前段固定文本\n后段固定文本\n", "切除后 system 正文必须逐字节还原"); + assert_eq!(item_role(&aligned["input"][1]), "user"); + assert_eq!( + whole_item_instruction_text(aligned["input"].as_array().unwrap().last().unwrap()) + .unwrap(), + instruction_text() + ); + assert!(report.tools_backfilled); + } + + #[test] + fn instruction_without_tail_sentence_is_conservatively_skipped() { + // 混排文本只见签名不见收尾句:不动 system 正文,但 tools 回填照做。 + let key = "align-thread-4"; + let block = "You are performing a CONTEXT CHECKPOINT COMPACTION 的残缺引用\n其余正文"; + remember(key); + let request = json!({ + "prompt_cache_key": key, + "tools": [], + "input": [message("developer", block), message("user", "hi")], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert!(report.skipped_mixed); + assert_eq!(report.moved, 0, "无法圈定区域时不删"); + assert!(report.tools_backfilled); + assert_eq!(aligned["input"][0]["content"], request["input"][0]["content"]); + } + + #[test] + fn mid_history_instruction_is_untouched_and_not_detected() { + // 上一轮压缩中断后残留的历史中段实例(early-rewrite 形态):绝不移动, + // 该轮按普通轮放行。 + let request = json!({ + "prompt_cache_key": "align-thread-5", + "tools": base_tools(), + "input": [ + message("developer", "系统块"), + message("user", &instruction_text()), + message("assistant", "被打断的回答"), + message("user", "新的普通消息"), + ], + }); + assert!(align_compaction_request(&request).is_none()); + } + + #[test] + fn system_role_tail_instruction_is_moved_to_user_tail() { + // 指令以 system 角色收尾:整项摘除,末尾补挂 user 项,指令字节原样。 + let key = "align-thread-6"; + remember(key); + let request = json!({ + "prompt_cache_key": key, + "tools": [], + "input": [message("user", "历史"), message("system", &instruction_text())], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert_eq!(report.moved, 1); + assert_eq!(report.appended, 1); + let items = aligned["input"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0], request["input"].as_array().unwrap()[0]); + assert_eq!(item_role(&items[1]), "user"); + assert_eq!( + whole_item_instruction_text(&items[1]).unwrap(), + instruction_text() + ); + assert!(report.tools_backfilled); + } + + #[test] + fn head_user_instruction_instance_is_moved_to_tail() { + // 「指令在消息序列头部」形态(首位 user 整项即指令):搬到末尾。 + let key = "align-thread-9"; + remember(key); + let request = json!({ + "prompt_cache_key": key, + "tools": [], + "input": [message("user", &instruction_text()), message("user", "历史消息")], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert_eq!(report.moved, 1); + assert_eq!(report.appended, 1); + let items = aligned["input"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0], request["input"].as_array().unwrap()[1]); + assert_eq!( + whole_item_instruction_text(&items[1]).unwrap(), + instruction_text() + ); + assert!(report.tools_backfilled); + } + + #[test] + fn compaction_round_detection_guards_remember_tools() { + // 压缩轮本体(检出 Some)与普通轮(None)的分界:调用方据此决定 + // 跳过/执行 remember_tools,杜绝 [] 毒化回填缓存。 + let compaction = json!({ + "prompt_cache_key": "align-thread-7", + "tools": [], + "input": [message("user", "历史"), message("user", &instruction_text())], + }); + assert!(align_compaction_request(&compaction).is_some()); + let normal = json!({ + "prompt_cache_key": "align-thread-7", + "tools": base_tools(), + "input": [message("user", "普通轮")], + }); + assert!(align_compaction_request(&normal).is_none()); + } + + #[test] + fn no_cached_tools_keeps_legacy_empty_tools_behavior() { + let request = json!({ + "prompt_cache_key": "align-thread-never-seen", + "tools": [], + "input": [message("user", "历史"), message("user", &instruction_text())], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert!(!report.tools_backfilled); + assert_eq!(aligned["tools"], json!([])); + assert_eq!(report.instances, 1); + } + + #[test] + fn top_level_instructions_containment_is_cut() { + // 指令混进顶层 instructions:切除后其余字节保留,指令补到消息末尾。 + let key = "align-thread-8"; + remember(key); + let request = json!({ + "prompt_cache_key": key, + "tools": [], + "instructions": format!("基础指令头\n{}", instruction_text()), + "input": [message("user", "hi")], + }); + let (aligned, report) = align_compaction_request(&request).expect("必须检出"); + assert_eq!(report.moved, 1); + assert_eq!(report.appended, 1); + assert_eq!(aligned["instructions"], json!("基础指令头\n")); + let last = aligned["input"].as_array().unwrap().last().unwrap(); + assert_eq!(item_role(last), "user"); + assert_eq!( + whole_item_instruction_text(last).unwrap(), + instruction_text() + ); + assert!(report.tools_backfilled); + } +} diff --git a/crates/codex-plus-core/src/compaction_tools.rs b/crates/codex-plus-core/src/compaction_tools.rs new file mode 100644 index 000000000..5b1b6df13 --- /dev/null +++ b/crates/codex-plus-core/src/compaction_tools.rs @@ -0,0 +1,109 @@ +// compaction 请求的 tools 回填缓存(「prompt 前缀稳定层」的组成部分,opt-in 同开关)。 +// +// 背景(取证定案,见 COMPACT_PREFILL.md):codex 客户端的 v2 压缩请求自带 +// `tools: []`;而上游 chat 模板把 tools 渲染进 prompt 前部,且 relay 转换层 +// 折叠历史 tool 项时依赖 tools schema——tools 由整块变空会让全历史消息重构 +// (实测 231→388 msgs)、token 前缀在历史首个 tool 项处分叉,压缩轮整段 +// ~15 万 token 全量冷算(约 4 分钟)。 +// +// 方案:普通请求把 tools 序列化缓存下来(key = prompt_cache_key,codex 客户端 +// 里等于线程 id,跨轮稳定);压缩请求回填同一 Value——序列化逐字节一致, +// 历史折叠形态不变,token 前缀纯追加;摘要阶段禁工具调用交给 tool_choice=none +// (rewrite_request_for_compaction 已设置,不入 prompt 文本,不影响前缀)。 +// 无缓存(如 relay 重启后首次即压缩)时保持旧行为(tools 置空)。 +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; + +/// 上限 32 会话;超限整体重置(工具定义体量小,重置只会退回旧行为一次)。 +const CACHE_LIMIT: usize = 32; + +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache_key(body: &Value) -> Option { + body.get("prompt_cache_key") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// 普通请求(非压缩轮)记录 tools 序列化,供后续压缩轮回填。 +pub fn remember_tools(request: &Value) { + let Some(key) = cache_key(request) else { + return; + }; + let Some(tools) = request + .get("tools") + .filter(|tools| tools.is_array()) + .cloned() + else { + return; + }; + let Ok(mut cache) = CACHE.get_or_init(Default::default).lock() else { + return; + }; + if cache.len() >= CACHE_LIMIT && !cache.contains_key(&key) { + cache.clear(); + } + cache.insert(key, tools); +} + +/// 压缩请求取回该会话最近一次普通请求的 tools;无缓存返回 None(回退旧行为)。 +pub fn preserved_tools(request: &Value) -> Option { + let key = cache_key(request)?; + let cache = CACHE.get_or_init(Default::default).lock().ok()?; + cache.get(&key).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remember_then_preserved_round_trips_exact_bytes() { + let request = json!({ + "prompt_cache_key": "thread-unit-1", + "tools": [{"type": "function", "name": "exec_command", "parameters": {"type": "object"}}], + }); + remember_tools(&request); + let preserved = preserved_tools(&request).expect("回填必须命中"); + assert_eq!( + serde_json::to_string(&preserved).unwrap(), + serde_json::to_string(&request["tools"]).unwrap(), + "回填序列化必须与普通请求逐字节一致" + ); + } + + #[test] + fn remember_ignores_missing_or_non_array_tools() { + let request = json!({"prompt_cache_key": "thread-unit-2"}); + remember_tools(&request); + assert!(preserved_tools(&request).is_none()); + let request2 = json!({"prompt_cache_key": "thread-unit-2", "tools": {"weird": true}}); + remember_tools(&request2); + assert!(preserved_tools(&request2).is_none()); + } + + #[test] + fn preserved_requires_known_key() { + let compaction = json!({"prompt_cache_key": "thread-unit-never-seen", "tools": []}); + assert!(preserved_tools(&compaction).is_none()); + } + + #[test] + fn later_normal_request_overwrites_cached_tools() { + let first = json!({ + "prompt_cache_key": "thread-unit-3", + "tools": [{"type": "function", "name": "old_tool"}], + }); + remember_tools(&first); + let second = json!({ + "prompt_cache_key": "thread-unit-3", + "tools": [{"type": "function", "name": "new_tool"}], + }); + remember_tools(&second); + let preserved = preserved_tools(&second).expect("回填必须命中最新一轮"); + assert_eq!(preserved[0]["name"], json!("new_tool")); + } +} diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index a0b6ffe60..54f0afd10 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -12,6 +12,8 @@ pub mod codex_app_state; pub mod codex_home; pub mod codex_local_storage; pub mod codex_sqlite; +pub mod compaction_align; +pub mod compaction_tools; pub mod connect; pub mod diagnostic_log; pub mod dictation; @@ -41,6 +43,7 @@ pub mod ports; pub mod plugin_market; pub mod protocol_proxy; pub mod provider_import; +pub mod prefix_divergence; pub mod proxy; pub mod relay_config; pub mod relay_environment; diff --git a/crates/codex-plus-core/src/prefix_divergence.rs b/crates/codex-plus-core/src/prefix_divergence.rs new file mode 100644 index 000000000..6c1de4662 --- /dev/null +++ b/crates/codex-plus-core/src/prefix_divergence.rs @@ -0,0 +1,318 @@ +// 「prompt 前缀稳定层」的分叉诊断探针(opt-in,随 stabilize_prompt_prefix 生效)。 +// +// 背景(2026-10-11 凌晨取证):codex 客户端重启后重放历史,system 区出现 +// +347B 变化(sys 49927b -> 50274b),mlx-serve 前缀从 98.6% 深命中跌到 +// 24576(仅 sys+tools 之前的浅层),重启首轮全历史冷算约 68 秒。 +// 现有毒源清单(OPS_RUNBOOK §5)对不上号,需要 payload 级证据: +// 同一会话(prompt_cache_key)的「转换后 system 块序列 + tools 序列化」 +// 指纹由本模块逐轮记录,字节变化即 dump 分叉点新旧对比,基线另存全文。 +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::Mutex; + +/// 与 compaction_tools 相同的会话缓存上限策略:超限整体重置,只退化为一次基线。 +const CACHE_LIMIT: usize = 32; + +/// 每个会话键记住的已见指纹数上限。子 Agent 与主会话共用 prompt_cache_key +/// (线程 id),多个 system prompt 形态在同一键上交替是常态而非分叉 +/// (2026-10-11 19:25 实锤:主会话/子 Agent 三指纹乒乓引发 divergence 误报); +/// 只有从未见过的指纹才判为真分叉。 +const KNOWN_FINGERPRINTS_PER_KEY: usize = 6; + +/// dump 分叉点上下文半径(字节)。 +const SNIP_RADIUS: usize = 240; + +pub enum DivergenceKind { + /// 首次见到该会话:基线已登记(全文另存 /tmp)。 + Baseline, + /// system+tools 前缀字节与上一轮不同。 + Diverged, +} + +pub struct DivergenceReport { + pub kind: DivergenceKind, + pub old_len: usize, + pub new_len: usize, + /// 首个差异字节偏移;基线轮为 0。 + pub first_diff: usize, + pub old_snip: String, + pub new_snip: String, +} + +/// 键 -> 已见指纹列表(按首见序,容量 6,逐旧替换最旧)。 +static CACHE: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + +fn normalize_cache_key(key: &str) -> Option { + let trimmed = key.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// 提取 chat message 的文本内容:字符串直接用;数组按 part 拼接 text。 +fn message_text(message: &Value) -> Option { + match message.get("content") { + Some(Value::String(text)) => Some(text.clone()), + Some(Value::Array(parts)) => { + let mut joined = String::new(); + for part in parts { + match part.get("text").and_then(Value::as_str) { + Some(text) => { + joined.push_str(text); + joined.push('\n'); + } + // content part 形态不认识:宁可放弃本轮观察,也不要把 + // 结构差异误报成分叉(基线照登,事件宁缺毋滥)。 + None => return None, + } + } + Some(joined) + } + _ => None, + } +} + +/// 会话前缀指纹 = 头部连续 system/developer 块内容 + 末尾分隔 + tools 序列化。 +/// 只取「头部连续段」:历史消息里混入的 system 项属于消息区,由稳定层 +/// 折叠逻辑负责,不归本探针(探针盯的是重启分叉最高发的 prompt 最前端)。 +pub fn prompt_prefix_fingerprint(body: &Value) -> Option { + let mut fingerprint = String::new(); + let messages = body.get("messages").and_then(Value::as_array); + for message in messages.unwrap_or(&Vec::new()) { + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + if role != "system" && role != "developer" { + break; + } + fingerprint.push_str(&message_text(message)?); + fingerprint.push('\n'); + } + match body.get("tools") { + Some(Value::Array(tools)) if !tools.is_empty() => { + serde_json::to_string(&Value::Array(tools.clone())).ok().map(|tools| format!("{fingerprint}\u{1}{tools}")) + } + None | Some(Value::Null) => Some(fingerprint), + _ => None, + } +} + +/// UTF-8 安全的分叉点上下文切片:按 first_diff 所在 char 边界向两侧扩半径。 +fn snip_around(text: &str, first_diff: usize, radius: usize) -> String { + let bytes = text.as_bytes(); + let mut start = first_diff.min(bytes.len()); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + let mut end = (first_diff + radius).min(bytes.len()); + while end < bytes.len() && !text.is_char_boundary(end) { + end += 1; + } + format!( + "<<{}|{}>>", + String::from_utf8_lossy(&text.as_bytes()[start..first_diff]), + String::from_utf8_lossy(&text.as_bytes()[first_diff..end]), + ) +} + +fn first_diff_offset(old: &str, new: &str) -> usize { + old.bytes() + .zip(new.bytes()) + .take_while(|(a, b)| a == b) + .count() +} + +/// 记录并对比当前轮前缀指纹。返回 None 表示本轮放弃观察(缺 key / 形态不认)。 +pub fn observe(prompt_cache_key: Option<&str>, body: &Value) -> Option { + let key = prompt_cache_key.and_then(normalize_cache_key)?; + let fingerprint = prompt_prefix_fingerprint(body)?; + let mut cache = CACHE.get_or_init(Default::default).lock().ok()?; + if cache.len() >= CACHE_LIMIT && !cache.contains_key(&key) { + cache.clear(); + } + let entry = cache.entry(key.clone()).or_default(); + if entry.is_empty() { + entry.push(fingerprint.clone()); + drop(cache); + dump_baseline_file(&key, &fingerprint); + return Some(DivergenceReport { + kind: DivergenceKind::Baseline, + old_len: 0, + new_len: fingerprint.len(), + first_diff: 0, + old_snip: String::new(), + new_snip: String::new(), + }); + } + if let Some(position) = entry.iter().position(|known| known == &fingerprint) { + // 已见指纹(含多会话共键乒乓、以及回摆到旧形态):不是分叉。 + // 把它提到首见序最前,最近常用者最后被逐出。 + if position > 0 { + let known = entry.remove(position); + entry.insert(0, known); + } + return None; + } + // 全新指纹:真分叉。与最近期版本对比取证,随后纳入已见集合。 + let previous = entry[0].clone(); + if entry.len() >= KNOWN_FINGERPRINTS_PER_KEY { + entry.pop(); + } + entry.insert(0, fingerprint.clone()); + drop(cache); + dump_baseline_file(&key, &fingerprint); + let first_diff = first_diff_offset(&previous, &fingerprint); + Some(DivergenceReport { + kind: DivergenceKind::Diverged, + old_len: previous.len(), + new_len: fingerprint.len(), + first_diff, + old_snip: snip_around(&previous, first_diff, SNIP_RADIUS), + new_snip: snip_around(&fingerprint, first_diff, SNIP_RADIUS), + }) +} + +/// 基线全文落盘(本机诊断文件;分叉 dump 会引用它做全文对照)。 +/// 基线全文落盘(本机诊断文件;分叉 dump 会引用它做全文对照)。 +/// 基线含用户提示词、工具定义与工作区信息,必须落在诊断日志的私有目录且 +/// 权限 0600,不得写 world-writable 的 /tmp(维护者 P1/P2);已存在的同名 +/// 文件在覆写前同样收紧权限。 +fn dump_baseline_file(key: &str, fingerprint: &str) { + let digest = format!("{:016x}", fnv1a64(key.as_bytes())); + let dir = match crate::diagnostic_log::diagnostic_log_path().parent() { + Some(parent) => parent.to_path_buf(), + None => std::env::temp_dir(), + }; + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let path = dir.join(format!("prefix_baseline_{digest}.txt")); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + if let Ok(mut file) = options.open(&path) { + use std::io::Write; + let _ = file.write_all(fingerprint.as_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + } +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn body(system: &str, tools: Value) -> Value { + json!({ + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": "hi"}, + ], + "tools": tools, + }) + } + + fn request(key: &str) -> Value { + json!({"prompt_cache_key": key}) + } + + fn key_of(request: &Value) -> Option<&str> { + request.get("prompt_cache_key").and_then(Value::as_str) + } + + #[test] + fn first_round_reports_baseline_and_stores_file() { + let key = "thread-div-baseline-1"; + let report = observe(key_of(&request(key)), &body("SYS", json!([{"type": "function"}]))).expect("首轮必须登记"); + assert!(matches!(report.kind, DivergenceKind::Baseline)); + assert!(report.new_len > 0); + let digest = format!("{:016x}", fnv1a64(key.as_bytes())); + // 基线必须落在诊断日志的私有目录(非 world-writable /tmp),供取证对照。 + let dir = crate::diagnostic_log::diagnostic_log_path() + .parent() + .expect("诊断日志目录必须存在") + .to_path_buf(); + let path = dir.join(format!("prefix_baseline_{digest}.txt")); + assert!(path.exists(), "基线全文必须落盘供取证"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "基线含用户提示词,权限必须 0600"); + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn identical_prefix_reports_nothing_but_changed_sys_diverges_with_offset() { + let key = "thread-div-baseline-2"; + observe(key_of(&request(key)), &body("SYS-A", json!([{"type": "function"}]))).expect("基线"); + // 完全相同:无事件。 + assert!(observe(key_of(&request(key)), &body("SYS-A", json!([{"type": "function"}]))).is_none()); + // 纯追加:分叉点落在旧文本末尾。 + let report = observe(key_of(&request(key)), &body("SYS-A-ADDED", json!([{"type": "function"}]))).expect("必须有分叉事件"); + assert!(matches!(report.kind, DivergenceKind::Diverged)); + assert_eq!(report.first_diff, "SYS-A".len(), "分叉点=旧文本首个不同字节(原换行处)"); + // 更新后相同前缀再次静默。 + assert!(observe(key_of(&request(key)), &body("SYS-A-ADDED", json!([{"type": "function"}]))).is_none()); + } + + #[test] + fn tools_change_also_diverges() { + let key = "thread-div-baseline-3"; + observe(key_of(&request(key)), &body("SYS", json!([{"type": "function", "function": {"name": "a"}}]))).expect("基线"); + let report = observe(key_of(&request(key)), &body("SYS", json!([{"type": "function", "function": {"name": "b"}}]))).expect("tools 变化必须可见"); + assert!(matches!(report.kind, DivergenceKind::Diverged)); + assert!(report.first_diff > 4); + } + + #[test] + fn unknown_message_shape_abandons_observation() { + let key = "thread-div-baseline-4"; + // system content 为未知形态(数字):放弃观察而不是误报。 + let odd = json!({"messages": [{"role": "system", "content": 1}], "tools": []}); + assert!(observe(key_of(&request(key)), &odd).is_none()); + // Null tools 属可观察形态:首轮必须登记为基线(不依赖并行用例的全局计数)。 + let report = observe(key_of(&request("thread-div-baseline-5")), &body("SYS", Value::Null)).expect("Null tools 必须可观察为基线"); + assert!(matches!(report.kind, DivergenceKind::Baseline)); + } + + #[test] + fn snip_keeps_utf8_boundaries() { + let text = "中文前缀测试×30".repeat(30); + // 故意切在多字节字符中间:snip 不得 panic。 + let raw = snip_around(&text, 7, 16); + assert!(raw.starts_with("<<") && raw.ends_with(">>")); + } + + #[test] + fn shared_cache_key_ping_pong_between_known_shapes_is_not_divergence() { + // 子 Agent 与主会话共用 prompt_cache_key:主/子 system prompt 交替 + // 不得判为分叉(2026-10-11 19:25 误报实锤);只有全新指纹才报。 + let key = "thread-div-shared-key"; + let main = body("MAIN sys text", json!([{"type": "function"}])); + let sub = body("SUB agent sys text", json!([{"type": "function"}])); + let sub2 = body("SECOND sub shape", json!([{"type": "function"}])); + assert!(matches!(observe(key_of(&request(key)), &main).unwrap().kind, DivergenceKind::Baseline)); + // 子 Agent 首见:报一次分叉并纳入已见集合 + assert!(matches!(observe(key_of(&request(key)), &sub).unwrap().kind, DivergenceKind::Diverged)); + // 主/子乒乓往返:全部静默 + assert!(observe(key_of(&request(key)), &main).is_none(), "回摆到已见指纹不是分叉"); + assert!(observe(key_of(&request(key)), &sub).is_none(), "共键交替不是分叉"); + // 第三种全新形态:必须仍能报 + assert!(matches!(observe(key_of(&request(key)), &sub2).unwrap().kind, DivergenceKind::Diverged)); + } +} diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index fb271d6a9..2b52e0c98 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -389,12 +389,13 @@ fn input_has_encrypted_agent_content(input: &Value) -> bool { } pub fn responses_to_chat_completions(body: Value) -> anyhow::Result { - responses_to_chat_completions_with_options(body, false) + responses_to_chat_completions_with_options(body, false, false) } pub fn responses_to_chat_completions_with_options( mut body: Value, standard: bool, + stabilize_prompt_prefix: bool, ) -> anyhow::Result { remove_unsupported_native_search(&mut body)?; expand_local_compaction_history(&mut body); @@ -426,6 +427,13 @@ pub fn responses_to_chat_completions_with_options( if let Some(input) = body.get("input") { append_responses_input(input, &mut messages); } + // 「prompt 前缀稳定层」(opt-in,默认关):先把轮变的 resize notice 从所有角色 + // 的文本里剥离,避免它插进历史早段;后面的 collapse 改走规范序拼接。 + if stabilize_prompt_prefix { + strip_image_resize_notice_blocks(&mut messages); + // 历史轮附件样板归一(opt-in):客户端附件轮重写历史样板被归一为常量。 + normalize_attachment_boilerplate(&mut messages); + } // 必须在 enforce_tool_call_pairing 之前:配对一旦被错误摘除就无法恢复。 relocate_interleaved_non_tool_messages(&mut messages); enforce_tool_call_pairing(&mut messages); @@ -438,7 +446,15 @@ pub fn responses_to_chat_completions_with_options( normalize_chat_messages(&mut messages); let model = body.get("model").and_then(Value::as_str).unwrap_or(""); normalize_image_data_urls_for_model(&mut messages, model); - let messages = collapse_system_messages_to_head(messages); + let mut messages = collapse_system_messages_to_head(messages, stabilize_prompt_prefix); + if stabilize_prompt_prefix { + relocate_turn_aborted_blocks(&mut messages); + } + if stabilize_prompt_prefix { + // 轮次标记:把「本轮要回答的指令」显式钉在最后一条 user 消息上, + // 根治上下文重建(切模型/压缩)时旧问题被回放成最新指令的串线问题。 + mark_current_user_turn(&mut messages); + } result["messages"] = json!(messages); if let Some(value) = body.get("max_output_tokens") { if is_openai_o_series(model) { @@ -2842,6 +2858,20 @@ pub fn ensure_web_search_tool_for_history(request_json: &mut Value) -> bool { true } +/// 前缀对齐诊断事件(命名沿用 prefix_stabilizer_* 风格)。 +fn log_compaction_align(report: &crate::compaction_align::CompactionAlignReport) { + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.prefix_stabilizer_compaction_appended", + json!({ + "instances": report.instances, + "moved": report.moved, + "appended": report.appended, + "toolsBackfilled": report.tools_backfilled, + "skippedMixed": report.skipped_mixed, + }), + ); +} + async fn upstream_request_parts( relay: &crate::settings::RelayProfile, mut request_json: Value, @@ -2866,17 +2896,63 @@ async fn upstream_request_parts( } } let synthetic_compaction = compact && relay.protocol == RelayProtocol::ChatCompletions; + if synthetic_compaction && relay.stabilize_prompt_prefix { + // 稳定层:压缩轮自带 tools:[],转换层会丢掉折叠历史 tool 项的 schema, + // 全历史重构、前缀全弃。rewrite 已在上方公共块完成且对 ChatCompletions 恒执行 + // (preserve_native_request 要求 Responses 协议),这里只做 tools 字节回填 + //(缓存按 prompt_cache_key 取回,与 rewrite 顺序无关)+ 前缀对齐: + // 把客户端塞进 system 区的指令复制件搬走。 + if let Some(tools) = crate::compaction_tools::preserved_tools(&request_json) { + request_json["tools"] = tools; + } + if let Some((aligned, report)) = + crate::compaction_align::align_compaction_request(&request_json) + { + log_compaction_align(&report); + request_json = aligned; + } + } else if relay.stabilize_prompt_prefix { + // 514-msg 流式压缩路径(codex++ 自动压缩):既不走 /responses/compact + // 也不带 compaction_trigger,compact 判定落空;由前缀对齐按指令签名检出, + // 做「末尾唯一 user 指令 + tools 字节回填」。检出即跳过 remember_tools, + // 杜绝把压缩轮的 tools=[] 毒化进回填缓存;未检出才是普通轮,照常记录。 + let aligned = if relay.protocol == RelayProtocol::ChatCompletions { + crate::compaction_align::align_compaction_request(&request_json) + } else { + None + }; + match aligned { + Some((aligned, report)) => { + log_compaction_align(&report); + request_json = aligned; + } + None => crate::compaction_tools::remember_tools(&request_json), + } + } if let Some(model) = model_override .map(str::trim) .filter(|value| !value.is_empty()) { request_json["model"] = json!(model); } + // 探针用 key 需在 request_json 被转换层 move 之前取出。 + let probe_key = if relay.protocol == RelayProtocol::ChatCompletions + && relay.stabilize_prompt_prefix + && !compact + { + request_json + .get("prompt_cache_key") + .and_then(Value::as_str) + .map(str::to_string) + } else { + None + }; let mut body = match relay.protocol { RelayProtocol::Responses => request_json, RelayProtocol::ChatCompletions => responses_to_chat_completions_with_options( request_json, relay.standard_openai_protocol, + relay.stabilize_prompt_prefix, )?, }; if relay.protocol == RelayProtocol::Responses { @@ -2892,6 +2968,28 @@ async fn upstream_request_parts( normalize_responses_item_ids(&mut body); } + // 前缀分叉探针(opt-in):普通轮观察「system 块序列 + tools」前缀, + // 字节突变即 dump 分叉点(重启首轮重放取证用);压缩轮前缀本就有意 + // 重写,跳过观察以免噪声。 + if relay.protocol == RelayProtocol::ChatCompletions && relay.stabilize_prompt_prefix && !compact { + if let Some(report) = crate::prefix_divergence::observe(probe_key.as_deref(), &body) { + let _ = crate::diagnostic_log::append_diagnostic_log( + if matches!(report.kind, crate::prefix_divergence::DivergenceKind::Baseline) { + "protocol_proxy.prefix_stabilizer_prefix_baseline" + } else { + "protocol_proxy.prefix_stabilizer_prefix_divergence" + }, + json!({ + "oldLen": report.old_len, + "newLen": report.new_len, + "firstDiff": report.first_diff, + "oldSnip": report.old_snip, + "newSnip": report.new_snip, + }), + ); + } + } + // Image handling (per-model): send-as-is / strip / VLM analysis let model = body .get("model") @@ -4516,6 +4614,43 @@ fn append_responses_item( } })); } + Some("web_search_call") => { + // codex 的 web_search 由客户端执行,item 是「调用+结果」合体形态。 + // 此前该 item 没有 content 字段,回放时整项被 `_` 兜底静默丢弃: + // 模型既看不到自己搜过、也看不到结果,导致重复搜索与轮次失联 + // (2026-10-11 多 Agent 会话实锤:3 轮 8 次重复搜索、两轮空 FINAL)。 + // 回放为 assistant 调用 + tool 结果消息:query/sources 存在则透传, + // 被客户端剥离时如实告知状态,杜绝模型失忆重搜。 + let call_id = item.get("id").and_then(Value::as_str).unwrap_or(""); + if call_id.is_empty() { + return; + } + let action = item.get("action").cloned().unwrap_or_else(|| json!({})); + let query = action + .get("query") + .and_then(Value::as_str) + .or_else(|| action.get("input").and_then(Value::as_str)) + .unwrap_or(""); + let arguments = serde_json::to_string(&json!({ "input": query })) + .unwrap_or_else(|_| "{\"input\":\"\"}".to_string()); + seen_tool_call_ids.insert(call_id.to_string()); + pending_tool_calls.push(json!({ + "id": call_id, + "type": "function", + "function": { "name": "web_search", "arguments": arguments } + })); + flush_tool_calls(messages, pending_tool_calls, pending_reasoning); + let status = item.get("status").and_then(Value::as_str).unwrap_or("completed"); + let content = match action.get("sources").or_else(|| action.get("results")) { + Some(sources) if !sources.is_null() => { + serde_json::to_string(sources).unwrap_or_else(|_| format!("Web search ({status}) results: {sources}")) + } + _ => format!( + "Web search ({status}) was performed; its query and results were not preserved in this history. Do not repeat it; continue with the available context." + ), + }; + messages.push(json!({ "role": "tool", "tool_call_id": call_id, "content": content })); + } Some("tool_search_call") => { // Codex 的 tool_search 是客户端执行的代理工具,历史回放时按 // function tool_call 形态映射进 chat 消息流。 @@ -5044,7 +5179,758 @@ fn system_message_text(content: Option<&Value>) -> String { } } -fn collapse_system_messages_to_head(messages: Vec) -> Vec { +/// 「prompt 前缀稳定层」(mediafix4)易变注入块名单。 +/// +/// 取证(MEDIAFIX3_TURN_BOUNDARY.md 第五节)确认:下列块随会话状态整段出现/消失, +/// 到达顺序轮轮漂移——混进折叠后的 system 头部块后,上游 token 前缀会在块区分叉, +/// 导致每轮全量重算。排序时把它们钉在块尾(按名字典序),保证新出现的注入块 +/// 永远追加在 system 区最尾,历史前缀不被平移。 +/// 名单保守:只收取证确认轮变的块;其余具名块(如 AGENTS、skills_instructions) +/// 不参与重排,一旦出现即保持到达位置。 +/// `image_resize_notice` 已被剥离步骤删尽,保留在名单中仅作为非成对残留路径的兜底。 +const VOLATILE_INJECTION_BLOCK_NAMES: &[&str] = &[ + "apps_instructions", + "collaboration_mode", + "image_resize_notice", + "multi_agent_mode", + "recommended_plugins", +]; + +const IMAGE_RESIZE_NOTICE_OPEN: &str = ""; +const IMAGE_RESIZE_NOTICE_CLOSE: &str = ""; + +/// 从所有角色(system/user/tool,developer 此前已映射为 system)的消息文本中 +/// 剥离 `…` 块及其残片。 +/// +/// 消息被块整体消耗时整条删除(不留空消息);非成对残留(孤儿标签连同其 +/// 残片)一并移除并记 debug 日志——残片属易变内容,按普通文本留在前缀里 +/// 会让后续轮次的前缀字节级分叉。 +/// 变换幂等:第二次运行零命中,不会改写已清洁的消息。 +fn strip_image_resize_notice_blocks(messages: &mut Vec) { + let mut unpaired = false; + messages.retain_mut(|message| { + // 带 tool_calls 的结构消息不允许因剥离被删(会破坏配对协议)。 + let has_tool_calls = message.get("tool_calls").is_some(); + let Some(content) = message.get_mut("content") else { + return true; + }; + match content { + Value::String(text) => { + let (cleaned, removed, has_unpaired) = strip_notice_blocks_in_text(text); + unpaired = unpaired || has_unpaired; + if !removed { + return true; + } + *text = cleaned; + // retain 返回 false 才整条删除:无结构负载的纯 notice 消息删除; + // 带 tool_calls 的骨架消息保留,内容已清空。 + !text.is_empty() || has_tool_calls + } + Value::Array(parts) => { + let mut removed_any = false; + for part in parts.iter_mut() { + // 只处理带 text 字段的 part;image_url 等形态不受影响。 + let Some(Value::String(text)) = part.get_mut("text") else { + continue; + }; + let (cleaned, removed, has_unpaired) = strip_notice_blocks_in_text(text); + unpaired = unpaired || has_unpaired; + if !removed { + continue; + } + removed_any = true; + *text = cleaned; + } + if !removed_any { + return true; + } + parts.retain(|part| match part.get("text") { + Some(Value::String(text)) => !text.is_empty(), + _ => true, + }); + if parts.is_empty() && !has_tool_calls { + return false; + } + true + } + _ => true, + } + }); + if unpaired { + // 非成对残片已随本步骤归零;只记 debug 日志,不影响转发。 + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.prefix_stabilizer_unpaired_notice_stripped", + json!({ + "note": "存在非成对的 image_resize_notice 标记,孤儿标签连同残片已按契约移除", + "volatileBlocks": VOLATILE_INJECTION_BLOCK_NAMES, + }), + ); + } +} + +/// 从单段文本剥离 notice 相关内容,返回(清洁后文本, 是否删除过内容, 是否存在非成对标记)。 +/// +/// 三态契约(前缀字节级稳定优先于残片原文保全,仅在 opt-in 开关开启时生效): +/// - 完整块(开始标签后随结束标签):整块删除; +/// - 孤儿结束标签(先于任何开始标签出现):文本段开头到该标签(含)视为残片,整体删除; +/// - 孤儿开始标签(其后无配对结束标签):该标签(含)到文本段结尾视为残片,整体删除。 +/// 上游客户端把 notice 以整块或「开始标签+上半残体」「下半残体+结束标签」的跨字段 +/// 拆分形态注入历史早段,且轮轮重写;残片只要进入前缀就必然漂移。删除范围只由 +/// 标签位置决定、与内容无关,因此变换确定且幂等。前提:轮变内容只出现在 notice +/// 标签内部;若客户端日后把用户原文并入残片,删除会一并吞掉该部分,属契约内代价。 +/// 未删除任何内容时返回的清洁文本无意义,调用方凭 removed=false 保持原文逐字节不变。 +fn strip_notice_blocks_in_text(text: &str) -> (String, bool, bool) { + let mut segments: Vec<&str> = Vec::new(); + let mut rest = text; + let mut removed = false; + let mut unpaired = false; + loop { + let open_at = rest.find(IMAGE_RESIZE_NOTICE_OPEN); + let close_at = rest.find(IMAGE_RESIZE_NOTICE_CLOSE); + let first_is_open = match (open_at, close_at) { + (None, None) => { + segments.push(rest); + break; + } + (Some(open), Some(close)) if close < open => false, + (Some(_), Some(_)) => true, + (Some(_), None) => true, + (None, Some(_)) => false, + }; + if first_is_open { + let open = open_at.unwrap(); + match close_at { + Some(close) if close > open => { + // 完整块:保留块前文本,跳过块体 + removed = true; + segments.push(&rest[..open]); + rest = &rest[close + IMAGE_RESIZE_NOTICE_CLOSE.len()..]; + } + _ => { + // 孤儿开始标签:该标签(含)到文本段结尾整体视为残片删除 + unpaired = true; + removed = true; + segments.push(&rest[..open]); + break; + } + } + } else { + // 孤儿结束标签(含「结束先于开始」形态):文本段开头到该标签(含)删除 + let close = close_at.unwrap(); + unpaired = true; + removed = true; + rest = &rest[close + IMAGE_RESIZE_NOTICE_CLOSE.len()..]; + } + } + if !removed { + return (String::new(), false, unpaired); + } + // 删块后的空白残迹整理:非空段之间用空行拼接,不留空段。 + let cleaned = segments + .iter() + .map(|segment| segment.trim()) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("\n\n"); + (cleaned, true, unpaired) +} + +/// 把 system 文本切成顶层块序列:(块名, 块体);块名为 None 表示普通文本段。 +// ———— 图片附件轮「前缀塌陷」根治层(opt-in,随 stabilize_prompt_prefix 生效)———— +// +// 取证(PREFIX_DRIFT_FORENSICS.md)定案的两个漂移源与对应变换: +// 1. `normalize_attachment_boilerplate`:客户端在用户图片附件轮**重渲染历史带图 +// user 消息**,附件样板一次性增长(新增 `Image attachment: true`、 +// `Distinguish instructions...` 等行)并固化。新旧两种形态都归一为同一常量 +// 占位前缀,客户端的重写被归一层消化,token 前缀不再分叉。 +// 2. open_page 系块内容归一:`codex_apps_open_page_instructions` / +// `external_codex_apps_open_page` 块随开页状态重写或重复注入(+273B 形态), +// 在块区统一替换为常量空体块并去重;裸文本形态先按签名包成具名块。 +// 3. 易变重注入块同名去重:客户端累积重注入 `codex_apps_client_time_context` +// 等具名锚块(时区块单实例 271B,每实例步进 +273B),同名只保留到达序 +// 最后一个实例(dedup-last),内容零改写,保留块区字典序的自然位置, +// 不进「内容钉死为常量」的钉尾区。 +// 以上变换都确定、幂等;不含签名/名单块的消息与文本逐字节不变。 + +/// 内容轮变的具名块名单:块体随会话状态(打开的 Page 等)改写或重复注入, +/// 归一为常量空体块并同名去重。块名保留在块区(字典序位置不变),只有体被钉死。 +const VOLATILE_CONTENT_BLOCK_NAMES: &[&str] = + &["codex_apps_open_page_instructions", "external_codex_apps_open_page"]; + +/// 易变同名重注入块名单:客户端累积式重注入(时区锚块约每 2 小时一次), +/// 块体语义有效、零改写;同名只保留到达序**最后一个实例**(时间语义取最新), +/// 块位置仍由块名字典序决定。实例数 >1 时经诊断日志记一条,供线上核查注入频率。 +const DEDUP_LAST_BLOCK_NAMES: &[&str] = &["codex_apps_client_time_context"]; + +/// 已知可能以「裸文本段」形态出现的易变注入签名:(行首签名, 包裹用的块名)。 +/// 包裹后进入块区,再被名单归一;只收取证确认过的开头句,保守防误吞。 +const BARE_VOLATILE_INJECTION_SIGNATURES: &[(&str, &str)] = &[ + ( + "The codex_apps_open_page_instructions context records", + "codex_apps_open_page_instructions", + ), + ( + "The codex_apps_open_page context records", + "codex_apps_open_page_instructions", + ), +]; + +/// 把裸文本形态的易变注入段(签名行 + 随后连续非空行 = 一段)包成具名块。 +/// 幂等:包裹后的块体在后续归一中被替换为空体,不再含签名;带标签的原文行 +/// 以 `<` 开头,不会命中行首签名,不会被二次包裹。无签名时借用原文零拷贝。 +fn wrap_bare_volatile_injections(text: &str) -> std::borrow::Cow<'_, str> { + if !BARE_VOLATILE_INJECTION_SIGNATURES + .iter() + .any(|(signature, _)| text.contains(signature)) + { + return std::borrow::Cow::Borrowed(text); + } + let mut out_lines: Vec = Vec::new(); + let mut lines = text.lines().peekable(); + while let Some(line) = lines.next() { + let Some((_, block_name)) = BARE_VOLATILE_INJECTION_SIGNATURES + .iter() + .find(|(signature, _)| line.trim_start().starts_with(signature)) + else { + out_lines.push(line.to_string()); + continue; + }; + let mut paragraph = vec![line.to_string()]; + while let Some(next) = lines.peek() { + if next.trim().is_empty() { + break; + } + paragraph.push(lines.next().unwrap_or_default().to_string()); + } + out_lines.push(format!("<{block_name}>\n{}\n", paragraph.join("\n"))); + } + std::borrow::Cow::Owned(out_lines.join("\n")) +} + +/// 名单块的归一形态:同名块恒为该常量(split 的块体带标签,这里连标签一起重建)。 +fn normalize_volatile_block(name: Option<&str>, block: &str) -> String { + match name { + // 名单块:块体(含标签)整体替换为常量;同名块排序后内容全等,便于去重。 + Some(name) if VOLATILE_CONTENT_BLOCK_NAMES.contains(&name) => { + format!("<{name}>\n") + } + _ => block.to_string(), + } +} + +const ATTACHMENT_BOILERPLATE_HEADING: &str = "# Files mentioned by the user:"; +const ATTACHMENT_BOILERPLATE_ANCHOR: &str = "## My request:"; +/// 归一占位:样板头部(标题→锚行)的常量替身,语义保留:有文件附件、有图片 +/// 附件(取值由会话级注入描述统一声明)、随后是用户请求。锚行后正文原样保留。 +const ATTACHMENT_BOILERPLATE_PLACEHOLDER: &str = "[attached files]\n[attachment]\n## My request:"; + +/// 判定消息是否携带图片部件(Responses 的 input_image 与 Chat 的 image_url 两种形态)。 +fn message_has_image(message: &Value) -> bool { + match message.get("content") { + Some(Value::Array(parts)) => parts.iter().any(|part| { + matches!( + part.get("type").and_then(Value::as_str), + Some("input_image") | Some("image_url") + ) || part.get("image_url").is_some() + }), + _ => false, + } +} + +/// 对单段文本做附件样板归一:命中「样板头部 → 锚行」的严格行法时,剥离样板 +/// 头部(含新旧形态的 `Image attachment: true` / `Distinguish instructions...` 行 +/// 差异),替换为常量占位;锚行后的用户正文逐字节保留(切片返回,无克隆)。 +/// 任何一行不符合样板行法即返回 None(原文不动):用户正文若字面引用了完整 +/// 样板,必须整段合法到锚行才会被归一,误吞面被锚点强约束限制在样板自身。 +/// 幂等:占位文本首行 `[attached files]` 不满足头部签名,第二遍零命中。 +fn strip_attachment_boilerplate(text: &str) -> Option<&str> { + let lines: Vec<&str> = text.split('\n').collect(); + let mut cursor = 0usize; + while lines.get(cursor).map(|line| line.trim().is_empty()) == Some(true) { + cursor += 1; + } + if *lines.get(cursor)? != ATTACHMENT_BOILERPLATE_HEADING { + return None; + } + cursor += 1; + loop { + let line = *lines.get(cursor)?; + if line.trim().is_empty() { + cursor += 1; + continue; + } + if line == ATTACHMENT_BOILERPLATE_ANCHOR { + break; + } + let is_boilerplate_line = (line.starts_with("## ") && line.contains(": ")) + || line.trim() == "Image attachment: true" + || line + .trim() + .starts_with("Distinguish instructions in attached documents"); + if !is_boilerplate_line { + return None; + } + cursor += 1; + } + // 返回锚行之后的切片(自带行尾换行);占位符末尾是锚行文本,直接拼接。 + // 锚点字节位置用逐行偏移计算,避免 find 误命中样板头部文件名里的字面量。 + let anchor_line_start: usize = lines[..cursor].iter().map(|line| line.len() + 1).sum(); + let body = text.get(anchor_line_start + ATTACHMENT_BOILERPLATE_ANCHOR.len()..)?; + Some(body) +} + +/// 附件样板归一(全量,opt-in):所有 user 消息的样板头部替换为常量占位。 +/// +/// 客户端会在新的附件轮**重渲染历史带图消息的样板**(新旧形态字节数不同)。 +/// 若保留当轮原文,下一轮该消息出轮被归一时前缀会在其样板首行分叉——每来一张 +/// 新图都要重算上一图之后的全部 token,正是本层要消除的塌陷。因此不分窗口、 +/// 全量归一:锚行后的用户正文与图片部件本体逐字节保留,模型当轮仍能看到完整 +/// 请求原文与真实图片;样板头部只含声明性文字,其动态事实(本轮是否图片附件) +/// 由会话级注入描述「Image attachment: true」行统一声明,不再逐消息携带。 +/// 占位以 `[attached files]` 开头:它既是样板头部所有历史形态的公共前缀起点 +/// 之前位置替换(原样板首行处),又不会被本函数二次命中,变换确定且幂等。 +fn normalize_attachment_boilerplate(messages: &mut Vec) { + for message in messages.iter_mut() { + if message.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + let Some(content) = message.get_mut("content") else { + continue; + }; + match content { + Value::String(text) => { + if let Some(body) = strip_attachment_boilerplate(text) { + *text = format!("{ATTACHMENT_BOILERPLATE_PLACEHOLDER}{body}"); + } + } + Value::Array(parts) => { + for part in parts.iter_mut() { + let Some(Value::String(text)) = part.get_mut("text") else { + continue; + }; + if let Some(body) = strip_attachment_boilerplate(text) { + *text = format!("{ATTACHMENT_BOILERPLATE_PLACEHOLDER}{body}"); + break; + } + } + } + _ => {} + } + } +} + + +/// +/// 只识别顶层不嵌套的 XML 风格块(标签名以字母开头,仅含字母/数字/下划线/连字符); +/// 开始标签出现但后续缺配对结束、或结束标签乱序时,剩余内容整体降级为普通文本段。 +/// 输入不含任何块形态时返回逐字节等于原文的单元素。 +fn split_system_top_level_blocks(text: &str) -> Vec<(Option<&str>, &str)> { + let mut blocks: Vec<(Option<&str>, &str)> = Vec::new(); + let mut plain_start = 0usize; + let mut cursor = 0usize; + let bytes = text.as_bytes(); + // 全扫描:任意位置的「…完整闭合…」都视为顶层块(只认首个 + // 闭合、不处理嵌套);其余字节一律留在普通文本段,逐字节不变。 + while cursor < bytes.len() { + if bytes[cursor] != b'<' { + cursor += 1; + continue; + } + let Some((name, body_start)) = open_tag_at(text, cursor) else { + cursor += 1; + continue; + }; + // find_close_tag 返回结束标签 `>` 之后的偏移;找不到配对该处按普通文本继续扫 + let Some(block_end) = find_close_tag(text, body_start, name) else { + cursor += 1; + continue; + }; + if plain_start < cursor { + blocks.push((None, &text[plain_start..cursor])); + } + blocks.push((Some(name), &text[cursor..block_end])); + cursor = block_end; + plain_start = block_end; + } + if blocks.is_empty() { + return vec![(None, text)]; + } + if plain_start < text.len() { + blocks.push((None, &text[plain_start..])); + } + blocks +} + +fn is_block_name_char(value: u8) -> bool { + value.is_ascii_alphanumeric() || value == b'_' || value == b'-' +} + +/// 识别 at 处的 ``(或 ``)开始标签,返回(块名, 块体起始偏移)。 +/// 块名须以字母开头、仅含字母/数字/下划线/连字符;带属性、自闭合形态一律视为非块, +/// 保持原文逐字节不变。 +fn open_tag_at(text: &str, at: usize) -> Option<(&str, usize)> { + let bytes = text.as_bytes(); + if bytes.get(at) != Some(&b'<') { + return None; + } + let start = at + 1; + if !bytes.get(start)?.is_ascii_alphabetic() { + return None; + } + let mut index = start; + while index < bytes.len() && is_block_name_char(bytes[index]) { + index += 1; + } + let name_end = index; + while matches!(bytes.get(index), Some(b' ') | Some(b'\t')) { + index += 1; + } + if bytes.get(index) != Some(&b'>') { + return None; + } + Some((&text[start..name_end], index + 1)) +} + +/// 从 from 起搜索 ``(名前名后允许空白)。 +/// 返回结束标签 `>` 之后的字节偏移;扫到结尾仍无配对则返回 None,调用方据此降级。 +fn find_close_tag(text: &str, from: usize, name: &str) -> Option { + let mut search = from; + loop { + let found = text.get(search..)?.find("') { + return Some(index + 1); + } + search = index.max(found + 2); + } +} + +/// 块的排序权重:普通文本段取 (0, 空名) 保持到达相对序在前;具名块取 (1, 块名), +/// 易变名单与未知名同档,统一按块名字典序钉在块尾。 +/// 名单的语义是「取证确认轮变、靠本层规范序兜底的块」:具名 XML 块无论是否在 +/// 名单内都会被规范重排;名单是取证文档与后续扩展点(新增轮变块只需加名单)。 +fn block_sort_rank(name: Option<&str>) -> (u8, &str) { + match name { + None => (0, ""), + Some(name) => (1, name), + } +} + +/// 稳定重排 system 头部块(「prompt 前缀稳定层」的规范序部分)。 +/// +/// 把每个到达 chunk 切成顶层块后按全局规则重排:普通文本段保持到达相对序在前; +/// 具名块进块区按名字典序(名单内与未知名同档)。排序只依赖块名与到达相对序, +/// 不依赖到达位置:同一块集合任意排列输入,输出逐字节一致(确定性); +/// 对输出再切块再排序,块序列不变(幂等)。 +/// +/// 易变重注入同名块(`DEDUP_LAST_BLOCK_NAMES`)在规范序后做「同名去重、保留 +/// 最后一个实例」:内容全等时仍满足置换不变;内容不同(日期翻转)按到达序 +/// 取最新,属每日至多一次的可控变化。块体逐字节保留客户端原文。 +/// +/// 已知限制(写入产物报告):文本段按全局到达序在前,若已出现的历史文本段在 +/// 后续轮次整体缺失,其后的文本段会前移导致局部分叉;已定案的轮变源中 notice +/// 走剥离、其余均为具名块,不受此影响。 +fn order_system_chunks_stably(chunks: &[String]) -> String { + // rank 里的块名拷进拥有类型,避免借用生命周期复杂化;排序成本与正确性无关。 + let mut parts: Vec<((u8, String), String)> = Vec::new(); + for chunk in chunks { + // 裸文本形态的易变注入先包成具名块,再进统一切块与归一。 + let wrapped = wrap_bare_volatile_injections(chunk); + for (name, block) in split_system_top_level_blocks(&wrapped) { + // 名单块体归一为常量:内容轮变/重复注入都不再进入 token 前缀。 + let block = normalize_volatile_block(name, block); + let (kind, name_part) = block_sort_rank(name); + parts.push(((kind, name_part.to_string()), block)); + } + } + // sort_by_key 是稳定排序:rank 相同的块(文本段/重复同名块)保持到达相对序 + parts.sort_by_key(|part| part.0.clone()); + // dedup-last 预扫描:统计易变重注入块的实例数;同名块规范序下必然相邻, + // 只放行最后一个实例;实例数 >1 记一条诊断事件供线上核查注入频率。 + let mut dedup_last: Vec<(&str, usize, usize)> = Vec::new(); + for ((kind, name_part), _) in parts.iter() { + if *kind == 1 && DEDUP_LAST_BLOCK_NAMES.contains(&name_part.as_str()) { + match dedup_last + .iter_mut() + .find(|(name, ..)| *name == name_part.as_str()) + { + Some(entry) => entry.1 += 1, + None => dedup_last.push((name_part.as_str(), 1, 0)), + } + } + } + for (name, instances, _) in dedup_last.iter() { + if *instances > 1 { + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.prefix_stabilizer_dedup_last_folded", + json!({ "blockName": name, "instances": instances }), + ); + } + } + // 常量块同名去重:只保留第一个,消除重复注入造成的块区平移。 + let mut constant_seen: Vec<&str> = Vec::new(); + // 切分结果是否含具名块:决定文本段空白是「块间分隔」(可归一)还是 + // 「块体本身」(透传),见下方 push 循语义。 + let has_named_blocks = parts.iter().any(|((kind, _), _)| *kind == 1); + let mut out: Vec<&str> = Vec::with_capacity(parts.len()); + for ((kind, name_part), text) in parts.iter() { + if *kind == 1 + && VOLATILE_CONTENT_BLOCK_NAMES.contains(&name_part.as_str()) + { + if constant_seen.contains(&name_part.as_str()) { + continue; + } + constant_seen.push(name_part.as_str()); + } + if *kind == 1 && DEDUP_LAST_BLOCK_NAMES.contains(&name_part.as_str()) { + // 只放行到达序最后一个实例,之前的同名实例折叠;块体本身零改写。 + if let Some(entry) = dedup_last + .iter_mut() + .find(|(name, ..)| *name == name_part.as_str()) + { + entry.2 += 1; + if entry.2 < entry.1 { + continue; + } + } + } + // 块体原文透传(维护者 P2:裁剪块体首尾空白违背「非易变内容逐字节 + // 不变」的 opt-in 契约);纯空白块仍跳过,本块只消分隔噪声。 + // 语义分桶(维护者 P2):切分结果含具名块时,文本段首尾空白属 + // 「块间分隔」,归一不伤内容且保幂等;整段只有纯文本时,空白就是 + // 块体本身,逐字节透传。具名块的尖括号标签同理属结构,两端参与 + // 分隔归一、标签内内容零改写。 + let text = if has_named_blocks { text.trim() } else { text }; + if text.trim().is_empty() { + continue; + } + out.push(text); + } + out.join("\n\n") +} + + +/// 「prompt 前缀稳定层」(opt-in):客户端把 `` 状态块注入 +/// system 区尾部(2026-10-11 探针基线实锤:sys 尾部 @50259,重启重放时 +/// 块的出现的/消失让 sys 字节 +223~347B,前缀在历史最前端分叉,重启首轮 +/// 全历史冷算)。块的语义是「紧邻下一轮的一次性提示」,正确归宿是瞬态区: +/// 这里把它从头部 system 合并文本中摘除,改挂到最后一条 user 消息之前。 +/// 转换产物不进客户端历史,下一轮客户端再注入时再摘,sys 头部字节恒稳, +/// 前缀恢复纯追加。无块时零改写,与旧行为逐字节一致。 +fn relocate_turn_aborted_blocks(messages: &mut Vec) { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let Some(head) = messages.first_mut() else { return }; + if head.get("role").and_then(Value::as_str) != Some("system") { + return; + } + let Some(Value::String(content)) = head.get("content") else { return }; + if !content.contains(OPEN) { + return; + } + let content = content.clone(); + if !content.contains(OPEN) { + return; + } + let mut stripped = String::with_capacity(content.len()); + let mut blocks: Vec = Vec::new(); + let mut cursor = 0usize; + while let Some(open_rel) = content[cursor..].find(OPEN) { + let open = cursor + open_rel; + stripped.push_str(&content[cursor..open]); + match content[open..].find(CLOSE) { + Some(close_rel) => { + let end = open + close_rel + CLOSE.len(); + blocks.push(content[open..end].to_string()); + cursor = end; + } + None => { + // 残缺块(只见开标签):整段保守放弃,不改 system。 + stripped.push_str(&content[open..]); + cursor = content.len(); + } + } + } + if cursor < content.len() { + stripped.push_str(&content[cursor..]); + } + if blocks.is_empty() { + return; + } + // 残缺回退判定:stripped 只剩空白说明块外正文为空,宁留原状。 + if stripped.trim().is_empty() { + return; + } + if let Some(Value::String(target)) = head.get_mut("content") { + *target = stripped.trim_end().to_string(); + } else { + return; + } + let block_messages: Vec = blocks + .into_iter() + .map(|block| json!({ "role": "system", "content": block })) + .collect(); + // 插到最后一条 user 消息之前;没有 user 消息则挂在 system 之后。 + let insert_at = messages + .iter() + .rposition(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .unwrap_or(1) + .max(1); + messages.splice(insert_at..insert_at, block_messages); +} + +const CURRENT_TURN_OPEN: &str = ""; +const CURRENT_TURN_CLOSE: &str = ""; + +/// 轮次标记(opt-in,随 stabilize_prompt_prefix 生效):上下文重建(切模型/ +/// 压缩/换手)时,已闭环的旧问题可能被回放成「历史里最后一条指令」,模型无法 +/// 从文本层分辨新旧,于是出现回答几天前旧问题的串线。这里把「本轮要回答的 +/// 指令」显式钉在最后一条 user 消息上:固定文本、无计数,历史里早已解包的 +/// 消息逐字节不变,新增 token 只落在本来就要重算的消息流末尾,不碰前缀缓存。 +fn mark_current_user_turn(messages: &mut Vec) { + // 幂等:历史回放时上一轮的标记会随原文回到请求里,先全量解包再重新钉, + // 保证同一历史消息在任意请求里的字节一致(前缀缓存依赖这一点)。 + for message in messages.iter_mut() { + if message.get("role").and_then(Value::as_str) == Some("user") { + unwrap_current_turn_mark(message); + } + } + // 只在请求确实以 user 输入收尾时才钉标记(维护者 P1):等待工具结果后的 + // 继续请求末尾是 tool/assistant 角色,此时最后一条 user 是历史旧问题, + // 钉上会把历史问题误导成「当前问题」。非 user 收尾:解包已完成,直接返回。 + match messages + .last() + .and_then(|message| message.get("role").and_then(Value::as_str)) + { + Some("user") => wrap_current_turn_mark(messages.last_mut().unwrap()), + _ => return, + } +} + +/// 从 user 消息文本里剥离轮次标记。只作用于首轮文本段:包装只会碰首轮文本段, +/// 解包同样只碰它;中段出现同款文本属构造输入,保守不动。 +fn unwrap_current_turn_mark(message: &mut Value) { + match message.get_mut("content") { + Some(Value::String(text)) => *text = strip_current_turn_pair(text), + Some(Value::Array(parts)) => { + let first_index = parts + .iter() + .position(|part| part.get("type").and_then(Value::as_str) == Some("text")); + let Some(first_index) = first_index else { return }; + let text_count = parts + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .count(); + let texts: Vec = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str).map(str::to_string)) + .collect(); + // 多 text 段是历史拼接形态(旧轮原文+空行+末轮原文):先合并再解包, + // 保持与单段形态一致的字节语义。 + let merged = if text_count > 1 { + Some(texts.join("\n\n")) + } else { + texts.first().cloned() + }; + let Some(merged) = merged else { return }; + let stripped = strip_current_turn_pair(&merged); + if let Some(Value::String(text)) = parts[first_index].get_mut("text") { + *text = stripped; + } + if text_count > 1 { + let mut seen = 0usize; + for part in parts + .iter_mut() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + { + seen += 1; + if seen > 1 { + if let Some(Value::String(text)) = part.get_mut("text") { + *text = String::new(); + } + } + } + } + } + _ => {} + } +} + +fn strip_current_turn_pair(text: &str) -> String { + let Some(start) = text.find(CURRENT_TURN_OPEN) else { + return text.to_string(); + }; + let Some(end_rel) = text[start..].find(CURRENT_TURN_CLOSE) else { + return text.to_string(); + }; + let inner = &text[start + CURRENT_TURN_OPEN.len()..start + end_rel]; + let head = text[..start].trim_end_matches('\n'); + let tail = text[start + end_rel + CURRENT_TURN_CLOSE.len()..].trim_start_matches('\n'); + format!("{}{}{}", head, inner.trim_start_matches('\n'), tail) +} + +/// 把轮次标记钉进 user 消息的首个文本段;没有文本段(纯图片轮)则在头部新建一段。 +fn wrap_current_turn_mark(message: &mut Value) { + let marked = |text: &str| { + if text.trim().is_empty() { + text.to_string() + } else { + format!( + "{}\n{}\n{}\n", + CURRENT_TURN_OPEN, + text.trim_end(), + CURRENT_TURN_CLOSE + ) + } + }; + match message.get_mut("content") { + Some(Value::String(text)) => { + let next = marked(text); + *text = next; + } + Some(Value::Array(parts)) => { + if let Some(first) = parts + .iter_mut() + .find(|part| part.get("type").and_then(Value::as_str) == Some("text")) + { + if let Some(Value::String(text)) = first.get_mut("text") { + let next = marked(text); + *text = next; + } + } else { + parts.insert(0, json!({ "type": "text", "text": marked("") })); + // marked("") 为空时(无文本段的空轮)不新建,下面回收。 + if parts + .first() + .and_then(|part| part.get("text").and_then(Value::as_str)) + .map(str::is_empty) + .unwrap_or(false) + { + parts.remove(0); + } + } + } + _ => {} + } +} + +fn collapse_system_messages_to_head(messages: Vec, stabilize: bool) -> Vec { let mut system_chunks = Vec::new(); let mut rest = Vec::with_capacity(messages.len()); @@ -5061,10 +5947,13 @@ fn collapse_system_messages_to_head(messages: Vec) -> Vec { let mut output = Vec::with_capacity(rest.len() + usize::from(!system_chunks.is_empty())); if !system_chunks.is_empty() { - output.push(json!({ - "role": "system", - "content": system_chunks.join("\n\n") - })); + // 前缀稳定层开启时改走规范序拼接;关闭时与历史行为逐字节一致。 + let content = if stabilize { + order_system_chunks_stably(&system_chunks) + } else { + system_chunks.join("\n\n") + }; + output.push(json!({ "role": "system", "content": content })); } output.extend(rest); output @@ -7390,6 +8279,7 @@ mod response_compatibility_tests { "model":"gemini-3.8-flash", "input":"hi", "reasoning":{"effort":"high"} }), standard, + false, ) .unwrap(); assert_eq!(body["reasoning_effort"], "high"); @@ -7403,7 +8293,7 @@ mod response_compatibility_tests { .is_none() ); assert_eq!( - responses_to_chat_completions_with_options(request, true).unwrap()["reasoning_effort"], + responses_to_chat_completions_with_options(request, true, false).unwrap()["reasoning_effort"], "high" ); } @@ -7571,3 +8461,176 @@ mod compaction_probe_body_limit_tests { assert!(error.to_string().contains("上游响应体超过")); } } + +#[cfg(test)] +mod prefix_stabilizer_tests { + use super::*; + + const NOTICE_TEXT: &str = + "Image 1 of 1 in the preceding user message was resized from 1206x2622 to 942x2048 pixels."; + + const NOTICE_TEXT_OPEN: &str = "\nImage 1 of 1 in the preceding user message was resized from 1206x2622 to 942x2048 pixels."; + + fn notice() -> String { + format!("\n{NOTICE_TEXT}\n") + } + + #[test] + fn strip_removes_notice_from_string_and_part_content() { + let mut messages = vec![ + json!({ "role": "system", "content": notice() }), + json!({ "role": "system", "content": [{ "type": "text", "text": notice() }] }), + json!({ "role": "tool", "tool_call_id": "call_a", "content": notice() }), + json!({ "role": "user", "content": "keep" }), + ]; + strip_image_resize_notice_blocks(&mut messages); + assert_eq!(messages.len(), 1, "整条 notice 消息必须被删除:{messages:?}"); + assert_eq!(messages[0]["content"], "keep"); + } + + #[test] + fn strip_removes_dangling_markers_and_is_idempotent() { + let mut messages = vec![json!({ + "role": "user", + "content": format!("pre\n{}\npost\n dangling", notice()), + })]; + strip_image_resize_notice_blocks(&mut messages); + let once = messages.clone(); + strip_image_resize_notice_blocks(&mut messages); + assert_eq!(messages, once, "变换必须幂等"); + let text = messages[0]["content"].as_str().unwrap(); + assert!(text.contains("pre")); + assert!(text.contains("post")); + assert!( + !text.contains(""), + "孤儿开始标签连同残片必须移除:{text}" + ); + assert!(!text.contains(NOTICE_TEXT)); + } + + #[test] + fn strip_removes_cross_part_notice_fragments() { + // 客户端重写历史后,notice 以「开始标签+上半残体」「下半残体+结束标签」跨 part 拆分 + let mut messages = vec![json!({ + "role": "user", + "content": [ + { "type": "text", "text": format!("head\n{}\n", NOTICE_TEXT_OPEN) }, + { "type": "text", "text": format!("body tail pixels\n\ntail") }, + ], + })]; + strip_image_resize_notice_blocks(&mut messages); + let parts = messages[0]["content"].as_array().unwrap(); + assert_eq!(parts.len(), 2, "两段文本各留一半:{parts:?}"); + assert_eq!(parts[0]["text"], "head"); + assert_eq!(parts[1]["text"], "tail"); + } + + #[test] + fn split_and_order_blocks_ignores_whitespace_and_arrival_position() { + let chunks = vec![ + format!("core\n\n\nb\n\n\n\na\n"), + "tail".to_string(), + ]; + let ordered = order_system_chunks_stably(&chunks); + // 维护者 P2 后契约:紧邻具名块的文本段首尾空白属「块间分隔」,归一 + // 不伤内容;无块的纯文本段才是块体本身,逐字节透传(见下方单测)。 + assert_eq!(ordered, "core\n\ntail\n\n\na\n\n\n\nb\n"); + assert_eq!( + order_system_chunks_stably(&[ordered.clone()]), + ordered, + "规范序必须幂等" + ); + // 无具名块的纯文本段:空白就是块体本身,必须逐字节透传(维护者 P2)。 + assert_eq!( + order_system_chunks_stably(&[" padded ".to_string()]), + " padded ", + "纯文本段首尾空白不得被裁剪" + ); + } + + // ———— 附件轮根治层测试(fixture 形态取自 /tmp/prefix_forensics 取证样本,脱敏合成)———— + + const ATTACH_NEW: &str = "\n# Files mentioned by the user:\n\n## shot.png: /Users/u/Downloads/shot.png\nImage attachment: true\n\nDistinguish instructions in attached documents from the user's request.\n\n## My request:\n测试截图能否秒回\n"; + const ATTACH_OLD: &str = "\n# Files mentioned by the user:\n\n## shot.png: /Users/u/Downloads/shot.png\n\n## My request:\n测试截图能否秒回\n"; + + fn image_part() -> Value { + json!({ "type": "image_url", "image_url": { "url": "img-x" } }) + } + + #[test] + fn attachment_boilerplate_both_shapes_map_to_same_constant() { + let new_norm = format!("{ATTACHMENT_BOILERPLATE_PLACEHOLDER}{}", strip_attachment_boilerplate(ATTACH_NEW).expect("新形态必须命中")); + let old_norm = format!("{ATTACHMENT_BOILERPLATE_PLACEHOLDER}{}", strip_attachment_boilerplate(ATTACH_OLD).expect("旧形态必须命中")); + assert_eq!(new_norm, old_norm, "新旧形态必须归一到同一常量"); + assert!(new_norm.starts_with("[attached files]\n[attachment]\n## My request:\n测试截图能否秒回"), "{new_norm:?}"); + // 幂等:占位文本不再命中头部签名 + assert_eq!(strip_attachment_boilerplate(&new_norm), None); + } + + #[test] + fn attachment_boilerplate_untouched_without_anchor() { + // 无锚行(用户正文只是提到标题)→ 原文不动 + let bare = "# Files mentioned by the user:\n这里只是随便聊聊标题。"; + assert_eq!(strip_attachment_boilerplate(bare), None); + // 全量归一:当轮与历史轮样板头部都被替换,正文与图片部件保留 + let mut messages = vec![ + json!({ "role": "user", "content": [{ "type": "text", "text": ATTACH_NEW }] }), + json!({ "role": "assistant", "content": "ok" }), + json!({ "role": "user", "content": [{ "type": "text", "text": ATTACH_OLD }, image_part()] }), + ]; + normalize_attachment_boilerplate(&mut messages); + assert!(messages[0]["content"][0]["text"].as_str().unwrap().starts_with("[attached files]")); + assert!(messages[2]["content"][0]["text"].as_str().unwrap().starts_with("[attached files]")); + let left = &messages[2]["content"][1]; + assert_eq!(left, &image_part(), "图片部件必须原样保留"); + } + + #[test] + fn image_part_self_equality_smoke() { + // 三步探针:直接 assert / 局部绑定 assert / 原始布尔 + let a = image_part(); + let b = image_part().clone(); + assert_eq!(a, b, "step2 局部绑定"); + assert_eq!(image_part(), image_part().clone(), "step3 直接调用"); + assert!(a == b, "step1"); + } + + #[test] + fn attachment_turn_rewrite_divergence_eliminated_end_to_end() { + // 门 b:复现 22:46 形态。open_page 声明块此前就在 system 前缀中(null 值), + // 塌陷轮块内容被重写(page_id 出值)且历史带图 user 消息的样板被客户端 + // 重渲染增长(旧形态 → 新形态)。经稳定层后公共前缀必须跨过图片之前的 + // 全部历史(旧实现分别在样板首行与块体内分叉)。 + let sys_null = "BASE SYSTEM\n\nThe codex_apps_open_page context records the Page. page_id=null"; + let sys_rewrite = "BASE SYSTEM\n\nThe codex_apps_open_page context records the Page. page_id=live-77\nextra volatile line"; + let mut a_msgs = vec![ + json!({ "role": "system", "content": sys_null }), + json!({ "role": "user", "content": [{ "type": "text", "text": ATTACH_OLD }, image_part()] }), + json!({ "role": "assistant", "content": "第一轮答复" }), + ]; + let mut b_msgs = vec![ + json!({ "role": "system", "content": sys_rewrite }), + json!({ "role": "user", "content": [{ "type": "text", "text": ATTACH_NEW }, image_part()] }), + json!({ "role": "assistant", "content": "第一轮答复" }), + json!({ "role": "user", "content": "新问题" }), + ]; + normalize_attachment_boilerplate(&mut a_msgs); + normalize_attachment_boilerplate(&mut b_msgs); + let a = collapse_system_messages_to_head(a_msgs, true); + let b = collapse_system_messages_to_head(b_msgs, true); + let a_json = serde_json::to_string(&a).unwrap(); + let b_json = serde_json::to_string(&b).unwrap(); + let common = a_json + .bytes() + .zip(b_json.bytes()) + .take_while(|(x, y)| x == y) + .count(); + // a 的全部消息必须完整落在公共前缀内(分叉只允许在 a 的收尾边界之后) + let boundary = a_json.rfind("第一轮答复").expect("fixture 缺陷") + "第一轮答复".len(); + assert!(common >= boundary, "公共前缀 {common} 必须跨过历史图片轮边界 {boundary}(旧版在此分叉)"); + // 且分叉点必须在图片 data URL 之后 + let image_at = a_json.find("img-x").expect("fixture 缺陷"); + assert!(common > image_at, "分叉不得发生在图片之前:common={common}, image_at={image_at}"); + } + +} diff --git a/crates/codex-plus-core/src/provider_import.rs b/crates/codex-plus-core/src/provider_import.rs index a1560bf84..835633da4 100644 --- a/crates/codex-plus-core/src/provider_import.rs +++ b/crates/codex-plus-core/src/provider_import.rs @@ -214,6 +214,7 @@ fn relay_profile_from_request( custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, } } diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index a964d7ebe..a294276ee 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -122,6 +122,15 @@ pub struct RelayProfile { skip_serializing_if = "is_false" )] pub standard_openai_protocol: bool, + /// 「prompt 前缀稳定层」开关:剥离 image_resize_notice、规范化 system + /// 头部注入块顺序。默认关=转发行为逐字节不变。 + /// 前端 UI 暂未接入,设置解析/序列化先保持完整。 + #[serde( + rename = "stabilizePromptPrefix", + default, + skip_serializing_if = "is_false" + )] + pub stabilize_prompt_prefix: bool, #[serde( rename = "webSearchHistoryCompat", default, @@ -266,6 +275,7 @@ impl Default for RelayProfile { custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, } } } @@ -873,6 +883,7 @@ impl BackendSettings { custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, }; } @@ -930,6 +941,7 @@ impl BackendSettings { custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, } } @@ -4137,6 +4149,35 @@ experimental_bearer_token = "sk-existing""# }; assert_eq!(profile.web_search_history_compat_enabled(), expected); } + + } + + #[test] + fn relay_profile_stabilize_prompt_prefix_defaults_off_for_legacy_profiles() { + // 旧 profile 没有该字段:反序列化后默认关(行为逐字节不变)。 + let mut enabled = RelayProfile::default(); + enabled.stabilize_prompt_prefix = true; + let mut legacy = serde_json::to_value(&enabled).unwrap(); + legacy + .as_object_mut() + .unwrap() + .remove("stabilizePromptPrefix"); + let profile: RelayProfile = serde_json::from_value(legacy).unwrap(); + assert!(!profile.stabilize_prompt_prefix); + } + + #[test] + fn relay_profile_stabilize_prompt_prefix_round_trip() { + // 关闭时导出不写该字段(与 standardOpenaiProtocol 同一策略);开启后能完整读回。 + let value = serde_json::to_value(RelayProfile::default()).unwrap(); + assert!(value.get("stabilizePromptPrefix").is_none()); + + let mut enabled = RelayProfile::default(); + enabled.stabilize_prompt_prefix = true; + let value = serde_json::to_value(&enabled).unwrap(); + assert_eq!(value["stabilizePromptPrefix"], json!(true)); + let round_tripped: RelayProfile = serde_json::from_value(value).unwrap(); + assert!(round_tripped.stabilize_prompt_prefix); } // 自动接管的两个设置字段必须走既有 settings.json 链路持久化: // 守护线程在另一个线程里读同一个文件,写不进就等于开关无效。 diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index e235fa8ee..c9e656b5d 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -1912,6 +1912,7 @@ async fn launch_starts_helper_when_chat_protocol_proxy_is_enabled() { custom_headers: Vec::new(), standard_openai_protocol: false, web_search_history_compat: false, + stabilize_prompt_prefix: false, }], active_relay_id: "relay-chat".to_string(), ..BackendSettings::default() diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 1fdb2b72e..6f4acb808 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -251,7 +251,7 @@ async fn web_search_history_proxy_declares_only_for_standard_responses() { ); assert!(headers.starts_with(&format!("POST {path} HTTP/1.1"))); } else { - let expected = responses_to_chat_completions_with_options(request, false).unwrap(); + let expected = responses_to_chat_completions_with_options(request, false, false).unwrap(); assert_eq!(sent, expected, "Chat conversion must remain unchanged"); assert!(headers.starts_with("POST /v1/chat/completions HTTP/1.1")); } @@ -395,6 +395,7 @@ fn chat_conversion_rejects_encrypted_agent_content_without_echoing_payload() { let error = responses_to_chat_completions_with_options( json!({"model":"custom-model","input":input}), standard, + false, ) .expect_err("加密 agent 内容必须明确拒绝,不能静默丢弃或视为明文"); let diagnostic = format!("{error:#}"); @@ -1822,10 +1823,10 @@ fn responses_request_maps_kimi_coding_reasoning_effort_per_official_spec() { fn kimi_k3_standard_protocol_avoids_adaptive_and_preserves_opt_in_vendor_behavior() { for model in ["kimi-k3", "k3-256k"] { let request = json!({"model":model,"reasoning":{"effort":"xhigh"},"input":"hello"}); - let standard = responses_to_chat_completions_with_options(request.clone(), true).unwrap(); + let standard = responses_to_chat_completions_with_options(request.clone(), true, false).unwrap(); assert!(standard.get("thinking").is_none()); assert!(standard.get("reasoning_effort").is_none()); - let vendor = responses_to_chat_completions_with_options(request, false).unwrap(); + let vendor = responses_to_chat_completions_with_options(request, false, false).unwrap(); assert_eq!(vendor["thinking"]["type"], "adaptive"); assert_eq!(vendor["reasoning_effort"], "max"); assert_eq!(standard["messages"], vendor["messages"]); @@ -1845,6 +1846,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert!(minimax.get("reasoning_split").is_none()); @@ -1859,6 +1861,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert!(glm.get("thinking").is_none()); @@ -1871,6 +1874,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert!(qwen.get("enable_thinking").is_none()); @@ -1883,6 +1887,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert!(openrouter.get("reasoning").is_none()); @@ -1896,6 +1901,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert_eq!(deepseek["reasoning_effort"], "xhigh"); @@ -1908,6 +1914,7 @@ fn responses_request_standard_protocol_strips_vendor_reasoning_dialects() { "input": "hi" }), true, + false, ) .unwrap(); assert_eq!(gpt5["reasoning_effort"], "high"); @@ -7031,3 +7038,799 @@ async fn native_compaction_preserves_protocol_and_opaque_state() { } } } + +// --------------------------------------------------------------------------- +// 「prompt 前缀稳定层」(mediafix4)测试。 +// 变换为 opt-in(RelayProfile.stabilize_prompt_prefix,默认关=转发行为逐字节 +// 不变);开启时剥离 image_resize_notice、system 头部块进入规范序。 +// --------------------------------------------------------------------------- + +fn injection_block(name: &str, body: &str) -> String { + format!("<{name}>\n{body}\n") +} + +fn resize_notice_block(body: &str) -> String { + injection_block("image_resize_notice", body) +} + +fn developer_messages(chunks: &[String]) -> Vec { + chunks + .iter() + .map(|chunk| { + json!({ + "type": "message", + "role": "developer", + "content": [{ "type": "input_text", "text": chunk }], + }) + }) + .collect() +} + +#[test] +fn prefix_stabilizer_strips_resize_notice_from_every_role_and_leaves_no_empty_message() { + let notice = resize_notice_block( + "Image 1 of 1 in the preceding user message was resized from 1206x2622 to 942x2048 pixels.", + ); + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "root system", + "input": [ + { "type": "message", "role": "user", "content": "first" }, + // 纯 notice 的 system(developer)消息:应整条删除,不能折叠成空 system + { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": notice.clone() }] }, + { "type": "function_call", "call_id": "call_a", "name": "shell", "arguments": "{}" }, + { "type": "function_call_output", "call_id": "call_a", "output": "done" }, + // 夹心在 tool 结果之间的 user 消息:删除 notice 后仅保留前后文本 + { "type": "message", "role": "user", "content": format!("prelude\n{notice}\ncoda") }, + // 非成对残片(开始标签+上半残体 / 下半残体+结束标签):应连同残片整体移除 + { "type": "message", "role": "user", "content": "\nbody fragment 1206x2622 upper" }, + { "type": "message", "role": "user", "content": "lower half 942x2048 pixels\n" }, + ], + }); + + let stabilized = responses_to_chat_completions_with_options(body.clone(), false, true).unwrap(); + let rendered = serde_json::to_string(&stabilized["messages"]).unwrap(); + assert!( + !rendered.contains("resized from 1206x2622"), + "完整 notice 块必须被剥离:{rendered}" + ); + + let messages = stabilized["messages"].as_array().unwrap(); + let system_messages: Vec<&Value> = messages + .iter() + .filter(|message| message["role"] == "system") + .collect(); + assert_eq!( + system_messages.len(), + 1, + "纯 notice 的 system 消息应被整条删除,头部只留非空 system:{rendered}" + ); + assert_eq!(system_messages[0]["content"], "root system"); + + for message in messages.iter() { + let blank = match &message["content"] { + // 带 tool_calls/reasoning 的骨架消息 content 为空是既有正常形态 + Value::String(text) => { + text.trim().is_empty() + && message.get("tool_calls").is_none() + && message.get("reasoning_content").is_none() + }, + Value::Array(parts) => parts.is_empty(), + _ => false, + }; + assert!(!blank, "剥离后不得留下空消息:{message}"); + } + + let mixed = messages + .iter() + .find(|message| { + message + .get("content") + .and_then(Value::as_str) + .is_some_and(|text| text.contains("prelude")) + }) + .expect("夹心 user 消息应保留 notice 前后的文本"); + // 夹心轮是请求末轮时会带轮次标记,标记外的正文必须完整保留。 + assert_eq!( + mixed["content"], + "\nprelude\n\ncoda\n\n" + ); + + let joined = messages + .iter() + .map(|message| message["content"].as_str().unwrap_or_default()) + .collect::>() + .join("|"); + assert!( + !joined.contains("image_resize_notice"), + "孤儿标签不得出现在任何消息里:{joined}" + ); + assert!( + !joined.contains("body fragment 1206x2622 upper") && !joined.contains("lower half 942x2048 pixels"), + "残片正文必须随孤儿标签一并移除:{joined}" + ); + + // 开关关闭时 notice 原样保留、位于原位(即与现行为一致) + let plain = responses_to_chat_completions_with_options(body, false, false).unwrap(); + let plain_rendered = serde_json::to_string(&plain["messages"]).unwrap(); + assert!( + plain_rendered.contains("resized from 1206x2622 to 942x2048 pixels."), + "默认关闭时必须保留 notice 原文:{plain_rendered}" + ); +} + +#[test] +fn prefix_stabilizer_orders_system_blocks_identically_for_any_arrival_order() { + let apps = injection_block("apps_instructions", "apps body"); + let collab = injection_block("collaboration_mode", "collab body"); + let multi = injection_block("multi_agent_mode", "multi body"); + let fresh = injection_block("fresh_unknown_block", "fresh body"); + + let make_body = |chunks: Vec| { + json!({ + "model": "MiniMax-M2.7", + "instructions": "core instructions text", + "input": developer_messages(&chunks), + }) + }; + + let first = make_body(vec![multi.clone(), collab.clone(), apps.clone(), fresh.clone()]); + // 第二种变体:换到达顺序,并跨消息拆分/合并,改变块的到达边界 + let second = make_body(vec![format!("{apps}\n\n{fresh}"), multi.clone(), collab.clone()]); + + let a = responses_to_chat_completions_with_options(first.clone(), false, true).unwrap(); + let b = responses_to_chat_completions_with_options(second, false, true).unwrap(); + assert_eq!( + serde_json::to_string(&a["messages"]).unwrap(), + serde_json::to_string(&b["messages"]).unwrap(), + "同块集合任意到达顺序,变换输出必须逐字节相等" + ); + // 规范序:普通文本段在前(到达序),具名块按块名字典序钉在块尾 + let expected = format!("core instructions text\n\n{apps}\n\n{collab}\n\n{fresh}\n\n{multi}"); + assert_eq!(a["messages"][0]["content"].as_str().unwrap(), expected); + + // 旧行为(关闭开关)严格按到达序拼接,块随插入位置轮轮平移 + let old = responses_to_chat_completions_with_options(first.clone(), false, false).unwrap(); + let old_expected = format!("core instructions text\n\n{multi}\n\n{collab}\n\n{apps}\n\n{fresh}"); + assert_eq!(old["messages"][0]["content"].as_str().unwrap(), old_expected); + let old_shuffled = + responses_to_chat_completions_with_options(make_body(vec![apps, multi, collab, fresh]), false, false) + .unwrap(); + assert_ne!( + old["messages"][0], old_shuffled["messages"][0], + "关闭开关时输出必须对到达顺序敏感(反证稳定层生效)" + ); +} + +#[test] +fn prefix_stabilizer_transformation_is_idempotent() { + let apps = injection_block("apps_instructions", "apps body"); + let collab = injection_block("collaboration_mode", "collab body"); + let notice = resize_notice_block("resized from 100x200 to 50x100 pixels."); + let chunks = vec![collab.clone(), format!("{notice}\n\n{apps}")]; + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core", + "input": developer_messages(&chunks), + }); + + let first = responses_to_chat_completions_with_options(body.clone(), false, true).unwrap(); + let system_once = first["messages"][0]["content"].as_str().unwrap(); + // 规范序:core 在前,之后按块名字典序 + assert_eq!(system_once, format!("core\n\n{apps}\n\n{collab}")); + + // 幂等:把第一轮输出当新一轮输入再变换,逐字节不变 + let mut round2 = body.clone(); + round2["instructions"] = json!(system_once); + round2["input"] = json!([]); + let second = responses_to_chat_completions_with_options(round2, false, true).unwrap(); + assert_eq!(second["messages"][0]["content"].as_str().unwrap(), system_once); +} + +#[test] +fn prefix_stabilizer_disabled_output_matches_snapshot_and_wrapper_defaults() { + // 「默认关」快照:开关关闭时的转发 messages 逐字节快照。 + // 该快照固化了现行为(notice 原文保留、developer 注入块按到达序平移插入、 + // tool_call 规范化形态),稳定层若意外改变关闭路径会立刻打破快照。 + let notice = resize_notice_block("resized from 1206x2622 to 942x2048 pixels."); + let collab = injection_block("collaboration_mode", "mode body"); + let mut input = vec![ + json!({ "type": "message", "role": "user", "content": "hello" }), + json!({ "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }), + json!({ "type": "function_call", "call_id": "call_a", "name": "shell", "arguments": "{}" }), + json!({ "type": "function_call_output", "call_id": "call_a", "output": "done" }), + json!({ "type": "message", "role": "user", "content": format!("pre\n{notice}\npost") }), + ]; + // developer 注入块插在 tool 结果之后:固化夹心形态在关闭路径下的现状 + input.extend(developer_messages(&[collab])); + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "root system", + "input": input, + }); + let snapshot = r#"[{"content":"root system\n\n\nmode body\n","role":"system"},{"content":"hello","role":"user"},{"content":"hi","role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"shell"},"id":"call_a","type":"function"}]},{"content":"done","role":"tool","tool_call_id":"call_a"},{"content":"pre\n\nresized from 1206x2622 to 942x2048 pixels.\n\npost","role":"user"}]"#; + let disabled = responses_to_chat_completions_with_options(body.clone(), false, false).unwrap(); + let rendered = serde_json::to_string(&disabled["messages"]).unwrap(); + assert_eq!(rendered, snapshot, "关闭开关的输出与快照不再一致(现行为被改变)"); + + // 公开入口(等价于现网默认路径)与显式关闭必须完全一致 + let wrapper = responses_to_chat_completions(body).unwrap(); + assert_eq!( + serde_json::to_string(&wrapper["messages"]).unwrap(), + rendered, + "默认入口与显式关闭输出必须逐字节一致" + ); +} + +#[tokio::test] +async fn prefix_stabilizer_setting_flows_from_profile_to_upstream_body() { + let _lock = settings_path_test_lock().lock().unwrap(); + let response = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 43\r\n\r\n{\"id\":\"r1\",\"object\":\"response\",\"output\":[]}"; + let make_settings = |stabilize: bool, upstream: String| BackendSettings { + relay_profiles: vec![RelayProfile { + id: "relay-stab".to_string(), + name: "Stab".to_string(), + base_url: upstream.clone(), + upstream_base_url: upstream, + protocol: RelayProtocol::ChatCompletions, + relay_mode: RelayMode::PureApi, + no_auth: true, + stabilize_prompt_prefix: stabilize, + ..RelayProfile::default() + }], + active_relay_id: "relay-stab".to_string(), + ..BackendSettings::default() + }; + let request = json!({ + "model": "test-model", + "instructions": "core", + "input": developer_messages(&[ + resize_notice_block("resized on"), + injection_block("collaboration_mode", "mode body"), + ]), + }) + .to_string(); + + let on = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let on_addr = on.local_addr().unwrap(); + let off = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let off_addr = off.local_addr().unwrap(); + let on_server = tokio::spawn(capture_request_and_respond_once(on, response)); + let off_server = tokio::spawn(capture_request_and_respond_once(off, response)); + + let on_result = open_responses_proxy_request_with_settings( + &request, + make_settings(true, format!("http://{on_addr}/v1")), + ) + .await + .unwrap(); + on_result.response.expect("上游响应").bytes().await.unwrap(); + let off_result = open_responses_proxy_request_with_settings( + &request, + make_settings(false, format!("http://{off_addr}/v1")), + ) + .await + .unwrap(); + off_result.response.expect("上游响应").bytes().await.unwrap(); + + let on_raw = on_server.await.unwrap(); + let off_raw = off_server.await.unwrap(); + assert!( + !on_raw.contains("image_resize_notice"), + "开关开启时发往上游的 body 不得含 notice:{on_raw}" + ); + assert!(on_raw.contains("collaboration_mode"), "具名注入块必须保留:{on_raw}"); + assert!( + off_raw.contains("image_resize_notice"), + "开关关闭时必须保持现行为(notice 透传):{off_raw}" + ); +} + +#[test] +fn prefix_stabilizer_unpaired_fragments_do_not_diverge_prefix() { + // 根因回归:非成对 notice 残片随轮次以不同长度/后缀重写进历史前缀区时, + // 两次请求的归一化输出必须逐字节一致,且上游看不到任何残片文本。 + let open_head = concat!("<", "image_resize_notice", ">\nbody fragment "); + let close_tail = concat!("\n"); + let make_body = |frag: &str, tail: &str| { + json!({ + "model": "MiniMax-M2.7", + "instructions": "root system", + "input": [ + { "type": "message", "role": "user", "content": "first" }, + { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": format!("{frag}{tail}") }] }, + { "type": "message", "role": "user", "content": format!("next {tail}") }, + ], + }) + }; + let a = responses_to_chat_completions_with_options( + make_body(&format!("{open_head}1206x2622 upper"), "alpha"), + false, + true, + ) + .unwrap(); + let b = responses_to_chat_completions_with_options( + make_body(&format!("{open_head}800x600 mid"), "beta"), + false, + true, + ) + .unwrap(); + // 公共前缀(system 头部 + 已发生的历史消息)必须逐字节一致; + // 当前轮新增的 user 消息允许不同,属预期追加而非分叉。 + let common_prefix = |v: &serde_json::Value| { + let messages = v["messages"].as_array().unwrap(); + serde_json::to_string(&messages[..messages.len() - 1]).unwrap() + }; + assert_eq!( + common_prefix(&a), + common_prefix(&b), + "非成对残片轮必须与另一残片轮产出逐字节一致的公共前缀" + ); + let rendered = serde_json::to_string(&a["messages"]).unwrap(); + assert!(!rendered.contains("image_resize_notice"), "上游不得看到残片:{rendered}"); + assert!(!rendered.contains("1206x2622"), "残片正文不得进入上游:{rendered}"); + // 残片消息被整体清除后,历史只剩两条 user 消息与头部 system + let messages = a["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 3, "残片消息应整条删除:{rendered}"); + // 另一轮里的 close_tail 孤儿结束标签同样不得泄漏 + let c = responses_to_chat_completions_with_options( + json!({ + "model": "MiniMax-M2.7", + "instructions": "root system", + "input": [ + { "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": format!("orphan body{close_tail}") }] }, + { "type": "message", "role": "user", "content": "keep" }, + ], + }), + false, + true, + ) + .unwrap(); + let c_rendered = serde_json::to_string(&c["messages"]).unwrap(); + assert!( + !c_rendered.contains("image_resize_notice") && !c_rendered.contains("orphan body"), + "孤儿结束标签轮同样必须归零:{c_rendered}" + ); + assert_eq!(c["messages"].as_array().unwrap().len(), 2, "纯残片 developer 消息应整条删除"); +} + +// --------------------------------------------------------------------------- +// 易变重注入具名块「同名去重、保留最后实例(dedup-last)」测试。 +// 根因(SYS_STEP_FORENSICS.md):codex 客户端累积重注入 +// codex_apps_client_time_context 时区锚块(单实例 271B,wire 步进 +273B), +// 历史不去重,每次步进首轮全历史 fork。稳定层在规范序之上做同名折叠, +// 保留到达序最后一个实例,块体零改写。 +// --------------------------------------------------------------------------- + +/// 时区锚块原文:与客户端实际注入逐字节同构(无任何敏感字段)。 +fn time_context_block(date: &str) -> String { + format!( + "Asia/Shanghai\n{date}\nUse this client time context for user-facing dates, times, and schedules instead of the execution host\'s timezone and current date." + ) +} + +/// 走稳定层变换,返回头部 system 消息文本。 +fn stabilized_system(chunks: &[String]) -> String { + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core instructions text", + "input": developer_messages(chunks), + }); + let stabilized = responses_to_chat_completions_with_options(body, false, true).unwrap(); + stabilized["messages"][0]["content"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn prefix_stabilizer_time_context_same_name_instances_collapse_to_one() { + let t = time_context_block("2026-10-10"); + let single = stabilized_system(&[t.clone()]); + assert_eq!(single, format!("core instructions text\n\n{t}"), "单实例场景必须原样保留块体"); + // 同一消息内同名两份:合并后与单实例逐字节相等 + let twice_same_chunk = stabilized_system(&[format!("{t}\n\n{t}")]); + assert_eq!(twice_same_chunk, single, "同一消息内同名实例必须合并为一份且逐字节相等"); + // 跨消息两份:同样与单实例逐字节相等 + let twice_two_chunks = stabilized_system(&[t.clone(), t.clone()]); + assert_eq!(twice_two_chunks, single, "跨消息同名实例必须合并为一份且逐字节相等"); +} + +#[test] +fn prefix_stabilizer_time_context_keeps_last_arrived_instance() { + let d10 = time_context_block("2026-10-10"); + let d11 = time_context_block("2026-10-11"); + let out = stabilized_system(&[d10.clone(), d11.clone()]); + assert_eq!( + out, + format!("core instructions text\n\n{d11}"), + "同名内容不同时保留到达序最后一份(时间语义取最新),块体零改写" + ); + // 契约以到达序为准:最后一份是旧的则保留旧的(现网到达序即时间序,旧实例恒在前) + let out_rev = stabilized_system(&[d11.clone(), d10.clone()]); + assert_eq!(out_rev, format!("core instructions text\n\n{d10}"), "dedup-last 以到达序为准"); +} + +#[test] +fn prefix_stabilizer_time_context_permutation_of_same_content_is_stable() { + let t = time_context_block("2026-10-10"); + let apps = injection_block("apps_instructions", "apps body"); + let collab = injection_block("collaboration_mode", "collab body"); + let first = stabilized_system(&[t.clone(), collab.clone(), apps.clone(), t.clone()]); + let second = stabilized_system(&[format!("{apps}\n\n{t}"), t.clone(), collab.clone()]); + assert_eq!(first, second, "同名实例内容全等时,任意到达排列输出必须逐字节一致"); + assert_eq!( + first, + format!("core instructions text\n\n{apps}\n\n{t}\n\n{collab}"), + "规范序:apps_instructions < codex_apps_client_time_context < collaboration_mode,折叠后保留块区自然位置" + ); +} + +#[test] +fn prefix_stabilizer_time_context_dedup_is_idempotent() { + let d10 = time_context_block("2026-10-10"); + let d11 = time_context_block("2026-10-11"); + // 现网到达序即时间序:旧实例在前、最新在后,折叠保留最新 + let first = stabilized_system(&[d10.clone(), d10.clone(), d11.clone()]); + assert_eq!(first, format!("core instructions text\n\n{d11}")); + // 输出再切块再排序:第一轮输出回填 instructions、无新增输入,必须逐字节不变 + let round2 = json!({ "model": "MiniMax-M2.7", "instructions": first, "input": [] }); + let second = responses_to_chat_completions_with_options(round2, false, true).unwrap(); + assert_eq!( + second["messages"][0]["content"].as_str().unwrap(), + first, + "幂等契约被破坏:对输出再变换不再逐字节相等" + ); +} + +#[test] +fn prefix_stabilizer_unregistered_repeat_blocks_keep_every_instance() { + // 未注册名单的相似块名(前缀相近但不相等)与未知重复块:逐实例保留、零改写 + let similar_1 = injection_block("codex_apps_client_time_context_extra", "instance one body"); + let similar_2 = injection_block("codex_apps_client_time_context_extra", "instance two body"); + let unknown = injection_block("legacy_env_context", "legacy instance"); + let out = stabilized_system(&[similar_1.clone(), unknown.clone(), similar_2.clone(), unknown.clone()]); + assert_eq!( + out, + format!("core instructions text\n\n{similar_1}\n\n{similar_2}\n\n{unknown}\n\n{unknown}"), + "名单外同名块必须逐实例保留且零改写(opt-in 边界不破)" + ); +} + +// —— turn_aborted 状态块摘除(2026-10-11 探针基线实锤的重启分叉毒源)—— + +fn turn_aborted_body(with_block: bool) -> Value { + let aborted = "\nThe previous turn was interrupted on purpose. Any running unified exec processes may still be running in the background.\n"; + let developer = if with_block { + format!("tools doc\n\n{aborted}") + } else { + "tools doc".to_string() + }; + json!({ + "model": "MiniMax-M2.7", + "instructions": "core instructions text", + "input": [ + {"role": "developer", "content": developer}, + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "answer"}, + {"role": "user", "content": "second question"}, + ], + }) +} + +#[test] +fn prefix_stabilizer_turn_aborted_moved_before_last_user_message() { + let out = responses_to_chat_completions_with_options(turn_aborted_body(true), false, true).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let head = messages[0]["content"].as_str().unwrap(); + assert!(!head.contains(""), "system 头部不得残留 turn_aborted 块"); + let last_user = messages + .iter() + .rposition(|m| m["role"].as_str() == Some("user")) + .expect("必须存在 user 消息"); + assert_eq!( + messages[last_user - 1]["content"].as_str().unwrap(), + turn_aborted_body(true)["input"][0]["content"] + .as_str() + .unwrap() + .split("\n\n") + .nth(1) + .unwrap() + .trim_end(), + "块必须原样搬到最后一条 user 消息之前" + ); + assert_eq!( + messages[last_user]["content"].as_str().unwrap(), + "\nsecond question\n\n", + "末轮同时带轮次标记(turn_aborted 块仍在标记外、其之前)" + ); +} + +#[test] +fn prefix_stabilizer_turn_aborted_appearance_keeps_system_head_pure_append() { + // 核心不变式:块出现与否不得改变 system 头部字节(重启/中断轮不再前缀分叉) + let without = responses_to_chat_completions_with_options(turn_aborted_body(false), false, true).unwrap(); + let with = responses_to_chat_completions_with_options(turn_aborted_body(true), false, true).unwrap(); + assert_eq!( + without["messages"][0]["content"], with["messages"][0]["content"], + "turn_aborted 注入/撤除不得扰动 system 头部字节" + ); + // 摘除块后两条消息序列前缀一致(with 仅多出搬移的块,落在瞬态区) + let head_without = serde_json::to_string(&without["messages"][0]).unwrap(); + let head_with = serde_json::to_string(&with["messages"][0]).unwrap(); + assert_eq!(head_without, head_with); +} + +#[test] +fn prefix_stabilizer_turn_aborted_malformed_block_left_untouched() { + // 只见开标签的残缺块:保守放弃,system 不动 + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core instructions text", + "input": [ + {"role": "developer", "content": "tools doc\n\n dangling without close"}, + {"role": "user", "content": "q"}, + ], + }); + let out = responses_to_chat_completions_with_options(body, false, true).unwrap(); + let head = out["messages"][0]["content"].as_str().unwrap(); + assert!(head.contains(""), "残缺块必须原样保留,宁留分叉不腐蚀 system"); +} + +#[test] +fn prefix_stabilizer_turn_aborted_stays_when_stabilizer_disabled() { + // opt-in 边界:开关关闭时行为与旧版逐字节一致(块留在 system) + let out = responses_to_chat_completions_with_options(turn_aborted_body(true), false, false).unwrap(); + let rendered = serde_json::to_string(&out["messages"]).unwrap(); + assert!(rendered.contains(""), "关开关不得改动任何字节"); +} + +// —— web_search_call 历史回放(多 Agent 会话重复搜索/空 FINAL 的根治)—— + +fn web_search_input(action: Value) -> Vec { + vec![ + json!({"role": "user", "content": "帮我调研麦当劳设计语言"}), + json!({"type": "web_search_call", "id": "ws_call_1", "status": "completed", "action": action}), + json!({"role": "user", "content": "继续"}), + ] +} + +#[test] +fn web_search_call_history_preserves_query_and_sources_when_present() { + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core", + "input": web_search_input(json!({ + "type": "search", + "query": "mcdonald design language", + "sources": [{"url": "https://example.com/a", "title": "A"}] + })), + }); + let out = responses_to_chat_completions(body).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let call = messages + .iter() + .find(|m| m["role"] == "assistant" && m.get("tool_calls").is_some()) + .expect("web_search 调用必须回放为 assistant tool_calls"); + let fn_name = call["tool_calls"][0]["function"]["name"].as_str().unwrap(); + assert_eq!(fn_name, "web_search"); + let args = call["tool_calls"][0]["function"]["arguments"].as_str().unwrap(); + assert!(args.contains("mcdonald design language"), "query 必须透传给模型"); + let tool_msg = messages + .iter() + .find(|m| m["role"] == "tool") + .expect("必须回放 tool 结果消息"); + assert_eq!(tool_msg["tool_call_id"].as_str().unwrap(), "ws_call_1"); + assert!(tool_msg["content"].as_str().unwrap().contains("example.com"), "sources 必须透传"); +} + +#[test] +fn web_search_call_history_stripped_by_client_informs_model_instead_of_vanishing() { + // 2026-10-11 实锤形态:codex 客户端把 query/results 剥离,只留 completed 标记。 + // 旧行为:整项静默丢弃 → 模型失忆重搜(3 轮 8 次)。新行为:如实告知。 + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core", + "input": web_search_input(json!({"type": "search"})), + }); + let out = responses_to_chat_completions(body).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let tool_msg = messages + .iter() + .find(|m| m["role"] == "tool") + .expect("剥离形态也必须留下回放痕迹,不得整项消失"); + let text = tool_msg["content"].as_str().unwrap(); + assert!(text.contains("completed") && text.contains("Do not repeat"), "必须告知状态并劝阻重复搜索"); + // 配对完整性:tool 消息必须紧跟在带同 id tool_calls 的 assistant 之后 + let idx_tool = messages.iter().position(|m| m["role"] == "tool").unwrap(); + let assistant = &messages[idx_tool - 1]; + assert_eq!(assistant["role"].as_str().unwrap(), "assistant"); + assert_eq!(assistant["tool_calls"][0]["id"].as_str().unwrap(), "ws_call_1"); +} + +#[test] +fn web_search_call_history_idempotent_second_conversion_stable() { + // 同一历史二次请求:回放消息为转换层产物,不进入客户端历史,字节必稳 + let body = json!({ + "model": "MiniMax-M2.7", + "instructions": "core", + "input": web_search_input(json!({"type": "search"})), + }); + let first = serde_json::to_string(&responses_to_chat_completions(body.clone()).unwrap()["messages"]).unwrap(); + let second = serde_json::to_string(&responses_to_chat_completions(body).unwrap()["messages"]).unwrap(); + assert_eq!(first, second, "转换层输出必须确定"); +} + +// —— 轮次标记(串线根治:上下文重建时旧问题被回放成最新指令)—— + +fn current_turn_body(last_text: &str) -> Value { + json!({ + "model": "test-model", + "instructions": "core", + "input": [ + { "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": "old stale question from days ago" } ] }, + { "type": "message", "role": "assistant", "content": [ + { "type": "output_text", "text": "old answer" } ] }, + { "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": last_text } ] } + ] + }) +} + +#[test] +fn current_turn_mark_wraps_only_last_user_message() { + let out = responses_to_chat_completions_with_options(current_turn_body("answer this now"), false, true).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let users: Vec<&Value> = messages.iter().filter(|m| m["role"] == "assistant" || m["role"] == "user").collect(); + let first_user = messages.iter().find(|m| m["role"] == "user").unwrap(); + assert_eq!( + first_user["content"].as_str().unwrap(), + "old stale question from days ago", + "历史 user 消息必须逐字节不变(前缀缓存依赖)" + ); + let last = &messages[users.len() - 1 + messages.iter().filter(|m| m["role"] == "system").count()]; + let marked = last["content"].as_str().unwrap(); + assert!(marked.starts_with("\n"), "末轮必须带开标签: {marked}"); + assert!(marked.contains("answer this now")); + assert!(marked.ends_with("\n"), "末轮必须带闭标签: {marked}"); +} + +#[test] +fn current_turn_mark_idempotent_across_replay() { + // 第一轮输出(含标记)作为历史回放 + 新增末轮:第二轮输出里旧轮必须解包回原文, + // 且同一历史在两次请求里字节一致。 + let first = responses_to_chat_completions_with_options(current_turn_body("question one"), false, true).unwrap(); + let first_messages = first["messages"].as_array().unwrap(); + let mut replay: Vec = first_messages + .iter() + .filter(|m| { + let role = m["role"].as_str().unwrap(); + role == "user" || role == "assistant" + }) + .map(|m| { + let content = m["content"].as_str().unwrap(); + let part_type = if m["role"] == "user" { "input_text" } else { "output_text" }; + json!({ "type": "message", "role": m["role"], "content": [ + { "type": part_type, "text": content } ] }) + }) + .collect(); + replay.push(json!({ "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": "question two" } ] })); + let second = responses_to_chat_completions_with_options( + json!({ "model": "test-model", "instructions": "core", "input": replay }), + false, + true, + ) + .unwrap(); + let second_messages = second["messages"].as_array().unwrap(); + let old_user = second_messages + .iter() + .filter(|m| m["role"] == "user") + .next() + .unwrap(); + assert_eq!( + old_user["content"].as_str().unwrap(), + "old stale question from days ago", + "标记解包后历史首轮字节必须回到原始形态" + ); + let marked_users: Vec<&Value> = second_messages + .iter() + .filter(|m| { + m["role"] == "user" + && m["content"] + .as_str() + .map(|t| t.contains("")) + .unwrap_or(false) + }) + .collect(); + assert_eq!(marked_users.len(), 1, "任意时刻只允许末轮 user 带标记"); + assert!(marked_users[0]["content"] + .as_str() + .unwrap() + .contains("question two")); +} + +#[test] +fn current_turn_mark_wraps_first_text_part_of_array_content() { + // 截图轮:末轮 content 是多段数组(文本+图片),标记只能进首个文本段,图片段不动。 + let body = json!({ + "model": "test-model", + "instructions": "core", + "input": [ + { "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": "check this screenshot" }, + { "type": "input_image", "image_url": "data:image/png;base64,AAAA" } ] } + ] + }); + let out = responses_to_chat_completions_with_options(body, false, true).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let last = messages.iter().rev().find(|m| m["role"] == "user").unwrap(); + let parts = last["content"].as_array().unwrap(); + assert!(parts[0]["text"] + .as_str() + .unwrap() + .starts_with("\n")); + assert_eq!(parts[1]["type"].as_str().unwrap(), "image_url", "图片段不得被动"); +} + +#[test] +fn current_turn_mark_absent_when_stabilizer_off() { + // opt-in 语义:开关关闭时转发行为逐字节不变。 + let out = responses_to_chat_completions(current_turn_body("answer this now")).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let last = messages.iter().rev().find(|m| m["role"] == "user").unwrap(); + assert_eq!(last["content"].as_str().unwrap(), "answer this now"); +} + +#[test] +fn current_turn_mark_skipped_when_request_ends_with_tool_result() { + // 维护者 P1:等待工具结果后的继续请求里,最后一条 user 是历史旧问题; + // 无条件回钉会把旧问题误导成「当前问题」。此类请求只解包、不钉定。 + let body = json!({ + "input": [ + { "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": "the historical question" } ] }, + { "type": "function_call", "name": "shell", "call_id": "call_p1" }, + { "type": "function_call_output", "call_id": "call_p1", "output": "tool says ok" } + ] + }); + let out = responses_to_chat_completions_with_options(body, false, true).unwrap(); + let messages = out["messages"].as_array().unwrap(); + let users: Vec<&Value> = messages.iter().filter(|m| m["role"] == "user").collect(); + assert_eq!(users.len(), 1); + assert_eq!( + users[0]["content"].as_str().unwrap(), + "the historical question", + "非 user 收尾时最后一条 user 必须原文透传,不得回钉轮次标记" + ); + assert!( + !out.to_string().contains(""), + "非 user 收尾的请求不得出现轮次标记" + ); +} + +#[test] +fn stabilizer_keeps_chunk_body_whitespace_verbatim() { + // 维护者 P2:规范序只规范块间分隔,不得裁剪块体自身首尾空白 + //(「非易变内容逐字节不变」的 opt-in 契约)。 + let body = json!({ + "instructions": " padded root system ", + "input": [ + { "type": "message", "role": "user", "content": [ + { "type": "input_text", "text": "hi" } ] } + ] + }); + let out = responses_to_chat_completions_with_options(body, false, true).unwrap(); + let system = out["messages"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "system") + .unwrap(); + let content = system["content"].as_str().unwrap(); + assert!( + content.starts_with(" padded") && content.ends_with("system "), + "块体首尾空白必须原样透传: {content:?}" + ); +}