//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。 //! //! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中 //! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages` //! 等历史调用路径不变)。 //! //! # 函数概览 //! - [`sanitize_messages`]:主入口(step0 active 过滤 → step1/2/3 正向 orphan → //! step3.5 反向 orphan → step4 序列合法性)。 //! - [`drop_reverse_orphans`]:step3.5 反向 orphan 检测。 //! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。 //! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。 use crate::context_helpers::{PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX}; use crate::provider::{ChatMessage, MessageRole, ToolCall}; /// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史 /// /// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176) /// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史, /// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv /// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对, /// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。 /// /// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的 /// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头: /// /// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。 /// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。 /// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其 /// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。 /// /// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。 pub fn sanitize_messages(messages: Vec) -> Vec { use std::collections::HashSet; // step 0过滤 truncated 软删消息,不进 LLM 上下文。 // 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除, // 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。 let messages: Vec = messages .into_iter() .filter(|m| m.is_active()) .collect(); // step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。 // 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧): // 协议要求 tool 响应必须紧随其 assistant(tool_calls);原实现按「id 全局存在」判定 // (位置无关),会把被 system/user 隔开的游离响应误判为已闭合,放行畸形序列致 400 // 「insufficient tool messages」。故判定改为「紧后连续段覆盖」——未覆盖的 call 视同 // 未闭合处理(剥除),其游离响应随后由 step3 按 orphaned_ids 丢弃,自动闭环。 let mut adjacency: std::collections::HashMap> = std::collections::HashMap::new(); for i in 0..messages.len() { if !matches!(messages[i].role, MessageRole::Assistant) { continue; } let mut seg: HashSet = HashSet::new(); let mut j = i + 1; while j < messages.len() && matches!(messages[j].role, MessageRole::Tool) { if let Some(id) = messages[j].tool_call_id.as_deref() { seg.insert(id.to_string()); } j += 1; } adjacency.insert(i, seg); } // step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合 // orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢 // partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的) // full_drop_heads:全未闭合的头——整条丢弃 let mut orphaned_ids: HashSet = HashSet::new(); let mut full_drop_heads = 0u32; let mut partial_heads = 0u32; // 记录部分闭合头中「应保留的 id」,用于 step3 精确重写 let mut partial_keep: std::collections::HashMap> = std::collections::HashMap::new(); for (i, m) in messages.iter().enumerate() { if !matches!(m.role, MessageRole::Assistant) { continue; } let Some(calls) = m.tool_calls.as_ref() else { continue }; if calls.is_empty() { continue; } let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls .iter() .partition(|c| { !c.id.starts_with(TOOL_MISSING_PREFIX) && adjacency.get(&i).is_some_and(|s| s.contains(&c.id)) }); match (resolved.is_empty(), unresolved.is_empty()) { // 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids (true, false) => { full_drop_heads += 1; for c in &unresolved { orphaned_ids.insert(c.id.clone()); } } // 全闭合 → 原样保留 (false, true) => {} // 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids (false, false) => { partial_heads += 1; for c in &unresolved { orphaned_ids.insert(c.id.clone()); } partial_keep.insert(i, resolved.into_iter().cloned().collect()); } // resolved/unresolved 都空不可能(calls 非空已保证) (true, true) => {} } } let after_triplet: Vec = if orphaned_ids.is_empty() && partial_keep.is_empty() { messages } else { // step 3:保序过滤 + 部分闭合头重写 tool_calls let sanitized: Vec = messages .into_iter() .enumerate() .filter_map(|(i, mut m)| match m.role { MessageRole::Assistant => { let Some(calls) = m.tool_calls.as_ref() else { return Some(m) }; if calls.is_empty() { return Some(m); } if let Some(keep) = partial_keep.get(&i) { // 部分闭合:重写 tool_calls 为仅已闭合子集 m.tool_calls = Some(keep.clone()); Some(m) } else { // 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃 let all_orphaned = calls.iter().all(|c| orphaned_ids.contains(&c.id)); if all_orphaned { None } else { Some(m) } } } MessageRole::Tool => { // 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留 if m .tool_call_id .as_deref() .is_some_and(|id| orphaned_ids.contains(id)) { None } else { Some(m) } } _ => Some(m), }) .collect(); // debug 级:同份畸形历史每请求重复触发,降级防告警洪水;根治=ai_messages 配对清洗(DB 原始保留不动) tracing::debug!( full_drop_heads, partial_rewrite_heads = partial_heads, orphaned_tool_results = orphaned_ids.len(), "history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)" ); sanitized }; // step 3.5(tool_result 补头自愈):tool_result 无对应 tool_call 头 → 补占位头自愈(不丢)。 // // 根因(三元组完整性 P0):压缩/裁剪/正向 orphan 处理(step2/3)可能丢弃 assistant tool_call 头, // 但其 tool_result 残留 → orphan tool_result(无头)。若按"丢 result"处理,LLM 看不到工具结果 // → AI Coding 静默停(根因)。本步骤改"补头自愈":对每个 orphan tool_result 就地补一个占位 // assistant tool_call 头(id 复用 result 的 tool_call_id),使三元组闭合(tool_result 保留, // LLM 仍能看到工具结果)。provider 协议铁律("每个 tool_call.id 必须有 tool_result")满足, // 不再 400/500。 // // 与 step2/3 互补:step2/3 管"头丢 result"(正向 orphan:整头或部分闭合),step3.5 管"result 丢头" // (反向 orphan:头已被丢但 result 残留)。补头是兜底自愈——根因在压缩/裁剪破坏三元组原子性, // 由 build_eviction_units / compress_old_messages 保证(见 mod.rs 三元组原子保护),但历史中毒 // /异常数据 /DB 加载的畸形历史仍可能残留 orphan result,故本层兜底。 // // **占位/非占位一视同仁**:占位 result(审批挂起)与普通工具结果同属"LLM 需看到的工具结果", // 一律补头保留(不丢)。补头后 ensure_sequence_legal 合并连续 assistant(防补头插在 assistant // 后产生连续同 role→400)。 // // view-only:不改持久化(仅改传入 Vec clone),持久化全量保留。 let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED { drop_reverse_orphans(after_triplet) } else { after_triplet }; // step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。 ensure_sequence_legal(after_reverse_orphan) } /// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位 /// assistant tool_call 头,而非丢弃 tool_result)。 /// /// 详见 [`sanitize_messages`] step3.5 注释。每个 tool_result 的 id 不在任何保留头 tool_calls 内 /// 即 orphan。本函数不丢 result(LLM 仍能看到工具结果),而是**就地补一个 TOOL_MISSING_PREFIX /// 占位头**(id 复用 result 的 tool_call_id 精确配对),使三元组闭合,满足 provider 协议。 /// /// 补头后跑一次 [`ensure_sequence_legal`] 合并连续 assistant(占位头可能插在 assistant 后产生 /// 连续同 role → provider 400/1214,合并吸收)。 /// /// **历史沿革**:此前对非占位 orphan result 直接丢弃,会丢失工具结果致 LLM 看不到工具执行 /// → AI Coding 静默停(三元组完整性 P0 根因)。占位 result 保留但靠出口断言补头,本步骤对 /// 占位与非占位统一改"补头自愈":任何工具结果都不丢(除非无 tool_call_id 的异常数据,无法 /// 补头配对,只能保留——由协议层兜底)。 /// /// **未闭合 vs TOOL_MISSING_PREFIX 头**:判定只看 head_ids.contains——真配对(占位头在)由 /// contains 覆盖,不会重复补头;头不在的 prefix id result(前轮占位头被裁剪丢)即 orphan, /// 同样补头自愈。补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头与 /// result 改写后 id 一致,唯一配对闭合(双层前缀为已知无害副作用)。 pub fn drop_reverse_orphans(messages: Vec) -> Vec { use std::collections::HashSet; // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。 // 注:含 TOOL_MISSING_PREFIX 占位头 id(出口断言/上一轮 sanitize 补的),与 result 配对即合法。 let head_ids: HashSet = messages .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .map(|c| c.id.clone()) .collect(); let mut healed = 0u32; let mut unidentifiable_kept = 0u32; let mut out: Vec = Vec::with_capacity(messages.len() + 4); for m in messages.into_iter() { if !matches!(m.role, MessageRole::Tool) { out.push(m); continue; } // 无 id 的 tool_result(异常数据):无法补头配对(占位头需复用 result 的 id), // 保留不动——丢弃会丢工具结果(LLM 看不到),违背三元组完整性目标。 // 协议层对无 id tool_result 自有兜底(anthropic_compat flush 合并/丢弃)。 let Some(id) = m.tool_call_id.as_deref() else { unidentifiable_kept += 1; out.push(m); continue; }; // 已配对(头在)→ 原样保留。TOOL_MISSING_PREFIX 前缀 id 无需特判:真配对(占位头在) // 已被 contains 覆盖;头不在(前轮占位头被裁剪丢掉)即是 orphan,同样走补头自愈。 // 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),头+result 改写后 // id 一致,唯一配对闭合,功能正确(双层前缀为已知无害副作用)。 if head_ids.contains(id) { out.push(m); continue; } // orphan tool_result(无头)→ **补占位头自愈**(不丢 result)。 // 头 id 复用 result 的 tool_call_id 精确配对(同 assert_placeholder_pairing 自愈模式)。 let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head = ChatMessage::assistant_with_tools( String::new(), vec![ToolCall::new(head_id.clone(), "recovered_tool_call", "{}")], ); out.push(head); // result 的 tool_call_id 改写为占位头 id,使三者精确配对(头 id == result id)。 let mut result = m; result.tool_call_id = Some(head_id); out.push(result); healed += 1; } if healed > 0 || unidentifiable_kept > 0 { tracing::debug!( healed_orphan_tool_results = healed, unidentifiable_tool_results_kept = unidentifiable_kept, "history sanitized: healed orphan tool_results by inserting placeholder heads (view-only, persisted history untouched)" ); // 补占位头可能插在 assistant 后产生连续 assistant(普通 assistant + 占位 assistant + // tool_result)→ Anthropic/GLM 协议连续同 role 400/1214。再过一次 ensure_sequence_legal // 合并连续 assistant(占位头 tool_calls 并入前一头,合并后配对仍闭合)。 // 同 assert_placeholder_pairing 出口加固(连续 assistant 防护)。 return ensure_sequence_legal(out); } out } /// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有 /// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。 /// /// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有 /// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢), /// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。 /// /// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属 /// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩 /// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。 /// /// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan /// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议 /// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的 /// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。 /// /// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。 /// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串 /// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。 /// /// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。 pub fn assert_placeholder_pairing( messages: Vec, enabled: bool, ) -> Vec { if !enabled { return messages; } // 收集现有头的 tool_call.id(已配对的) use std::collections::HashSet; let head_ids: HashSet = messages .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .map(|c| c.id.clone()) .collect(); // 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。 // 注:TOOL_MISSING_PREFIX 前缀 id 不豁免——真配对(占位头在)已被 head_ids.contains // 覆盖;头不在的 prefix id result(前轮占位头被裁剪丢)正是 orphan,必须补头闭合。 // 补出的头 id 为前缀叠加(tool_missing_tool_missing_X,双层前缀),唯一配对,功能正确。 // 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。 let mut healed = 0u32; let mut out: Vec = Vec::with_capacity(messages.len()); for m in messages.into_iter() { if matches!(m.role, MessageRole::Tool) { if let Some(id) = m.tool_call_id.as_deref() { if !head_ids.contains(id) { // orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对) let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id); let head = ChatMessage::assistant_with_tools( String::new(), vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")], ); out.push(head); // 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对 let mut result = m; result.tool_call_id = Some(head_id); out.push(result); healed += 1; continue; } } } out.push(m); } if healed > 0 { tracing::debug!( healed_orphan_tool_results = healed, "send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)" ); // 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant // (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role // 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获 // 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其 // tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头 // 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。 // // view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。 return ensure_sequence_legal(out); } out } /// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。 /// /// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤) /// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result /// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。 /// /// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。 /// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user, /// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数, /// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。 /// /// **不制造 orphan(自洽,根本修)**:开头 skip assistant 头时,**显式连带 skip 其后命中的 tool_result** /// (id 在被 skip 头的 tool_calls 内)。旧实现依赖隐式巧合——367 行 `Assistant | Tool` 联合判断 + /// `fixed.is_empty()` 在整组 skip 期间保持 true,使开头整组(head + result)被一起丢。这个契约脆弱: /// (1) 若有人改开头 skip 只针对 Assistant(为修连续 role),立刻制造 orphan; /// (2) step3.5(`drop_reverse_orphans`)在 step4 前跑,无法预见 step4 自身 skip 制造的 orphan; /// (3) memory `ai-router-sanitize-refactor-debt` 走查标 P1 隐式契约债。 /// 根本修:step4 主动跟踪自己 skip 掉的 head 的 tool_call.id,后续 tool_result 命中即连带丢, /// 把"不制造 orphan"从隐式巧合变为显式机制——不依赖下游 `assert_placeholder_pairing` 出口断言兜底。 /// 保留 367 行"开头 tool_result 也 skip"(防御性兜底:防 step3.5 未跑/开关关闭时孤儿 result 漏网)。 pub fn ensure_sequence_legal(messages: Vec) -> Vec { use std::collections::HashSet; let mut skipped = 0u32; let mut merged = 0u32; // 被 step4 自身 skip 掉的 assistant 头的 tool_call.id 集合。 // 后续 tool_result 若命中(id 在此集合),说明其头被 step4 skip 掉了 → 连带丢(不制造 orphan)。 let mut skipped_head_ids: HashSet = HashSet::new(); let mut fixed: Vec = Vec::with_capacity(messages.len()); for m in messages { // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)。 // assistant 头被 skip 时,记录其 tool_call.id,后续 result 命中即连带丢(根本修:不制造 orphan)。 if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) { if matches!(m.role, MessageRole::Assistant) { if let Some(calls) = m.tool_calls.as_ref() { for c in calls { skipped_head_ids.insert(c.id.clone()); } } } skipped += 1; continue; } // 自洽兜底(根本修):tool_result 的 id 命中被 skip 头集合 → 其头已被 step4 自身丢, // 留下即 orphan(直送 provider 400)。显式连带丢,不依赖下游出口断言兜底。 // 注:仅丢"step4 自己制造的 orphan",step3.5 已补头的 result 不受影响(其头在 fixed 中,id 不在集合)。 if matches!(m.role, MessageRole::Tool) { if m.tool_call_id .as_deref() .is_some_and(|id| skipped_head_ids.contains(id)) { skipped += 1; continue; } } // 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并—— // 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id) if let Some(last) = fixed.last_mut() { let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role); if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) { if !m.content.is_empty() { if !last.content.is_empty() { last.content.push('\n'); } last.content.push_str(&m.content); } if matches!(m.role, MessageRole::Assistant) { if let Some(calls) = m.tool_calls { last.tool_calls.get_or_insert_with(Vec::new).extend(calls); } } // 多模态 parts 随合并迁移:空 content 纯图消息的 content 拼接为空操作, // parts 不迁移则图片片随整条消息丢弃,图对 LLM 隐身 if let Some(parts) = m.parts { last.parts.get_or_insert_with(Vec::new).extend(parts); } merged += 1; continue; } } fixed.push(m); } if skipped > 0 || merged > 0 { tracing::debug!( skipped, merged, "序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)" ); } fixed } #[cfg(test)] mod tests { use super::*; use crate::context::ContextManager; use crate::context_helpers::ContextConfig; fn cfg(max_tokens: u32) -> ContextConfig { ContextConfig { max_tokens, output_reserve: 0, safety_ratio: 1.0, } } #[test] fn sanitize_drops_placeholder_id_triplet() { // 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result // → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("调用工具")); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("tool_missing_0", "read_file", "{}")], )); mgr.push(ChatMessage::tool_result("tool_missing_0", "结果")); let (msgs, _trimmed) = mgr.build_for_request(0); // 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除"); assert_eq!(msgs[0].content, "调用工具"); // 持久化全量保留(自愈只改发送视图) assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量"); } #[test] fn sanitize_drops_unresolved_tool_call_head() { // 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")], )); // 两个 call 都无 result → 整头丢弃 let (msgs, _trimmed) = mgr.build_for_request(0); assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant) || m.tool_calls.as_ref().is_none_or(|c| c.is_empty())), "全未闭合的 assistant 头应被剔除"); assert!(msgs.is_empty(), "全未闭合应整条删"); assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留"); } #[test] fn sanitize_partial_resolve_rewrites_head() { // 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result // provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。 // 「不能整条删」——保住已发生的合法工具交互(call_b)历史。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("触发工具")); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")], )); // 只有 call_b 有 result → call_a 未闭合 mgr.push(ChatMessage::tool_result("call_b", "b 结果")); let (msgs, _trimmed) = mgr.build_for_request(0); // 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b let assistant_heads: Vec<_> = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some()) .collect(); assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)"); let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap(); assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call"); assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b"); assert!( head_calls.iter().all(|c| c.id != "call_a"), "未闭合的 call_a 应从重写后的 tool_calls 中移除" ); // tool_result 只剩 call_b 的,user 前置保留 let tool_msgs: Vec<_> = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result"); assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b")); assert!( msgs.iter() .any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"), "无关 user 消息不应被误删" ); assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量"); } #[test] fn sanitize_keeps_well_formed_triplet() { // 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。 // 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问题")); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("call_a", "fn_a", "{}")], )); mgr.push(ChatMessage::tool_result("call_a", "结果")); let (msgs, _trimmed) = mgr.build_for_request(0); assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除"); } // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── /// 邻接性回归(2026-10-09 400 根因):tool 响应与 head 之间被夹层(system/user)隔开时, /// 闭合判定按「紧后 tool 连续段覆盖」而非「id 全局存在」——被隔开的 call 视同未闭合 /// (剥除/整头丢),其游离响应随 orphaned_ids 一并丢弃,保证发送序列满足「head 后紧邻其 /// 响应」。防 OpenAI 400「insufficient tool messages following tool_calls message」。 #[test] fn sanitize_strips_calls_when_response_separated_by_interloper() { // [user] [assistant(c1,c2)] [system 夹层] [tool c1] [tool c2] let msgs = vec![ ChatMessage::user("go"), ChatMessage::assistant_with_tools( "", vec![ ToolCall::new("c1".to_string(), "t", "{}"), ToolCall::new("c2".to_string(), "t", "{}"), ], ), ChatMessage::system("夹层提示"), ChatMessage::tool_result("c1", "r1"), ChatMessage::tool_result("c2", "r2"), ]; let out = sanitize_messages(msgs); // 夹层隔开 → 两 call 均不在紧后连续段 → 无任何带 tool_calls 的 assistant 存活 assert!( out.iter() .all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), "被夹层隔开的 call 应被剥除(不得残留 head+tool_calls), 实际 {:?}", out ); // 游离响应无头配对 → 丢弃;序列内不得残留 role=Tool assert!( out.iter().all(|m| !matches!(m.role, MessageRole::Tool)), "游离 tool 响应应被丢弃, 实际 {:?}", out ); } #[test] fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 // 旧行为是丢 result,致 LLM 看不到工具结果 → AI Coding 静默停(根因)。 // 新行为:补 TOOL_MISSING_PREFIX 占位头(id 复用 result 的 tool_call_id),tool_result 保留, // 三元组闭合。LLM 仍能看到"孤儿结果无头"这条工具结果。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("orphan_id", "孤儿结果无头"), ]; let sanitized = sanitize_messages(msgs); // tool_result 必须保留(不丢) let kept_tool: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); assert_eq!(kept_tool.len(), 1, "orphan tool_result 应被保留(补头自愈),不丢"); assert_eq!( kept_tool[0].content, "孤儿结果无头", "tool_result 原文应保留(LLM 看到工具结果)" ); // 必补一个 TOOL_MISSING_PREFIX 占位头,且其 id 与 result 的 tool_call_id 配对 let healed_heads: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX)) .collect(); assert_eq!(healed_heads.len(), 1, "应补 1 个 TOOL_MISSING_PREFIX 占位头"); // 占位头 id 含原 tool_call_id(精确配对) assert!( healed_heads[0].id.contains("orphan_id"), "补的头 id 应含原 tool_call_id, 实际 {}", healed_heads[0].id ); // result 的 tool_call_id 被改写为占位头 id(三者精确配对,闭合三元组) assert_eq!( kept_tool[0].tool_call_id.as_deref(), Some(healed_heads[0].id.as_str()), "result id 应与补的头 id 一致(闭合配对)" ); } #[test] fn sanitize_keeps_reverse_orphan_pending_placeholder() { // 三元组完整性 P0:反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ // step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 result)。 // 验证:占位 tool_result 保留(不丢),且补了占位头配对闭合。 let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1"; let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("call_pending_1", placeholder_content), ]; let sanitized = sanitize_messages(msgs); let kept: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); assert_eq!(kept.len(), 1, "pending 占位 tool_result 应被补头自愈保留,不丢"); // result 的 tool_call_id 被改写为占位头 id(配对闭合) assert!( kept[0] .tool_call_id .as_deref() .is_some_and(|id| id.starts_with(TOOL_MISSING_PREFIX) && id.contains("call_pending_1")), "占位 result id 应改写为 TOOL_MISSING_PREFIX+原 id(闭合配对), 实际 {:?}", kept[0].tool_call_id ); } #[test] fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() { // 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX // 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。 let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2"; let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问题")); mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content)); let (msgs, _trimmed) = mgr.build_for_request(0); // 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id let healed_heads: Vec<_> = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .filter(|c| c.id.starts_with("tool_missing_")) .collect(); assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头"); // 占位头 id 应含原 tool_call_id(精确配对) assert!( healed_heads[0].id.contains("call_pending_2"), "补的头 id 应含原 tool_call_id, 实际 {}", healed_heads[0].id ); // result 的 tool_call_id 也被改写为占位头 id(三者精确配对) let result_id = msgs .iter() .find(|m| matches!(m.role, MessageRole::Tool)) .and_then(|m| m.tool_call_id.clone()) .expect("应有 tool_result"); assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)"); // 持久化全量保留(view-only) assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化"); } #[test] fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() { // 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问题")); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("call_ok", "fn", "{}")], )); mgr.push(ChatMessage::tool_result("call_ok", "正常结果")); let (msgs, _trimmed) = mgr.build_for_request(0); // 无 tool_missing_ 头(正常三元组不需自愈) let missing_heads = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .filter(|c| c.id.starts_with("tool_missing_")) .count(); assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头"); } #[test] fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() { // 加固(连续 assistant→400):出口断言 assert_placeholder_pairing 补占位头插在 orphan // tool_result 正前方,若 orphan result 的前一条恰是 assistant(无 tool_result 隔开), // 则补头后产生**连续 assistant**(前 assistant + 占位 assistant + tool_result)→ // Anthropic/GLM 协议 400/1214。出口断言补头后必须再过一次 ensure_sequence_legal 合并 // 连续 assistant,使其不触发 provider 拒绝。 // // 本测**直接测出口函数**(不经 sanitize_messages):把原始 orphan 序列喂 // assert_placeholder_pairing,验证它补头 + 合并连续 assistant 的自愈逻辑独立可用 // (sanitize step3.5 也补头,但出口断言是兜底防线,须独立验证)。 // // 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) → // orphan tool_result(call_pending_cc,无头)。出口断言在 result 正前方补占位头 // (其前驱正是 assistant)→ 补后序列 user → assistant(纯文本) → assistant(占位头) → // tool_result = 连续 assistant(补头后未合并则会 400)。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::assistant("纯文本回复(无 tool_calls)"), ChatMessage::tool_result("call_pending_cc", "工具结果无头"), ]; // 直接喂出口断言(模拟绕过 sanitize 的场景:如 src-tauri 侧压缩后未过 sanitize 直送) let healed = assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED); // 断言:补了占位头(tool_missing_ 前缀 id,含原 call_pending_cc) let missing_heads: Vec<_> = healed .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX)) .collect(); assert_eq!( missing_heads.len(), 1, "应补 1 个占位头, 实际 {:?}", missing_heads ); // 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。 // 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant + // 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。 let mut prev_is_assistant = false; let mut consecutive = 0u32; for m in &healed { let is_assistant = matches!(m.role, MessageRole::Assistant); if is_assistant && prev_is_assistant { consecutive += 1; } prev_is_assistant = is_assistant; } assert_eq!( consecutive, 0, "补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}", healed .iter() .map(|m| format!("{:?}", m.role)) .collect::>() ); // 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]), // 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头 // 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。 let assistant_msgs: Vec<_> = healed .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .collect(); assert_eq!( assistant_msgs.len(), 1, "连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}", assistant_msgs.len(), healed.iter().map(|m| format!("{:?}", m.role)).collect::>() ); // 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls let merged = &assistant_msgs[0]; assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content"); let merged_call_ids: Vec<&str> = merged .tool_calls .as_ref() .map(|cs| cs.iter().map(|c| c.id.as_str()).collect()) .unwrap_or_default(); assert!( merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX) && id.contains("call_pending_cc")), "合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}", merged_call_ids ); // 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对 let result_id = healed .iter() .find(|m| matches!(m.role, MessageRole::Tool)) .and_then(|m| m.tool_call_id.clone()) .expect("应有 tool_result"); assert!( merged_call_ids.contains(&result_id.as_str()), "result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}", result_id, merged_call_ids ); } #[test] fn drop_reverse_orphans_preserves_pending_with_legacy_text() { // 边界(三元组完整性 P0):老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ // step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 tool_result)。 // 验证:tool_result 保留(补头后闭合三元组,LLM 看到"等待审批")。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"), ]; let sanitized = sanitize_messages(msgs); let kept = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(); assert_eq!(kept, 1, "老占位(纯文本无标记)应被补头自愈保留(向前兼容)"); } #[test] fn sanitize_mixed_multi_message_sequence() { // 场景 4:连续多条消息混合—— // [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user] // 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("开场")); // 全闭合三元组 mgr.push(ChatMessage::assistant_with_tools( "调1", vec![ToolCall::new("ok_1", "fn", "{}")], )); mgr.push(ChatMessage::tool_result("ok_1", "ok 结果")); // 中毒占位 id 三元组(全未闭合)→ 整删 mgr.push(ChatMessage::assistant_with_tools( "调2", vec![ToolCall::new("tool_missing_0", "fn", "{}")], )); mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果")); // 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1 mgr.push(ChatMessage::assistant_with_tools( "调3", vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")], )); mgr.push(ChatMessage::tool_result("keep_1", "keep 结果")); mgr.push(ChatMessage::user("收尾")); let (msgs, _trimmed) = mgr.build_for_request(0); // 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1 let head_ids: Vec> = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some()) .map(|m| { m.tool_calls .as_ref() .unwrap() .iter() .map(|c| c.id.clone()) .collect() }) .collect(); assert!( head_ids.iter().all(|ids| { ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1") }), "保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids ); let tool_ids: Vec = msgs .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .filter_map(|m| m.tool_call_id.clone()) .collect(); // ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在 assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留"); assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留"); assert!( tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"), "无主 tool_result 应被剔除, 实际 {:?}", tool_ids ); // 两条 user 消息原样保留 let user_contents: Vec<&str> = msgs .iter() .filter(|m| matches!(m.role, MessageRole::User)) .map(|m| m.content.as_str()) .collect(); assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删"); assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量"); } // ============================================================ // 三元组完整性 P0/P1(P0 修消息三元组:工具结果被吞根因) // ============================================================ #[test] fn drop_reverse_orphans_heals_multiple_orphan_results_each_gets_head() { // 边界(多 orphan):多个不同 id 的 orphan tool_result → 各补独立占位头,三者各自配对。 // 防共享一个占位头 id 致配对错乱(每 result 一个独立 TOOL_MISSING_PREFIX+id 头)。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("orphan_a", "结果A"), ChatMessage::tool_result("orphan_b", "结果B"), ]; let sanitized = drop_reverse_orphans(msgs); // 每个 orphan 都补了头(2 个 TOOL_MISSING_PREFIX 占位头,各自含原 id) let healed_heads: Vec = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .filter_map(|c| { if c.id.starts_with(TOOL_MISSING_PREFIX) { Some(c.id.clone()) } else { None } }) .collect(); assert_eq!(healed_heads.len(), 2, "两个 orphan 各补一个独立占位头"); assert!( healed_heads.iter().any(|id| id.contains("orphan_a")), "应有含 orphan_a 的头, 实际 {:?}", healed_heads ); assert!( healed_heads.iter().any(|id| id.contains("orphan_b")), "应有含 orphan_b 的头, 实际 {:?}", healed_heads ); // 两 result 都保留,各自 tool_call_id 改写为对应占位头 id(独立配对) let result_ids: Vec = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .filter_map(|m| m.tool_call_id.clone()) .collect(); assert_eq!(result_ids.len(), 2, "两 orphan tool_result 都应保留"); for rid in &result_ids { assert!( healed_heads.contains(rid), "每个 result id 应与某个补头 id 配对, rid={} heads={:?}", rid, healed_heads ); } } #[test] fn drop_reverse_orphans_heals_prefix_id_orphan_with_nested_prefix_head() { // 边界(盲区修复回归):orphan result 的 id 已是 TOOL_MISSING_PREFIX 但占位头不在 // (前轮占位头被裁剪丢)→ 即 orphan,同样补头自愈(旧"前缀即已配对"豁免会吞掉此场景, // 三元组不闭合直送 provider 400)。补出的头 id 为双层前缀叠加 // (tool_missing_tool_missing_X),头与 result 改写后 id 一致,唯一配对闭合。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("tool_missing_already", "已补过头的结果"), ]; let sanitized = drop_reverse_orphans(msgs); // result 保留(不丢) assert_eq!( sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(), 1, "TOOL_MISSING_PREFIX id 的 orphan result 应保留(补头自愈)" ); // 补双层前缀占位头(头 id 含原 id 且以前缀开头) let healed_heads: Vec<&str> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .map(|c| c.id.as_str()) .collect(); assert_eq!( healed_heads.len(), 1, "prefix id orphan 应补 1 个占位头, 实际 {:?}", healed_heads ); assert!( healed_heads[0].starts_with(TOOL_MISSING_PREFIX) && healed_heads[0][TOOL_MISSING_PREFIX.len()..].starts_with(TOOL_MISSING_PREFIX), "补的头 id 应为双层前缀叠加(tool_missing_tool_missing_X), 实际 {}", healed_heads[0] ); // result 的 tool_call_id 改写为占位头 id(闭合配对) let result_id = sanitized .iter() .find(|m| matches!(m.role, MessageRole::Tool)) .and_then(|m| m.tool_call_id.clone()) .expect("应有 tool_result"); assert_eq!( result_id, healed_heads[0], "result id 应与补的头 id 一致(闭合配对)" ); } #[test] fn drop_reverse_orphans_no_id_result_kept_unmodified() { // 边界(异常数据):tool_result 无 tool_call_id(无法补头配对)→ 保留不动(不丢,不补)。 // 丢弃会丢工具结果违背三元组完整性目标;无法补头只能保留,由协议层兜底。 let mut orphan = ChatMessage::tool_result("", "无 id 结果"); orphan.tool_call_id = None; let msgs = vec![ChatMessage::user("问题"), orphan]; let sanitized = drop_reverse_orphans(msgs); // 无 id result 保留(不丢) let kept = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(); assert_eq!(kept, 1, "无 id 的 tool_result 应保留(无法补头,不丢)"); } #[test] fn drop_reverse_orphans_consecutive_assistant_merged_after_heal() { // 加固(连续 assistant→400):orphan result 前驱是纯文本 assistant(无 tool_calls)→ // 补占位头后产生连续 assistant(纯文本 assistant + 占位 assistant)→ 应被 // ensure_sequence_legal 合并(占位头 tool_calls 并入前驱,合并后配对仍闭合)。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::assistant("纯文本回复"), ChatMessage::tool_result("orphan_x", "孤儿结果"), ]; let sanitized = drop_reverse_orphans(msgs); // 不应有连续 assistant(补头后已合并) let mut prev_is_assistant = false; let mut consecutive = 0u32; for m in &sanitized { let is_assistant = matches!(m.role, MessageRole::Assistant); if is_assistant && prev_is_assistant { consecutive += 1; } prev_is_assistant = is_assistant; } assert_eq!(consecutive, 0, "补占位头后不应有连续 assistant(应被合并)"); // 合并后唯一 assistant 含占位头 tool_calls(并入前驱纯文本 assistant) let assistant_msgs: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .collect(); assert_eq!(assistant_msgs.len(), 1, "连续 assistant 应合并为 1 条"); assert_eq!( assistant_msgs[0].content, "纯文本回复", "合并后应保留前驱纯文本 content" ); let has_missing = assistant_msgs[0] .tool_calls .as_ref() .map(|cs| cs.iter().any(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("orphan_x"))) .unwrap_or(false); assert!(has_missing, "合并后前驱 assistant 应含占位头 tool_call"); } #[test] fn sanitize_compress_path_preserves_tool_result_via_heal() { // 三元组完整性 P0(端到端 + 压缩路径):模拟压缩/裁剪破坏三元组(头被丢但 result 留), // sanitize_messages 应补头自愈保留 tool_result(LLM 看到工具结果),不静默停。 // // 场景:历史里有 user + assistant 调用工具(tc_x)+ tool_result(tc_x)。 // 假设压缩/裁剪错误地丢掉了 assistant 头(模拟 head 丢失)→ 剩 user + tool_result(tc_x)。 // sanitize step3.5 补头自愈:补 TOOL_MISSING_PREFIX+tc_x 占位头,tool_result 保留。 let orphan_history = vec![ ChatMessage::user("用 http_request 调接口"), // assistant tool_call 头被压缩/裁剪错误丢弃(模拟三元组破坏) ChatMessage::tool_result("tc_http_x", "{\"status\":\"ok\",\"data\":42}"), ]; let sanitized = sanitize_messages(orphan_history); // 核心断言:tool_result 必须保留(补头自愈,不丢)—— 否则 LLM 看不到工具结果 → 静默停。 let tool_kept: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); assert_eq!(tool_kept.len(), 1, "工具结果必须保留(三元组完整性 P0)"); assert!( tool_kept[0].content.contains("status\":\"ok"), "工具结果原文应保留, 实际 {}", tool_kept[0].content ); // 补了占位头(id 含原 tc_http_x),与 result 配对闭合 let healed = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .any(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("tc_http_x")); assert!(healed, "应补占位头配对闭合三元组"); // result 的 tool_call_id 与补头 id 一致(闭合) assert_eq!( tool_kept[0].tool_call_id.as_deref(), sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) .filter_map(|m| m.tool_calls.as_ref()) .flatten() .find(|c| c.id.starts_with(TOOL_MISSING_PREFIX) && c.id.contains("tc_http_x")) .map(|c| c.id.as_str()), "result id 应与补头 id 一致(闭合配对)" ); } // ============================================================ // step4 不制造 orphan(根本修:显式整组 skip,不依赖下游出口断言) // ============================================================ #[test] fn ensure_sequence_legal_skips_leading_assistant_head_with_its_tool_result() { // 根本修核心场景( memory ai-router-sanitize-refactor-debt P1 隐式契约债): // 开头 user 被 step0/裁剪裁掉后,序列以合法 assistant 工具三元组头开头: // [asst(head, tc_a)] [tool_result(tc_a)] [user "实问"] // step4 skip 开头 assistant 头(首条非 user)→ 必须连带 skip 其后的 tool_result(tc_a), // 否则 tool_result 无头成 orphan(直送 provider 400)。 // // 旧实现依赖隐式巧合:367 行 `Assistant | Tool` 联合判断 + fixed.is_empty() 整组保持 true, // 使开头 tool_result 也被 skip。本测验证根本修后**显式机制**(跟踪被 skip head 的 id, // result 命中即连带丢)——即使将来有人改 367 行只 skip Assistant,本测仍通过(连带丢兜底)。 let msgs = vec![ ChatMessage::assistant_with_tools( "调用工具", vec![ToolCall::new("tc_a", "read_file", "{}")], ), ChatMessage::tool_result("tc_a", "工具结果"), ChatMessage::user("实问"), ]; let fixed = ensure_sequence_legal(msgs); // 输出仅剩首条 user(开头三元组整组丢,不留 orphan tool_result) assert_eq!( fixed.len(), 1, "开头 asst(head)+tool_result 应整组 skip, 实际 {} 条: {:?}", fixed.len(), fixed.iter().map(|m| format!("{:?}", m.role)).collect::>() ); assert!(matches!(fixed[0].role, MessageRole::User)); assert_eq!(fixed[0].content, "实问"); // 核心断言:输出中不得有任何 tool_result(无 orphan) let tool_count = fixed .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(); assert_eq!(tool_count, 0, "step4 不应留下 orphan tool_result"); } #[test] fn ensure_sequence_legal_no_orphan_when_leading_user_dropped_mid_sequence() { // 根本修进阶场景:开头 user 被裁 + 中间还有合法三元组(其 head 也在开头被 skip)。 // 序列:[asst(head1, tc_a)] [tool(tc_a)] [asst(head2, tc_b)] [tool(tc_b)] [user "问"] // 旧隐式契约:整组都 skip(fixed 全程空,所有 Assistant|Tool 命中 367)。 // 根本修后:head1 skip(记 tc_a),tc_a 连带丢(命中集合);head2 skip(记 tc_b),tc_b 连带丢。 // 结果:只剩 [user "问"]。验证多组三元组在开头被整组 skip 不留 orphan。 let msgs = vec![ ChatMessage::assistant_with_tools( "调1", vec![ToolCall::new("tc_a", "fn_a", "{}")], ), ChatMessage::tool_result("tc_a", "结果A"), ChatMessage::assistant_with_tools( "调2", vec![ToolCall::new("tc_b", "fn_b", "{}")], ), ChatMessage::tool_result("tc_b", "结果B"), ChatMessage::user("问"), ]; let fixed = ensure_sequence_legal(msgs); assert_eq!( fixed.len(), 1, "开头两组三元组应整组 skip, 实际 {:?}", fixed.iter().map(|m| format!("{:?}", m.role)).collect::>() ); assert!(matches!(fixed[0].role, MessageRole::User)); // 不留任何 orphan tool_result let tool_count = fixed .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(); assert_eq!(tool_count, 0, "不应留下 orphan tool_result"); } #[test] fn ensure_sequence_legal_preserves_well_formed_triplet_after_user() { // 根本修回归保护:user 在前 + 完整合法三元组在后 → 三元组原样保留(不被误丢)。 // 验证根本修的"连带丢 orphan"只针对"被 step4 自己 skip 的头", // 不影响 user 之后正常三元组(其 head 进 fixed 不被 skip,id 不入 skipped_head_ids)。 let msgs = vec![ ChatMessage::user("开场"), ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("tc_ok", "fn", "{}")], ), ChatMessage::tool_result("tc_ok", "结果"), ChatMessage::user("收尾"), ]; let fixed = ensure_sequence_legal(msgs); // 全保留:首条 user 已合法,后续三元组闭合,无 skip 无合并 assert_eq!( fixed.len(), 4, "合法序列应原样保留, 实际 {} 条", fixed.len() ); assert!(matches!(fixed[0].role, MessageRole::User)); assert!(matches!(fixed[1].role, MessageRole::Assistant)); assert!(matches!(fixed[2].role, MessageRole::Tool)); assert!(matches!(fixed[3].role, MessageRole::User)); // tool_result 保留(配对闭合,非 orphan) let tool = fixed .iter() .find(|m| matches!(m.role, MessageRole::Tool)) .expect("合法三元组的 tool_result 应保留"); assert_eq!(tool.tool_call_id.as_deref(), Some("tc_ok")); } #[test] fn sanitize_messages_step4_self_contained_no_orphan_without_exit_assert() { // 端到端根本修验证(不经出口断言):模拟"开头 user 被裁 + 直接调 sanitize_messages" // (即用户担心的"若直接调 sanitize_messages 不经出口,orphan 直送 provider 400"路径)。 // 根本修后:step4 自洽,sanitize_messages 输出本身不含 step4 制造的 orphan, // 不依赖调用方再跑 assert_placeholder_pairing 兜底。 // // 场景:step3.5(PLACEHOLDER_INTEGRITY_ENABLED=true)跑过后,开头三元组闭合, // 但 step4 skip 开头 assistant 头 → 根本修连带丢其 result。 // 输入模拟"开头 user 被 step0 过滤掉":序列以 assistant head 开头。 let orphan_inducing = vec![ ChatMessage::assistant_with_tools( "调工具", vec![ToolCall::new("tc_step4", "fn", "{}")], ), ChatMessage::tool_result("tc_step4", "工具结果"), ChatMessage::user("后续问题"), ]; let sanitized = sanitize_messages(orphan_inducing); // 核心断言:sanitize_messages 输出无 orphan tool_result(不依赖出口断言) let orphan_tool_results: Vec<_> = sanitized .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); assert!( orphan_tool_results.is_empty(), "step4 根本修:sanitize_messages 输出不应含 orphan tool_result(自洽,不依赖出口断言), 实际 {:?}", sanitized.iter().map(|m| format!("{:?}", m.role)).collect::>() ); // 首条必须是 user(step4 序列修复后) assert!( sanitized .first() .is_some_and(|m| matches!(m.role, MessageRole::User)), "首条必须是 user, 实际 {:?}", sanitized.first().map(|m| format!("{:?}", m.role)) ); } /// 连续同 role 合并须迁移多模态 parts:空 content 纯图消息与相邻同 role 消息 /// 合并时,图片片并入前一条(此前只拼 content,纯图消息整条丢弃,图对 LLM 隐身) #[test] fn sequence_merge_moves_multimodal_parts() { let pure_image = ChatMessage::user_parts( "", vec![crate::provider::ContentPart::image_url("https://x/a.png")], ); let fixed = ensure_sequence_legal(vec![pure_image, ChatMessage::user("继续")]); assert_eq!(fixed.len(), 1, "连续 user 应合并为一条"); let parts = fixed[0].parts.as_ref().expect("图片片应迁移到合并结果"); assert_eq!(parts.len(), 1); assert!(parts[0].is_image(), "迁移的应是图片片"); assert_eq!(fixed[0].content, "继续"); } }