Files
DevFlow/crates/df-ai/src/context/manager_tests.rs
T
lxy c1e5b96d75 修复: 上下文流式与工具签名域清债
- tool_signature 归一化补参,合法换参不再误触探索熔断
- SSE 超限截头改对齐事件边界保尾,流不再永久破损
- content_filter 纳入终态判定,无 DONE 流也收敛
- sanitize 占位补头前缀短路只吞 orphan 的盲区消除
- intent 关键词加词边界,history 不再误判
- 恢复重放后重置话题锚点,URL 图 token 地板不被稀释
2026-10-03 15:35:05 +08:00

1184 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<String> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.map(|c| c.id.clone())
.collect();
// 每个 tool_result 的 tool_call_id 都必须在 head_ids 内(不残留 orphan result)
let orphans: Vec<&str> = msgs
.iter()
.filter(|m| matches!(m.role, MessageRole::Tool))
.filter_map(|m| m.tool_call_id.as_deref())
.filter(|id| !head_ids.contains(*id))
.collect();
assert!(
orphans.is_empty(),
"裁剪后不应有 orphan tool_result(无对应头), 实际 orphans={:?}, heads={:?}",
orphans, head_ids
);
}
#[test]
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::<String>()).collect::<Vec<_>>()
);
}
#[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::<Vec<_>>()
);
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, "裁剪污染了内存全量");
}