//! `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, 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 模式(无字节)也按 URL 长度估算,不爆 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); } #[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 必空"); }