diff --git a/crates/df-ai/src/context/manager_tests.rs b/crates/df-ai/src/context/manager_tests.rs index 185189a..292e958 100644 --- a/crates/df-ai/src/context/manager_tests.rs +++ b/crates/df-ai/src/context/manager_tests.rs @@ -162,6 +162,63 @@ fn replace_tool_result_updates_tokens() { assert!(after > before); } +// ============================================================ +// 三元组完整性 P1(根治):裁剪/压缩必须保证三元组原子性, +// 不出现 tool_result 残留但 tool_call 头被裁的 orphan(根因:AI Coding 静默停)。 +// ============================================================ + +#[test] +fn trim_never_produces_orphan_tool_result_without_head() { + // 三元组完整性 P1 回归:超预算裁剪后,发送视图中**任何 tool_result 的 tool_call_id 都必须 + // 有对应 assistant tool_call 头**(不残留 orphan result)。裁剪按 build_eviction_units 的 + // 三元组原子单元(Head+Tail+Standalone Assistant 同进同出),trim_end 始终落在单元边界, + // 永不切断三元组。 + // + // 布局:5 旧 user/assistant 文本(淘汰区) + 完整三元组(tc_mid) + 6 新(保护区) = 14 条 + // 强制小预算使裁剪切到三元组边界(整体保留或整体丢弃,不在中间切)。 + let mut mgr = ContextManager::new(cfg(120)); + for i in 0..5 { + mgr.push(ChatMessage::user(&format!("旧消息 {} 用于撑爆预算的较长文本", i))); + } + // 完整三元组(裁剪边界附近) + mgr.push(ChatMessage::assistant_with_tools( + "调中间工具", + vec![ToolCall::new("tc_mid", "read_file", "{}")], + )); + mgr.push(ChatMessage::tool_result("tc_mid", "中间工具结果")); + mgr.push(ChatMessage::assistant("中间完成")); + // 保护区(最近 PROTECT_COUNT=6 条) + for i in 0..6 { + mgr.push(ChatMessage::user(&format!("新消息 {} 保护区", i))); + } + + let (msgs, trimmed) = mgr.build_for_request(0); + assert!(trimmed, "应触发裁剪"); + + // 收集发送视图中所有 assistant 头的 tool_call.id + use std::collections::HashSet; + let head_ids: HashSet = msgs + .iter() + .filter(|m| matches!(m.role, MessageRole::Assistant)) + .filter_map(|m| m.tool_calls.as_ref()) + .flatten() + .map(|c| c.id.clone()) + .collect(); + + // 每个 tool_result 的 tool_call_id 都必须在 head_ids 内(不残留 orphan result) + let orphans: Vec<&str> = msgs + .iter() + .filter(|m| matches!(m.role, MessageRole::Tool)) + .filter_map(|m| m.tool_call_id.as_deref()) + .filter(|id| !head_ids.contains(*id)) + .collect(); + assert!( + orphans.is_empty(), + "裁剪后不应有 orphan tool_result(无对应头), 实际 orphans={:?}, heads={:?}", + orphans, head_ids + ); +} + #[test] fn restore_rebuilds_token_cache() { let mut mgr = ContextManager::new(cfg(100_000)); diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs index 658a853..ac30252 100644 --- a/crates/df-ai/src/context/sanitize.rs +++ b/crates/df-ai/src/context/sanitize.rs @@ -11,7 +11,7 @@ //! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。 //! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。 -use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX}; +use crate::context_helpers::{PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX}; use crate::provider::{ChatMessage, MessageRole, ToolCall}; /// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史 @@ -151,22 +151,25 @@ pub fn sanitize_messages(messages: Vec) -> Vec { sanitized }; - // step 3.5(占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。 + // step 3.5(tool_result 补头自愈):tool_result 无对应 tool_call 头 → 补占位头自愈(不丢)。 // - // 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER) - // 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组 - // 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。 - // deepseek-v4-pro 等端点对此严格校验 → 400。 + // 根因(三元组完整性 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。 // - // 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在 - // head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。 + // 与 step2/3 互补:step2/3 管"头丢 result"(正向 orphan:整头或部分闭合),step3.5 管"result 丢头" + // (反向 orphan:头已被丢但 result 残留)。补头是兜底自愈——根因在压缩/裁剪破坏三元组原子性, + // 由 build_eviction_units / compress_old_messages 保证(见 mod.rs 三元组原子保护),但历史中毒 + // /异常数据 /DB 加载的畸形历史仍可能残留 orphan result,故本层兜底。 // - // **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留—— - // 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃, - // 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。 - // 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留 - // (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后 - // 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。 + // **占位/非占位一视同仁**:占位 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 { @@ -177,13 +180,27 @@ pub fn sanitize_messages(messages: Vec) -> Vec { ensure_sequence_legal(after_reverse_orphan) } -/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。 +/// step 3.5:反向 orphan 检测(view-only)—— **补头自愈**(对 orphan tool_result 就地补占位 +/// assistant tool_call 头,而非丢弃 tool_result)。 /// -/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免 -/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。 +/// 详见 [`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 头**:已带 TOOL_MISSING_PREFIX 的 id 视为已配对(出口断言 +/// 补的占位头),不重复补头(否则同 id 双头致 400)。 pub fn drop_reverse_orphans(messages: Vec) -> Vec { - // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id) 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)) @@ -192,39 +209,61 @@ pub fn drop_reverse_orphans(messages: Vec) -> Vec { .map(|c| c.id.clone()) .collect(); - let mut dropped = 0u32; - let mut placeholder_kept = 0u32; - let filtered: Vec = messages - .into_iter() - .filter(|m| { - if !matches!(m.role, MessageRole::Tool) { - return true; - } - let Some(id) = m.tool_call_id.as_deref() else { - return true; // 无 id 的 tool_result(异常数据),不在此处处理 - }; - if head_ids.contains(id) { - return true; // 有对应头 → 保留 - } - // 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢 - if is_pending_placeholder(&m.content) { - placeholder_kept += 1; - true - } else { - dropped += 1; - false - } - }) - .collect(); + let mut healed = 0u32; + let mut unidentifiable_kept = 0u32; + let mut out: Vec = Vec::with_capacity(messages.len() + 4); - if dropped > 0 || placeholder_kept > 0 { - tracing::warn!( - dropped_reverse_orphan_tool_results = dropped, - placeholder_kept_without_head = placeholder_kept, - "history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)" + 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; + }; + // 已配对(头在)→ 原样保留。 + if head_ids.contains(id) { + out.push(m); + continue; + } + // TOOL_MISSING_PREFIX 占位头 id:已被出口断言/上一轮补过,视为配对,不重复补头。 + if id.starts_with(TOOL_MISSING_PREFIX) { + 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; } - filtered + + if healed > 0 || unidentifiable_kept > 0 { + tracing::warn!( + 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(含审批占位)都有 @@ -473,25 +512,56 @@ mod tests { // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── #[test] - fn sanitize_drops_reverse_orphan_tool_result() { - // 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。 - // 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。 + 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!( - sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)), - "无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}", - sanitized + 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() { - // 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留 - // (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。 + // 三元组完整性 P0:反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ + // step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 result)。 + // 验证:占位 tool_result 保留(不丢),且补了占位头配对闭合。 let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1"; let msgs = vec![ ChatMessage::user("问题"), @@ -502,8 +572,16 @@ mod tests { .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .collect(); - assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢"); - assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1")); + 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] @@ -566,36 +644,29 @@ mod tests { #[test] fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() { - // 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的 - // 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant** - // (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。 - // 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。 + // 加固(连续 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 pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5 - // 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant) - // → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。 - // - // 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」, - // 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。 - // 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。 - let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc"; + // 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", placeholder_content), + ChatMessage::tool_result("call_pending_cc", "工具结果无头"), ]; - let sanitized = sanitize_messages(msgs.clone()); - // 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result - assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized); - assert!(sanitized.iter().any(|m| - matches!(m.role, MessageRole::Tool) - && m.tool_call_id.as_deref() == Some("call_pending_cc") - ), "占位 tool_result 应保留"); + // 直接喂出口断言(模拟绕过 sanitize 的场景:如 src-tauri 侧压缩后未过 sanitize 直送) + let healed = assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED); - let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED); - - // 断言:补了占位头(tool_missing_ 前缀 id) + // 断言:补了占位头(tool_missing_ 前缀 id,含原 call_pending_cc) let missing_heads: Vec<_> = healed .iter() .filter(|m| matches!(m.role, MessageRole::Assistant)) @@ -676,8 +747,9 @@ mod tests { #[test] fn drop_reverse_orphans_preserves_pending_with_legacy_text() { - // 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别 - // (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。 + // 边界(三元组完整性 P0):老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ + // step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 tool_result)。 + // 验证:tool_result 保留(补头后闭合三元组,LLM 看到"等待审批")。 let msgs = vec![ ChatMessage::user("问题"), ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"), @@ -687,7 +759,7 @@ mod tests { .iter() .filter(|m| matches!(m.role, MessageRole::Tool)) .count(); - assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)"); + assert_eq!(kept, 1, "老占位(纯文本无标记)应被补头自愈保留(向前兼容)"); } #[test] @@ -758,4 +830,191 @@ mod tests { 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_no_redundant_head_for_tool_missing_prefix_id() { + // 边界:orphan result 的 id 已是 TOOL_MISSING_PREFIX(出口断言/上轮补过)→ 视为已配对, + // 不重复补头(否则同 id 双头致 400)。 + 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 的 result 应保留" + ); + // 不补新头(原样保留,无新 TOOL_MISSING_PREFIX 占位头插入) + let new_heads = 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)) + .count(); + assert_eq!(new_heads, 0, "TOOL_MISSING_PREFIX id result 不应重复补头"); + } + + #[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 一致(闭合配对)" + ); + } }