//! `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 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级 assert!( mm_tokens > 500, "大 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 条超预算,触发裁剪且保留保护区 let mut mgr = ContextManager::new(cfg(200)); // 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):推进基线 + 清 needs_full_rewrite(DB 已与内存一致) let mut mgr = ContextManager::new(cfg(100_000)); mgr.push(ChatMessage::user("x")); mgr.push(ChatMessage::user("y")); // 模拟 save 成功后推进 mgr.advance_persisted_count(2); 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); mgr.advance_persisted_count(3); // 误传更小 assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退"); } #[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); // 模拟 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); 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); assert!(!mgr.needs_full_rewrite()); } // ============================================================ // 视图裁剪补偿摘要(淘汰区头部 user 概要,防无声丢失) // ============================================================ #[test] fn eviction_inserts_summary_message_at_view_head() { // 触发淘汰 → 视图头部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题), // 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。 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 head = msgs.first().expect("视图不应为空"); assert!( matches!(head.role, MessageRole::User), "视图头部应为 user 摘要, 实际 {:?}", head.role ); assert!( head.content.starts_with("[早期对话概要] 高频主题: "), "摘要格式不符, 实际 {}", head.content ); assert!( head.content.contains("审批"), "摘要应含被淘汰区高频主题词'审批', 实际 {}", head.content ); assert!( head.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS, "摘要应 ≤ {} 字符, 实际 {}", EVICTION_SUMMARY_MAX_CHARS, head.content.chars().count() ); // 保护消息完好:末条(i=19 assistant)保留;摘要 user 与首轮真实 user 相邻,由 // sanitize 的 ensure_sequence_legal 合并(摘要在前,换行接原文),故 msgs[1] 为 i=1 的 assistant assert_eq!( msgs.last().unwrap().content, "第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", "保护区最新消息被误裁" ); assert!( matches!(msgs[1].role, MessageRole::Assistant), "摘要与首轮 user 合并后,第二条应为 assistant, 实际 {:?}", msgs[1].role ); assert!( msgs[0].content.contains("审批流程与审批门禁需要适配"), "合并不应丢失被保留的首轮真实 user 文本, 实际 {}", msgs[0].content ); // 纯视图:摘要不进内存全量,被淘汰原文存储态不变 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, "裁剪污染了内存全量"); }