修复: 消息三元组完整性(sanitize 补头自愈 orphan tool_result,治 AI 看不到工具结果致静默停)

This commit is contained in:
lxy
2026-08-01 16:34:33 +08:00
parent 2b08e32c26
commit 1a5d543fa1
2 changed files with 400 additions and 84 deletions
+57
View File
@@ -162,6 +162,63 @@ fn replace_tool_result_updates_tokens() {
assert!(after > before); 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<String> = 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] #[test]
fn restore_rebuilds_token_cache() { fn restore_rebuilds_token_cache() {
let mut mgr = ContextManager::new(cfg(100_000)); let mut mgr = ContextManager::new(cfg(100_000));
+344 -85
View File
@@ -11,7 +11,7 @@
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。 //! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
//! - [`ensure_sequence_legal`]step4 序列合法性修复(首条 user + 连续同 role 合并)。 //! - [`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}; use crate::provider::{ChatMessage, MessageRole, ToolCall};
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史 /// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
@@ -151,22 +151,25 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitized 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) // 根因(三元组完整性 P0):压缩/裁剪/正向 orphan 处理(step2/3)可能丢弃 assistant tool_call 头,
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组 // 但其 tool_result 残留 → orphan tool_result(无头)。若按"丢 result"处理,LLM 看不到工具结果
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。 // → AI Coding 静默停(根因)。本步骤改"补头自愈":对每个 orphan tool_result 就地补一个占位
// deepseek-v4-pro 等端点对此严格校验 → 400。 // 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 不在 // 与 step2/3 互补:step2/3 管"头丢 result"(正向 orphan:整头或部分闭合),step3.5 管"result 丢头"
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。 // (反向 orphan:头已被丢但 result 残留)。补头是兜底自愈——根因在压缩/裁剪破坏三元组原子性,
// 由 build_eviction_units / compress_old_messages 保证(见 mod.rs 三元组原子保护),但历史中毒
// /异常数据 /DB 加载的畸形历史仍可能残留 orphan result,故本层兜底。
// //
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留—— // **占位/非占位一视同仁**:占位 result(审批挂起)与普通工具结果同属"LLM 需看到的工具结果",
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃, // 一律补头保留(不丢)。补头后 ensure_sequence_legal 合并连续 assistant(防补头插在 assistant
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。 // 后产生连续同 role→400)。
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留 //
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留 // view-only:不改持久化(仅改传入 Vec clone),持久化全量保留
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED { let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
drop_reverse_orphans(after_triplet) drop_reverse_orphans(after_triplet)
} else { } else {
@@ -177,13 +180,27 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan) 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 标记)豁免 /// 详见 [`sanitize_messages`] step3.5 注释。每个 tool_result 的 id 不在任何保留头 tool_calls 内
/// (保留,出口断言自愈补头),其余 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<ChatMessage>) -> Vec<ChatMessage> { pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
use std::collections::HashSet; use std::collections::HashSet;
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)。
// 注:含 TOOL_MISSING_PREFIX 占位头 id(出口断言/上一轮 sanitize 补的),与 result 配对即合法。
let head_ids: HashSet<String> = messages let head_ids: HashSet<String> = messages
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Assistant)) .filter(|m| matches!(m.role, MessageRole::Assistant))
@@ -192,39 +209,61 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
.map(|c| c.id.clone()) .map(|c| c.id.clone())
.collect(); .collect();
let mut dropped = 0u32; let mut healed = 0u32;
let mut placeholder_kept = 0u32; let mut unidentifiable_kept = 0u32;
let filtered: Vec<ChatMessage> = messages let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
.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();
if dropped > 0 || placeholder_kept > 0 { for m in messages.into_iter() {
tracing::warn!( if !matches!(m.role, MessageRole::Tool) {
dropped_reverse_orphan_tool_results = dropped, out.push(m);
placeholder_kept_without_head = placeholder_kept, continue;
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
);
} }
filtered // 无 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;
}
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(含审批占位)都有 /// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
@@ -473,25 +512,56 @@ mod tests {
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ── // ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
#[test] #[test]
fn sanitize_drops_reverse_orphan_tool_result() { fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃 // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan // 旧行为是丢 result,致 LLM 看不到工具结果 → AI Coding 静默停(根因)
// 新行为:补 TOOL_MISSING_PREFIX 占位头(id 复用 result 的 tool_call_id),tool_result 保留,
// 三元组闭合。LLM 仍能看到"孤儿结果无头"这条工具结果。
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
ChatMessage::tool_result("orphan_id", "孤儿结果无头"), ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
]; ];
let sanitized = sanitize_messages(msgs); 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!( assert!(
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)), healed_heads[0].id.contains("orphan_id"),
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}", "补的头 id 应含原 tool_call_id, 实际 {}",
sanitized 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] #[test]
fn sanitize_keeps_reverse_orphan_pending_placeholder() { 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 placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
@@ -502,8 +572,16 @@ mod tests {
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Tool)) .filter(|m| matches!(m.role, MessageRole::Tool))
.collect(); .collect();
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢"); assert_eq!(kept.len(), 1, "pending 占位 tool_result 应被补头自愈保留,不丢");
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1")); // 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] #[test]
@@ -566,36 +644,29 @@ mod tests {
#[test] #[test]
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() { fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的 // 加固(连续 assistant→400):出口断言 assert_placeholder_pairing 补占位头插在 orphan
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant** // tool_result 正前方,若 orphan result 的前一条恰是 assistant(无 tool_result 隔开),
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。 // 则补头后产生**连续 assistant**(前 assistant + 占位 assistant + tool_result)→
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。 // Anthropic/GLM 协议 400/1214。出口断言补头后必须再过一次 ensure_sequence_legal 合并
// 连续 assistant,使其不触发 provider 拒绝。
//
// 本测**直接测出口函数**(不经 sanitize_messages):把原始 orphan 序列喂
// assert_placeholder_pairing,验证它补头 + 合并连续 assistant 的自愈逻辑独立可用
// (sanitize step3.5 也补头,但出口断言是兜底防线,须独立验证)。
// //
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) → // 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5 // orphan tool_result(call_pending_cc,无头)。出口断言在 result 正前方补占位头
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant) // (其前驱正是 assistant)→ 补后序列 user → assistant(纯文本) → assistant(占位头) →
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。 // tool_result = 连续 assistant(补头后未合并则会 400)
//
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
ChatMessage::assistant("纯文本回复(无 tool_calls)"), 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 的场景:如 src-tauri 侧压缩后未过 sanitize 直送)
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result let healed = assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
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 应保留");
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED); // 断言:补了占位头(tool_missing_ 前缀 id,含原 call_pending_cc)
// 断言:补了占位头(tool_missing_ 前缀 id)
let missing_heads: Vec<_> = healed let missing_heads: Vec<_> = healed
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Assistant)) .filter(|m| matches!(m.role, MessageRole::Assistant))
@@ -676,8 +747,9 @@ mod tests {
#[test] #[test]
fn drop_reverse_orphans_preserves_pending_with_legacy_text() { fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别 // 边界(三元组完整性 P0):老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据 // step3.5 补头自愈(占位/非占位一视同仁,都补占位头保留 tool_result)
// 验证:tool_result 保留(补头后闭合三元组,LLM 看到"等待审批")。
let msgs = vec![ let msgs = vec![
ChatMessage::user("问题"), ChatMessage::user("问题"),
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"), ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
@@ -687,7 +759,7 @@ mod tests {
.iter() .iter()
.filter(|m| matches!(m.role, MessageRole::Tool)) .filter(|m| matches!(m.role, MessageRole::Tool))
.count(); .count();
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)"); assert_eq!(kept, 1, "老占位(纯文本无标记)应被补头自愈保留(向前兼容)");
} }
#[test] #[test]
@@ -758,4 +830,191 @@ mod tests {
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删"); assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量"); 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<String> = 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<String> = 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 一致(闭合配对)"
);
}
} }