//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。 //! //! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress / //! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内 //! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。 #![cfg(test)] use super::*; use crate::context_helpers::{ ContextConfig, EVICTION_SUMMARY_MAX_CHARS, TokenEstimator, PROTECT_COUNT, }; use crate::provider::ToolCall; fn cfg(max_tokens: u32) -> ContextConfig { ContextConfig { max_tokens, output_reserve: 0, safety_ratio: 1.0, } } #[test] fn estimate_message_counts_parts_tokens() { // 多模态回归:含图消息的大段 base64 必须计入 token 预算, // 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。 let est = TokenEstimator::default(); // 纯文本基线 let text_msg = ChatMessage::user("短文本"); let text_tokens = est.estimate_message(&text_msg); // 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本 let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符 let multimodal = ChatMessage::user_parts( "短文本", vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())], ); let mm_tokens = est.estimate_message(&multimodal); assert!( mm_tokens > text_tokens, "含图消息 token({}) 应高于纯文本({})", mm_tokens, text_tokens ); // base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀 assert!( mm_tokens >= 1600, "大 base64 应按图地板计 token,实际 {}", mm_tokens ); // url 模式(无字节)按每图 ~1600 token 地板估(生效于 ×0.35 折扣之后,防预算漏裁) let url_msg = ChatMessage::user_parts( "t", vec![crate::provider::ContentPart::image_url("https://example.com/x.png")], ); let url_tokens = est.estimate_message(&url_msg); assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献"); } #[test] fn short_history_no_trim() { let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("你好")); mgr.push(ChatMessage::assistant("你好啊")); let (msgs, trimmed) = mgr.build_for_request(10); assert!(!trimmed); assert_eq!(msgs.len(), 2); } #[test] fn over_budget_trims_old() { // 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。 // 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间, // 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。 let mut mgr = ContextManager::new(cfg(400)); // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪) for i in 0..20 { if i % 2 == 0 { mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i))); } else { mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i))); } } let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len()); // 保护区:最新一条必保留(末条 i=19 是 assistant) assert_eq!( msgs.last().unwrap().content, "第 19 条较长的回复用于撑爆预算", "保护区最新消息被误裁" ); // 裁剪是视图:内存全量不变 assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); } #[test] fn tool_triplet_kept_atomic() { // 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断 // 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条 let mut mgr = ContextManager::new(cfg(95)); for i in 0..6 { mgr.push(ChatMessage::user(&format!("旧消息 {}", i))); } mgr.push(ChatMessage::assistant_with_tools( "调工具", vec![ToolCall::new("tc1", "read_file", "{}")], )); mgr.push(ChatMessage::tool_result("tc1", "文件内容")); mgr.push(ChatMessage::assistant("完成")); for i in 0..6 { mgr.push(ChatMessage::user(&format!("新消息 {}", i))); } // 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在 let (msgs_keep, trimmed1) = mgr.build_for_request(0); assert!(trimmed1, "分支一应触发裁剪"); assert_eq!( has_head(&msgs_keep), has_tail(&msgs_keep), "分支一三元组被切断: head={} tail={}", has_head(&msgs_keep), has_tail(&msgs_keep) ); // 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在 let (msgs_drop, trimmed2) = mgr.build_for_request(40); assert!(trimmed2, "分支二应触发裁剪"); assert_eq!( has_head(&msgs_drop), has_tail(&msgs_drop), "分支二三元组被切断: head={} tail={}", has_head(&msgs_drop), has_tail(&msgs_drop) ); // 裁剪是视图:两次 build 都不应改变内存全量 assert_eq!( mgr.all_messages_clone().len(), 15, "裁剪污染了内存全量" ); } fn has_head(msgs: &[ChatMessage]) -> bool { msgs.iter() .any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some()) } fn has_tail(msgs: &[ChatMessage]) -> bool { msgs.iter().any(|m| matches!(m.role, MessageRole::Tool)) } #[test] fn replace_tool_result_updates_tokens() { let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::tool_result("tc1", "短")); let before = mgr.history_tokens(); assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估")); let after = mgr.history_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)); let src = vec![ ChatMessage::user("测试消息一"), ChatMessage::assistant("回复一"), ChatMessage::user("测试消息二"), ]; mgr.restore_from_messages(src); assert!(mgr.history_tokens() > 0); assert_eq!(mgr.len(), 3); } #[test] fn push_token_only_active() { // F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push // 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens, // 避免 build_for_request 误判超预算触发不必要裁剪。 // 1) 直接 push 路径 let mut mgr = ContextManager::new(cfg(100_000)); let active_msg = ChatMessage::user("这条是 active 的"); let active_tokens = TokenEstimator::default().estimate_message(&active_msg); let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token"); inactive_msg.status = Some(MessageStatus::Truncated); let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg); mgr.push(active_msg); mgr.push(inactive_msg); // 全量保留(两条都在内存) assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages"); assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响"); // token 预算只含 active assert_eq!( mgr.history_tokens(), active_tokens, "history_tokens 应只含 active,多算了 {}(inactive 应被忽略)", mgr.history_tokens().saturating_sub(active_tokens) ); assert!( inactive_tokens > 0, "前提:inactive 消息本身确有 token,否则无法证明它被排除" ); // 2) restore_from_messages 路径(调 push,token 同步仅 active) let mut mgr2 = ContextManager::new(cfg(100_000)); let mut a = ChatMessage::user("active 一"); a.status = Some(MessageStatus::Active); let mut b = ChatMessage::user("archived 一"); b.status = Some(MessageStatus::ArchivedSegment); let mut c = ChatMessage::user("compressed 一"); c.status = Some(MessageStatus::Compressed); mgr2.restore_from_messages(vec![a, b, c]); assert_eq!(mgr2.len(), 3, "restore 后全量保留三条"); // 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false) let only_active_tokens = TokenEstimator::default() .estimate_message(&ChatMessage::user("active 一")); assert_eq!( mgr2.history_tokens(), only_active_tokens, "restore 后 history_tokens 应只含 active 一条,archived/compressed 不计" ); } #[test] fn empty_history_returns_empty() { let mgr = ContextManager::new(cfg(100_000)); let (msgs, trimmed) = mgr.build_for_request(10); assert!(!trimmed); assert!(msgs.is_empty(), "空历史应返回空列表"); } #[test] fn protect_zone_returns_full_when_untrimmable() { // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量 let mut mgr = ContextManager::new(cfg(10)); mgr.push(ChatMessage::user("撑爆小预算的长消息内容")); mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息")); let (msgs, trimmed) = mgr.build_for_request(0); assert!(!trimmed, "无可淘汰单元应返回 false(兜底)"); assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)"); } #[test] fn system_over_budget_trims_to_protect_zone() { // system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic let mut mgr = ContextManager::new(cfg(200)); for i in 0..10 { mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i))); } let (msgs, _trimmed) = mgr.build_for_request(195); assert!( msgs.len() <= PROTECT_COUNT, "system 超预算时裁剪后至多保留保护区 {} 条,实际 {}", PROTECT_COUNT, msgs.len() ); } // ── 辅助方法单测 ── #[test] fn compress_old_messages_marks_compressed_and_returns_refs() { // F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed, // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("旧消息1")); mgr.push(ChatMessage::assistant("旧回复1")); mgr.push(ChatMessage::user("新消息2")); let tokens_before = mgr.history_tokens(); assert!(tokens_before > 0); let compressed = mgr.compress_old_messages(2); assert_eq!(compressed.len(), 2, "应压缩前 2 条 active"); assert_eq!(compressed[0].content, "旧消息1"); assert_eq!(compressed[1].content, "旧回复1"); // status 已改 compressed assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed)); assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed)); // 保护区外(本例 index 2)仍 active assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动"); // 持久化全量不变 assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)"); // token 已扣(剩第 3 条的) let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2")); assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条"); } #[test] fn compress_old_messages_is_idempotent() { // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("b")); let first = mgr.compress_old_messages(2); assert_eq!(first.len(), 2); let tokens_after_first = mgr.history_tokens(); let second = mgr.compress_old_messages(2); assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)"); assert_eq!( mgr.history_tokens(), tokens_after_first, "二次压缩 history_tokens 不应再变(幂等)" ); } #[test] fn compress_old_messages_clamps_oversized_end() { // compress_end 越界自动 clamp 到 len,不 panic。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("唯一")); let compressed = mgr.compress_old_messages(999); assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)"); assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零"); } #[test] fn compress_old_messages_skips_already_inactive() { // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。 let mut mgr = ContextManager::new(cfg(100_000)); let mut truncated = ChatMessage::user("被截断"); truncated.status = Some(MessageStatus::Truncated); mgr.push(truncated); mgr.push(ChatMessage::user("active 一条")); let tokens_before = mgr.history_tokens(); // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条 let compressed = mgr.compress_old_messages(2); assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过"); assert_eq!(mgr.history_tokens(), 0); assert_eq!( mgr.history_tokens(), tokens_before.saturating_sub(tokens_before), "幂等扣除:truncated 本就没计 token,active 扣光" ); // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆) assert_eq!( mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Truncated), "已 truncated 不应被改写为 compressed" ); } #[test] fn has_compressible_messages_respects_protect_zone() { let mut mgr = ContextManager::new(cfg(100_000)); for i in 0..8 { mgr.push(ChatMessage::user(&format!("消息 {}", i))); } // protect_start=6 → [0,6) 内有 active → true assert!(mgr.has_compressible_messages(6)); // protect_start=0 → 空范围 → false assert!(!mgr.has_compressible_messages(0)); // 全部压缩后 → false mgr.compress_old_messages(6); assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息"); } #[test] fn is_compressing_flag_round_trip() { // 标志位读写 round-trip;clear() 复位。 let mut mgr = ContextManager::new(cfg(100_000)); assert!(!mgr.is_compressing(), "默认 false"); mgr.set_compressing(true); assert!(mgr.is_compressing(), "set true 后应读到 true"); mgr.set_compressing(false); assert!(!mgr.is_compressing(), "set false 后复位"); // clear 复位 mgr.set_compressing(true); mgr.clear(); assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing"); } #[test] fn insert_at_adds_to_budget_when_active() { let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); let tokens_before = mgr.history_tokens(); // 插入 active system 消息 → 计入 token mgr.insert_at(0, ChatMessage::system("## 摘要")); assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token"); assert_eq!(mgr.len(), 2); assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要"); // 插入 !active 消息 → 不计入 token let tokens_before_inactive = mgr.history_tokens(); let mut inactive = ChatMessage::user("x"); inactive.status = Some(MessageStatus::Truncated); mgr.insert_at(0, inactive); assert_eq!( mgr.history_tokens(), tokens_before_inactive, "!active 消息插入不应计 token" ); } #[test] fn build_eviction_units_keeps_triplet_atomic_public() { // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("前置")); mgr.push(ChatMessage::assistant_with_tools( "调", vec![ToolCall::new("c1", "fn", "{}")], )); mgr.push(ChatMessage::tool_result("c1", "结果")); mgr.push(ChatMessage::assistant("完成")); mgr.push(ChatMessage::user("后置")); // protect_start=5(全部纳入) let units = mgr.build_eviction_units(5); // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开 // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3) let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元"); assert!( unit2.end >= 4, "三元组三件套应在同一淘汰单元, end={}", unit2.end ); } // ── 消息级溯源:last_assistant/last_user message_id ── #[test] fn last_assistant_message_id_returns_latest() { // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问1")); let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1")); mgr.push(ChatMessage::user("问2")); let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2")); // 末条 assistant id 应是 last(非 first) assert_eq!( mgr.last_assistant_message_id().as_deref(), Some(last.as_str()), "应取末条 assistant id, 而非首条" ); assert_ne!( mgr.last_assistant_message_id().as_deref(), Some(first.as_str()) ); } #[test] fn last_user_message_id_returns_latest() { // 多条 user,反向扫描取末条 id(触发本轮检索的 user) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问1")); mgr.push(ChatMessage::assistant("答1")); let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2")); assert_eq!( mgr.last_user_message_id().as_deref(), Some(last_user.as_str()) ); } #[test] fn last_message_id_none_when_no_such_role() { // 无 assistant → None;无 user → None let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("只有 user")); assert!( mgr.last_assistant_message_id().is_none(), "无 assistant 消息应返 None" ); let mut mgr2 = ContextManager::new(cfg(100_000)); mgr2.push(ChatMessage::assistant("只有 assistant")); assert!( mgr2.last_user_message_id().is_none(), "无 user 消息应返 None" ); } #[test] fn last_message_id_none_for_legacy_no_id() { // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式) let mut mgr = ContextManager::new(cfg(100_000)); let mut legacy = ChatMessage::assistant("老消息无 id"); legacy.id = None; mgr.push(legacy); assert!( mgr.last_assistant_message_id().is_none(), "老消息无 id 应返 None(向前兼容)" ); } /// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id) fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String { let id = message.id.clone(); mgr.push(message); id.expect("新构造消息必有 id") } // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ── #[test] fn topic_marker_triggers_on_two_different_high_conf_topics() { // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。 // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("帮我重构这段代码")); // 此时只有一条 user,无 marker assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker"); mgr.push(ChatMessage::user("创建项目并绑定目录")); let marker = mgr.take_topic_marker() .expect("双不同 topic 应置位 marker"); assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker); assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker); // take 后清空(幂等) assert!(mgr.take_topic_marker().is_none(), "take 后应清空"); } #[test] fn topic_marker_not_triggered_on_same_topic() { // 同 topic(code/code)→ 不置位(非切换)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); mgr.push(ChatMessage::user("再重构另一段代码")); assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker"); } #[test] fn topic_marker_not_triggered_when_either_topic_none() { // 保守:任一 topic 为 None(低置信未标)不置位。 // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("今天的天气不错啊")); mgr.push(ChatMessage::user("帮我重构这段代码")); assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)"); // 反向:前一条高置信 + 后一条低置信 let mut mgr2 = ContextManager::new(cfg(100_000)); mgr2.push(ChatMessage::user("帮我重构这段代码")); mgr2.push(ChatMessage::user("嗯嗯好的")); assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)"); } #[test] fn topic_marker_not_polluting_compression_or_trim() { // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常, // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。 let mut mgr = ContextManager::new(cfg(80)); for i in 0..10 { mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i))); } mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪) // 裁剪仍正常工作(topic 字段不参与裁剪逻辑) let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed); assert!(!msgs.is_empty()); } #[test] fn topic_field_default_none_in_constructors() { // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖, // insert_at 始终 None——insert_at 不做主题推断,保守)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.insert_at(0, ChatMessage::system("摘要")); // insert_at 的 system 消息 topic=None(role 非 User) assert_eq!(mgr.messages_mut()[0].topic, None); } // ===== 苁刻测:主题切换检测对抗/边界/链式 ===== #[test] fn topic_marker_chained_three_switches_each_triggers() { // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。 // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。 let mut mgr = ContextManager::new(cfg(100_000)); // 1. Code mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code assert!(mgr.take_topic_marker().is_none(), "首条不触发"); // 2. Code → Project(切换) mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project let m1 = mgr.take_topic_marker().expect("code→project 应触发"); assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1); assert!(mgr.take_topic_marker().is_none(), "take 后清空"); // 3. Project → Task(切换) mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task let m2 = mgr.take_topic_marker().expect("project→task 应触发"); assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2); // 4. Task → Http(切换,证链式不因中间穿插断) mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http let m3 = mgr.take_topic_marker().expect("task→http 应触发"); assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3); } #[test] fn topic_marker_low_confidence_chain_never_triggers() { // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None assert!(mgr.take_topic_marker().is_none()); mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发"); // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标) mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 assert!( mgr.take_topic_marker().is_none(), "前一条 topic None,即使本条高置信也不触发(双高置信约束)" ); } #[test] fn topic_marker_high_then_low_confidence_never_triggers() { // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0 assert!(mgr.take_topic_marker().is_none()); mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None assert!( mgr.take_topic_marker().is_none(), "后一条 topic None,即使前一条高置信也不触发" ); } #[test] fn topic_marker_switch_back_to_same_after_different_not_triggered() { // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。 // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); // code mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project let _ = mgr.take_topic_marker(); mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同) let m = mgr.take_topic_marker().expect("project→code 切回应触发"); assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m); } #[test] fn topic_field_survives_compress_old_messages() { // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。 // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file mgr.push(ChatMessage::assistant("ok")); // idx 2 // 压缩 [0,2):idx 0/1 标 compressed let compressed = mgr.compress_old_messages(2); assert_eq!(compressed.len(), 2); // topic 字段保留(compress 不触碰) assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留"); assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留"); // status 改为 compressed assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed)); } #[test] fn topic_marker_system_message_in_send_view_under_adequate_budget() { // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 → // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。 // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); // code mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker let marker = mgr.take_topic_marker().expect("应触发 marker"); assert!(marker.contains("code") && marker.contains("project")); // 调用方据 marker insert system 标记(agentic loop 实际行为) mgr.insert_at(0, ChatMessage::system("[主题切换标记]")); let (msgs, trimmed) = mgr.build_for_request(0); assert!(!trimmed, "充足预算不应裁剪"); assert!( msgs.iter().any(|m| m.content.contains("[主题切换标记]")), "marker system 应进发送视图: {:?}", msgs.iter().map(|m| m.content.chars().take(15).collect::()).collect::>() ); } #[test] fn topic_marker_not_triggered_by_assistant_or_tool_messages() { // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); // code assert!(mgr.take_topic_marker().is_none()); // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User) mgr.push(ChatMessage::assistant("编译 error 出现了")); assert!( mgr.take_topic_marker().is_none(), "assistant 消息不参与主题推断,不触发 marker" ); // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换 mgr.push(ChatMessage::user("继续重构")); assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)"); // 此时末条 user 是 code,切到 project 应触发 mgr.push(ChatMessage::user("创建项目并绑定目录")); assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)"); } #[test] fn topic_marker_take_is_idempotent_and_clears() { // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("重构这段代码")); mgr.push(ChatMessage::user("创建项目并绑定目录")); let _ = mgr.take_topic_marker().expect("应有 marker"); // 第二次 take 必 None assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空"); } #[test] fn topic_marker_reset_after_restore_from_messages() { // restore_from_messages(DB 加载)重放历史时 push 会照常跑主题切换检测, // 历史里"当时已发生"的切换不得残留成 pending marker——否则加载后首轮 take // 拿到假话题锚点,后续话题归属被误判为切换、多插一次系统标记。 let history = vec![ ChatMessage::user("帮我重构这段代码"), // topic=code(高置信) ChatMessage::assistant("好的"), ChatMessage::user("创建项目并绑定目录"), // topic=project(高置信)→ 重放中会置位 marker ]; let mut mgr = ContextManager::new(cfg(100_000)); mgr.restore_from_messages(history); assert!( mgr.take_topic_marker().is_none(), "重放历史不得残留 pending_topic_marker(假话题锚点)" ); // restore 之后的实时 push 检测不受影响:末条 user topic=project,新 push code → 触发 mgr.push(ChatMessage::user("帮我重构这段代码")); let marker = mgr .take_topic_marker() .expect("restore 后的实时切换仍应触发"); assert!( marker.contains("project") && marker.contains("code"), "marker: {}", marker ); } // ============================================================ // msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite) // ============================================================ // // 这些测试验证 save_conversation 增量优化的核心不变量: // - 纯 push(append)不置 needs_full_rewrite,persisted_msg_count 由 save 推进(此处只测标志语义) // - 任何修改既有消息 / 缩短 / 中段插入 / 清空的入口都置 needs_full_rewrite=true // (漏置位 = 静默 DB 不同步,是本优化最危险的回归点) // - restore_from_messages(DB 加载)后基线对齐到 len + 清 needs_full_rewrite // // persisted_msg_count 的推进/读取由 save_conversation(src-tauri) 调 advance_persisted_count 完成, // 此处仅测 ContextManager 侧的字段初值 + advance 语义(max 防回退 + 清标志)。 #[test] fn phase1_new_manager_zero_baseline_no_rewrite_needed() { // 新建 ContextManager:基线 0,无需全量重写(append-only 起点) let mgr = ContextManager::new(cfg(100_000)); assert_eq!(mgr.persisted_msg_count(), 0); assert!(!mgr.needs_full_rewrite(), "新 manager 不应需全量重写"); } #[test] fn phase1_push_only_keeps_clean_no_dirty_flag() { // 纯 append(push 多条)不置 needs_full_rewrite —— 这是常态(agentic loop 每轮追加), // 必须走增量路径只插新消息,不能误触发全量重写。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问题1")); assert!(!mgr.needs_full_rewrite(), "push 不应置 dirty"); mgr.push(ChatMessage::assistant("回答1")); mgr.push(ChatMessage::user("问题2")); assert!(!mgr.needs_full_rewrite(), "连续 push 不应置 dirty"); assert_eq!(mgr.len(), 3); } #[test] fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() { // advance_persisted_count(new_count, full):推进基线;全量重写才清 needs_full_rewrite let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("x")); mgr.push(ChatMessage::user("y")); // 模拟 save(全量重写)成功后推进 mgr.advance_persisted_count(2, true); assert_eq!(mgr.persisted_msg_count(), 2); assert!(!mgr.needs_full_rewrite()); } #[test] fn phase1_advance_persisted_count_max_no_regression() { // 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写) let mut mgr = ContextManager::new(cfg(100_000)); mgr.advance_persisted_count(5, true); mgr.advance_persisted_count(3, true); // 误传更小 assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退"); } #[test] fn phase1_advance_incremental_keeps_dirty() { // 增量/无变化路径(full=false)不得清 dirty:写库期间新置的 dirty(如压缩链 mark) // 与本次 append 写库无关,误清会致改动滞留内存永不落库 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); mgr.advance_persisted_count(1, false); // 增量 save 成功 assert!(!mgr.needs_full_rewrite()); mgr.mark_needs_full_rewrite(); // 模拟写库期间压缩 mark mgr.advance_persisted_count(1, false); // 又一次增量 save assert!(mgr.needs_full_rewrite(), "增量路径 advance 不得清 dirty"); mgr.advance_persisted_count(1, true); // 直到全量重写才清 assert!(!mgr.needs_full_rewrite()); } #[test] fn phase1_compress_old_messages_sets_dirty() { // compress 改既有消息 status(active→compressed) → 必须全量重写收敛 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("旧1")); mgr.push(ChatMessage::user("旧2")); mgr.push(ChatMessage::user("保护区")); assert!(!mgr.needs_full_rewrite()); let compressed = mgr.compress_old_messages(2); assert_eq!(compressed.len(), 2, "应压缩 2 条"); assert!(mgr.needs_full_rewrite(), "compress 后必须全量重写"); } #[test] fn phase1_compress_no_op_does_not_set_dirty() { // 边界:compress_end 范围内无可压缩(全已 compressed)→ 状态未变,不应置 dirty // (避免无意义全量重写,compress 重试场景常见) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); let _ = mgr.compress_old_messages(1); assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty"); mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty // 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty let compressed = mgr.compress_old_messages(1); assert!(compressed.is_empty()); // 注:此处 dirty 在二次 compress 时未变(未置位),但 advance 已清,故仍 false assert!(!mgr.needs_full_rewrite(), "无新压缩不应置 dirty"); } #[test] fn phase1_replace_tool_result_content_sets_dirty() { // 审批 replace_tool_result 改既有 tool 消息 content → 必须全量重写 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::assistant_with_tools( "调用工具", vec![ToolCall { id: "call_1".into(), call_type: "function".into(), function: crate::provider::ToolCallFunction { name: "read_file".into(), arguments: "{}".into(), }, }], )); mgr.push(ChatMessage::tool_result("call_1", "占位结果")); assert!(!mgr.needs_full_rewrite()); let replaced = mgr.replace_tool_result_content("call_1", "真实结果"); assert!(replaced, "应找到并替换"); assert!(mgr.needs_full_rewrite(), "replace content 后必须全量重写"); } #[test] fn phase1_pop_last_assistant_round_sets_dirty() { // 重新生成 pop 末尾 assistant 轮 → len 缩短 → 必须全量重写(删 DB 尾行) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问")); mgr.push(ChatMessage::assistant("答")); assert!(!mgr.needs_full_rewrite()); let popped = mgr.pop_last_assistant_round(); assert!(popped); assert!(mgr.needs_full_rewrite(), "pop 后必须全量重写"); assert_eq!(mgr.len(), 1, "应只剩 user"); } #[test] fn phase1_pop_no_op_does_not_set_dirty() { // 边界:无可弹 assistant(末尾是 user)→ 不变,不应置 dirty let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("只有 user")); let popped = mgr.pop_last_assistant_round(); assert!(!popped); assert!(!mgr.needs_full_rewrite(), "pop no-op 不应置 dirty"); } #[test] fn phase1_truncate_after_user_message_sets_dirty() { // 编辑重生成 truncate 后续消息 status → 必须全量重写 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("原始问题")); mgr.push(ChatMessage::assistant("答1")); mgr.push(ChatMessage::assistant("答2")); assert!(!mgr.needs_full_rewrite()); let count = mgr.truncate_after_user_message("原始问题").expect("应找到"); assert_eq!(count, 2); assert!(mgr.needs_full_rewrite(), "truncate 后必须全量重写"); } #[test] fn phase1_replace_last_active_user_content_sets_dirty() { // 编辑 user 消息 content → 必须全量重写 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("旧问题")); assert!(!mgr.needs_full_rewrite()); mgr.replace_last_active_user_content("新问题").expect("应替换"); assert!(mgr.needs_full_rewrite(), "replace user content 后必须全量重写"); } #[test] fn phase1_insert_at_sets_dirty() { // 中段插入(压缩摘要/话题标记)改变 seq → 必须全量重写 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); mgr.push(ChatMessage::user("b")); assert!(!mgr.needs_full_rewrite()); mgr.insert_at(0, ChatMessage::system("[摘要]")); assert!(mgr.needs_full_rewrite(), "insert_at 后必须全量重写( seq 错位)"); assert_eq!(mgr.len(), 3); } #[test] fn phase1_clear_resets_baseline_and_sets_dirty() { // clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); mgr.advance_persisted_count(1, true); assert_eq!(mgr.persisted_msg_count(), 1); mgr.clear(); assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0"); assert!(mgr.needs_full_rewrite(), "clear 应置 dirty"); assert_eq!(mgr.len(), 0); } #[test] fn phase1_restore_from_messages_aligns_baseline_clears_dirty() { // DB 加载(restore_from_messages)后:基线对齐到 len + 清 dirty // (DB 已是该真相源,append 基线应对齐当前长度,不需全量重写) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("旧内存")); // 制造一个 dirty + 非零状态 mgr.mark_needs_full_rewrite(); // 模拟从 DB 加载 3 条消息 let loaded = vec![ ChatMessage::user("db1"), ChatMessage::assistant("db2"), ChatMessage::user("db3"), ]; mgr.restore_from_messages(loaded); assert_eq!(mgr.persisted_msg_count(), 3, "基线应对齐到 DB 加载的消息数"); assert!(!mgr.needs_full_rewrite(), "DB 加载后不应需全量重写"); assert_eq!(mgr.len(), 3); } #[test] fn phase1_mark_needs_full_rewrite_idempotent() { // 幂等:重复 mark 不报警不改变语义(供 messages_mut 调用方多次调用兜底) let mut mgr = ContextManager::new(cfg(100_000)); mgr.mark_needs_full_rewrite(); mgr.mark_needs_full_rewrite(); mgr.mark_needs_full_rewrite(); assert!(mgr.needs_full_rewrite()); } #[test] fn phase1_append_after_dirty_stays_dirty_until_save() { // dirty 后再 push(append):dirty 保持(下次 save 仍走全量重写收敛,不会因 push 重置) // 验证 push 不清 dirty(否则 compress 后追加新消息会漏收敛旧行) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("a")); mgr.mark_needs_full_rewrite(); // 模拟 compress mgr.push(ChatMessage::user("b")); // 追加 assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty"); // 模拟 save(全量重写)成功后推进 + 清 mgr.advance_persisted_count(2, true); assert!(!mgr.needs_full_rewrite()); } // ============================================================ // 视图裁剪补偿摘要(淘汰区头部 user 概要,防无声丢失) // ============================================================ #[test] fn eviction_inserts_summary_message_at_view_tail() { // 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题), // 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。 // 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移 // 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。 // 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。 let mut mgr = ContextManager::new(cfg(200)); for i in 0..20 { if i % 2 == 0 { mgr.push(ChatMessage::user(&format!( "审批流程与审批门禁需要适配 第{}条消息内容足够长确保淘汰文本合计超过一百字符", i ))); } else { mgr.push(ChatMessage::assistant(&format!( "第{}条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", i ))); } } let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); // 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题) let tail = msgs.last().expect("视图不应为空"); assert!( matches!(tail.role, MessageRole::User), "视图尾部应为 user 摘要, 实际 {:?}", tail.role ); assert!( tail.content.starts_with("[早期对话概要] 高频主题: "), "摘要格式不符, 实际 {}", tail.content ); assert!( tail.content.contains("审批"), "摘要应含被淘汰区高频主题词'审批', 实际 {}", tail.content ); assert!( tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS, "摘要应 ≤ {} 字符, 实际 {}", EVICTION_SUMMARY_MAX_CHARS, tail.content.chars().count() ); // 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant // 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant assert_eq!( msgs[msgs.len() - 2].content, "第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", "保护区最新消息被误裁" ); assert!( matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant), "摘要前一条应为 assistant(i=19), 实际 {:?}", msgs[msgs.len() - 2].role ); // 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀) assert!( matches!(msgs[0].role, MessageRole::User), "视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}", msgs[0].role ); // 纯视图:摘要不进内存全量,被淘汰原文存储态不变 assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); assert!( mgr.all_messages_clone() .iter() .all(|m| !m.content.starts_with("[早期对话概要]")), "补偿摘要不应写入存储(纯发送视图)" ); } fn role_tag(m: &ChatMessage) -> &'static str { match m.role { MessageRole::System => "system", MessageRole::User => "user", MessageRole::Assistant => "assistant", MessageRole::Tool => "tool", } } #[test] fn no_eviction_output_unchanged_no_summary() { // 预算内 → 输出与改动前一致:消息序列逐条原样,无补偿摘要插入(回归护栏)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("第一条问题")); mgr.push(ChatMessage::assistant("第一条回复")); mgr.push(ChatMessage::user("第二条问题")); mgr.push(ChatMessage::assistant("第二条回复")); let (msgs, trimmed) = mgr.build_for_request(0); assert!(!trimmed); assert_eq!(msgs.len(), 4); let pair: Vec<(&str, &str)> = msgs .iter() .map(|m| (role_tag(m), m.content.as_str())) .collect(); assert_eq!( pair, vec![ ("user", "第一条问题"), ("assistant", "第一条回复"), ("user", "第二条问题"), ("assistant", "第二条回复"), ], "预算内输出应与历史序列逐条一致" ); } #[test] fn eviction_tiny_text_no_summary() { // 被淘汰内容极短(单字消息,合计 <100 字符)→ 不值得产摘要,不插补偿摘要消息。 // 20 条消息(约 5 token/条),cfg(40) 触发裁剪,淘汰前缀合计仅二十余字符。 let mut mgr = ContextManager::new(cfg(40)); for i in 0..20 { if i % 2 == 0 { mgr.push(ChatMessage::user(&format!("问{}", i))); } else { mgr.push(ChatMessage::assistant(&format!("答{}", i))); } } let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); assert!( msgs.iter() .all(|m| !m.content.starts_with("[早期对话概要]")), "极短淘汰文本不应产生补偿摘要, 实际 {:?}", msgs.iter().map(|m| format!("{:?}", m.role)).collect::>() ); assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量"); } #[test] fn eviction_no_user_text_no_summary() { // 被淘汰区无任何 user 消息(仅 assistant 长文本)→ 提炼核心只认 user → 无关键词 → 不插摘要。 // 布局:10 条 assistant 长文本(淘汰候选)+ 6 条短 user(保护区),预算卡在淘汰 4 条 assistant。 let mut mgr = ContextManager::new(cfg(150)); for i in 0..10 { mgr.push(ChatMessage::assistant(&format!( "这是一条没有主题价值的 assistant 长文本第{}条,用于撑爆预算但提炼不出关键词", i ))); } for i in 0..6 { mgr.push(ChatMessage::user(&format!("问{}", i))); } let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); assert!( msgs.iter() .all(|m| !m.content.starts_with("[早期对话概要]")), "无可提炼关键词(无 user)时不应插补偿摘要" ); assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量"); } // ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ── #[test] fn push_system_anchor_appends_on_normal_tail() { let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问")); mgr.push(ChatMessage::assistant("答")); let avoided = mgr.push_system_anchor(ChatMessage::system("警示")); assert!(!avoided, "常规尾部应走追加(不避让)"); assert_eq!(mgr.len(), 3); assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部"); } #[test] fn push_system_anchor_avoids_dangling_tool_call_head() { // [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("跑工具")); mgr.push(ChatMessage::assistant_with_tools( "", vec![ToolCall::new("c1".to_string(), "t", "{}")], )); let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要")); assert!(avoided, "末条为悬空 tool_call 头应避让插入"); // 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开 let msgs = mgr.all_messages_clone(); assert_eq!(msgs.len(), 3); assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前"); assert!( msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()), "悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)" ); } // ============================================================ // 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user) // ============================================================ /// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。 /// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。 fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) { mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler))); mgr.push(ChatMessage::assistant_with_tools( "", vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")], )); mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果")); mgr.push(ChatMessage::assistant(&format!("小结{}", tag))); } /// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。 fn view_history_tokens(msgs: &[ChatMessage]) -> u64 { let est = TokenEstimator::default(); msgs.iter() .filter(|m| !m.content.starts_with("[早期对话概要]")) .map(|m| est.estimate_message(m) as u64) .sum() } #[test] fn block_trim_lands_below_target_water_level() { // W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。 let mut mgr = ContextManager::new(cfg(1000)); for i in 0..40 { let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i); if i % 2 == 0 { mgr.push(ChatMessage::user(&text)); } else { mgr.push(ChatMessage::assistant(&text)); } } assert!(mgr.history_tokens() > 1000, "前置:历史须超预算"); mgr.set_cache_friendly(true); let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64; let remaining = view_history_tokens(&msgs); assert!( remaining <= target, "裁后水位应落在目标 {} 以下, 实际 {}", target, remaining ); // 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条) let last_real = msgs .iter() .rev() .find(|m| !m.content.starts_with("[早期对话概要]")) .expect("视图非空"); assert!( last_real.content.contains("第39条"), "保护区最新消息被误裁, 实际末条: {}", last_real.content ); } #[test] fn block_trim_snaps_boundary_to_turn_start() { // W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点 // (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14 // 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。 let mut mgr = ContextManager::new(cfg(300)); let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间"; for r in 0..3 { push_tool_round(&mut mgr, &r.to_string(), filler); } mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler))); mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler))); for i in 0..6 { let text = format!("保护区{}需要足够长的文本来撑量", i); if i % 2 == 0 { mgr.push(ChatMessage::user(&text)); } else { mgr.push(ChatMessage::assistant(&text)); } } assert!(mgr.history_tokens() > 300, "前置:历史须超预算"); mgr.set_cache_friendly(true); let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); let first = msgs.first().expect("视图非空"); assert!( matches!(first.role, MessageRole::User), "裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}", first.role ); let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64; assert!( view_history_tokens(&msgs) <= target, "吸附同时仍应满足目标水位" ); assert_eq!( has_head(&msgs), has_tail(&msgs), "轮吸附不得劈开工具三元组" ); } #[test] fn block_trim_pathological_falls_back_to_hard_budget() { // W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条 // 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界, // 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。 let mut mgr = ContextManager::new(cfg(400)); let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10); for r in 0..3 { push_tool_round(&mut mgr, &r.to_string(), &filler); } // 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达 mgr.push(ChatMessage::system("[早期锚点]")); for i in 0..5 { let text = format!("保护区{}需要足够长的文本来撑量", i); if i % 2 == 0 { mgr.push(ChatMessage::user(&text)); } else { mgr.push(ChatMessage::assistant(&text)); } } assert!(mgr.history_tokens() > 400, "前置:历史须超预算"); mgr.set_cache_friendly(true); let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "病态回退仍应触发裁剪"); let remaining = view_history_tokens(&msgs); assert!( remaining <= 400, "硬预算安全底线:裁后不得超过 available, 实际 {}", remaining ); let first = msgs.first().expect("视图非空"); assert!( first.content.contains("[早期锚点]"), "回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}", first.content ); } #[test] fn cache_friendly_off_keeps_greedy_min_trim() { // W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。 let mut mgr = ContextManager::new(cfg(1000)); for i in 0..40 { let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i); if i % 2 == 0 { mgr.push(ChatMessage::user(&text)); } else { mgr.push(ChatMessage::assistant(&text)); } } // 不注入(字段默认 false)= 开关关 let (msgs, trimmed) = mgr.build_for_request(0); assert!(trimmed, "超预算应触发裁剪"); let remaining = view_history_tokens(&msgs); let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64; assert!( remaining <= 1000, "贪心裁后应塞下硬预算, 实际 {}", remaining ); assert!( remaining > target, "贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}", target, remaining ); } #[test] fn system_anchor_view_becomes_user_when_cache_friendly_on() { // W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层 // system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问")); mgr.push(ChatMessage::assistant("答")); mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]")); mgr.set_cache_friendly(true); let (msgs, _t) = mgr.build_for_request(0); assert!(!msgs.is_empty()); assert!( msgs.iter().all(|m| !matches!(m.role, MessageRole::System)), "开时视图不得残留 system: {:?}", msgs.iter().map(role_tag).collect::>() ); assert!( mgr.all_messages_clone() .iter() .any(|m| matches!(m.role, MessageRole::System)), "存储态 system 锚点不应被改写(纯视图)" ); let mut final_view = vec![ChatMessage::system("SYS_PROMPT")]; final_view.extend(msgs); assert!( matches!(final_view[0].role, MessageRole::System), "组装层首条 system_prompt 不转" ); assert_eq!( final_view .iter() .filter(|m| matches!(m.role, MessageRole::System)) .count(), 1, "最终视图应只有组装层首条 system" ); } #[test] fn system_anchor_head_insert_still_converted() { // W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问")); mgr.insert_at(0, ChatMessage::system("[摘要]")); mgr.set_cache_friendly(true); let (msgs, _t) = mgr.build_for_request(0); assert!( msgs.iter().all(|m| !matches!(m.role, MessageRole::System)), "存储首位的 system 锚点最终视图下标 > 0,应转 user" ); } #[test] fn system_anchor_stays_system_when_cache_friendly_off() { // W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。 let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("问")); mgr.push(ChatMessage::assistant("答")); mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]")); let (msgs, _t) = mgr.build_for_request(0); assert!( msgs.iter().any(|m| matches!(m.role, MessageRole::System)), "关时 system 锚点应原样进视图" ); }