- 块状裁剪: 水位目标+轮边界吸附+病态回退,贪心路径保留为开关回退 - 存储态 System 锚点出请求时视图转 user,治前缀缓存整体折叠失配 - BP3 断点机制+缓存命中率观测日志,调参有据 - 压缩摘要可召回: JSONL 档案落盘+句柄行注入,take 语义一次性消费 - 字符估算修正: CJK 分档计权+改图地板,估算对象对齐实际消耗 - namespace 引用跨重启内容哈希懒回填
1480 lines
61 KiB
Rust
1480 lines
61 KiB
Rust
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
|
||
//!
|
||
//! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress /
|
||
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
|
||
//! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
|
||
|
||
#![cfg(test)]
|
||
|
||
use super::*;
|
||
use crate::context_helpers::{
|
||
ContextConfig, EVICTION_SUMMARY_MAX_CHARS, TokenEstimator, PROTECT_COUNT,
|
||
};
|
||
use crate::provider::ToolCall;
|
||
|
||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||
ContextConfig {
|
||
max_tokens,
|
||
output_reserve: 0,
|
||
safety_ratio: 1.0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn estimate_message_counts_parts_tokens() {
|
||
// 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||
let est = TokenEstimator::default();
|
||
|
||
// 纯文本基线
|
||
let text_msg = ChatMessage::user("短文本");
|
||
let text_tokens = est.estimate_message(&text_msg);
|
||
|
||
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
|
||
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
|
||
let multimodal = ChatMessage::user_parts(
|
||
"短文本",
|
||
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
|
||
);
|
||
let mm_tokens = est.estimate_message(&multimodal);
|
||
|
||
assert!(
|
||
mm_tokens > text_tokens,
|
||
"含图消息 token({}) 应高于纯文本({})",
|
||
mm_tokens,
|
||
text_tokens
|
||
);
|
||
// base64 改按图地板计(~1600,与 provider 端按图计费同口径),不随字符串长度膨胀
|
||
assert!(
|
||
mm_tokens >= 1600,
|
||
"大 base64 应按图地板计 token,实际 {}",
|
||
mm_tokens
|
||
);
|
||
|
||
// url 模式(无字节)按每图 ~1600 token 地板估(生效于 ×0.35 折扣之后,防预算漏裁)
|
||
let url_msg = ChatMessage::user_parts(
|
||
"t",
|
||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||
);
|
||
let url_tokens = est.estimate_message(&url_msg);
|
||
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
|
||
}
|
||
|
||
#[test]
|
||
fn short_history_no_trim() {
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("你好"));
|
||
mgr.push(ChatMessage::assistant("你好啊"));
|
||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||
assert!(!trimmed);
|
||
assert_eq!(msgs.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn over_budget_trims_old() {
|
||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区。
|
||
// 预算取值须容纳「补偿摘要+保护区 6 条」(中文按 1.2 token/字计约 230)再留裁剪空间,
|
||
// 否则保护区被挤压、最新消息也被压,测的就不再是「裁旧保新」语义。
|
||
let mut mgr = ContextManager::new(cfg(400));
|
||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||
for i in 0..20 {
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
|
||
}
|
||
}
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||
|
||
// 保护区:最新一条必保留(末条 i=19 是 assistant)
|
||
assert_eq!(
|
||
msgs.last().unwrap().content,
|
||
"第 19 条较长的回复用于撑爆预算",
|
||
"保护区最新消息被误裁"
|
||
);
|
||
|
||
// 裁剪是视图:内存全量不变
|
||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||
}
|
||
|
||
#[test]
|
||
fn tool_triplet_kept_atomic() {
|
||
// 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断
|
||
// 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
|
||
let mut mgr = ContextManager::new(cfg(95));
|
||
for i in 0..6 {
|
||
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
|
||
}
|
||
mgr.push(ChatMessage::assistant_with_tools(
|
||
"调工具",
|
||
vec![ToolCall::new("tc1", "read_file", "{}")],
|
||
));
|
||
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
|
||
mgr.push(ChatMessage::assistant("完成"));
|
||
for i in 0..6 {
|
||
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
|
||
}
|
||
|
||
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
|
||
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
|
||
assert!(trimmed1, "分支一应触发裁剪");
|
||
assert_eq!(
|
||
has_head(&msgs_keep),
|
||
has_tail(&msgs_keep),
|
||
"分支一三元组被切断: head={} tail={}",
|
||
has_head(&msgs_keep),
|
||
has_tail(&msgs_keep)
|
||
);
|
||
|
||
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
|
||
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
|
||
assert!(trimmed2, "分支二应触发裁剪");
|
||
assert_eq!(
|
||
has_head(&msgs_drop),
|
||
has_tail(&msgs_drop),
|
||
"分支二三元组被切断: head={} tail={}",
|
||
has_head(&msgs_drop),
|
||
has_tail(&msgs_drop)
|
||
);
|
||
|
||
// 裁剪是视图:两次 build 都不应改变内存全量
|
||
assert_eq!(
|
||
mgr.all_messages_clone().len(),
|
||
15,
|
||
"裁剪污染了内存全量"
|
||
);
|
||
}
|
||
|
||
fn has_head(msgs: &[ChatMessage]) -> bool {
|
||
msgs.iter()
|
||
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||
}
|
||
|
||
fn has_tail(msgs: &[ChatMessage]) -> bool {
|
||
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
|
||
}
|
||
|
||
#[test]
|
||
fn replace_tool_result_updates_tokens() {
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::tool_result("tc1", "短"));
|
||
let before = mgr.history_tokens();
|
||
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
|
||
let after = mgr.history_tokens();
|
||
assert!(after > before);
|
||
}
|
||
|
||
// ============================================================
|
||
// 三元组完整性 P1(根治):裁剪/压缩必须保证三元组原子性,
|
||
// 不出现 tool_result 残留但 tool_call 头被裁的 orphan(根因:AI Coding 静默停)。
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn trim_never_produces_orphan_tool_result_without_head() {
|
||
// 三元组完整性 P1 回归:超预算裁剪后,发送视图中**任何 tool_result 的 tool_call_id 都必须
|
||
// 有对应 assistant tool_call 头**(不残留 orphan result)。裁剪按 build_eviction_units 的
|
||
// 三元组原子单元(Head+Tail+Standalone Assistant 同进同出),trim_end 始终落在单元边界,
|
||
// 永不切断三元组。
|
||
//
|
||
// 布局:5 旧 user/assistant 文本(淘汰区) + 完整三元组(tc_mid) + 6 新(保护区) = 14 条
|
||
// 强制小预算使裁剪切到三元组边界(整体保留或整体丢弃,不在中间切)。
|
||
let mut mgr = ContextManager::new(cfg(120));
|
||
for i in 0..5 {
|
||
mgr.push(ChatMessage::user(&format!("旧消息 {} 用于撑爆预算的较长文本", i)));
|
||
}
|
||
// 完整三元组(裁剪边界附近)
|
||
mgr.push(ChatMessage::assistant_with_tools(
|
||
"调中间工具",
|
||
vec![ToolCall::new("tc_mid", "read_file", "{}")],
|
||
));
|
||
mgr.push(ChatMessage::tool_result("tc_mid", "中间工具结果"));
|
||
mgr.push(ChatMessage::assistant("中间完成"));
|
||
// 保护区(最近 PROTECT_COUNT=6 条)
|
||
for i in 0..6 {
|
||
mgr.push(ChatMessage::user(&format!("新消息 {} 保护区", i)));
|
||
}
|
||
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "应触发裁剪");
|
||
|
||
// 收集发送视图中所有 assistant 头的 tool_call.id
|
||
use std::collections::HashSet;
|
||
let head_ids: HashSet<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, full):推进基线;全量重写才清 needs_full_rewrite
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("x"));
|
||
mgr.push(ChatMessage::user("y"));
|
||
// 模拟 save(全量重写)成功后推进
|
||
mgr.advance_persisted_count(2, true);
|
||
assert_eq!(mgr.persisted_msg_count(), 2);
|
||
assert!(!mgr.needs_full_rewrite());
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_advance_persisted_count_max_no_regression() {
|
||
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.advance_persisted_count(5, true);
|
||
mgr.advance_persisted_count(3, true); // 误传更小
|
||
assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_advance_incremental_keeps_dirty() {
|
||
// 增量/无变化路径(full=false)不得清 dirty:写库期间新置的 dirty(如压缩链 mark)
|
||
// 与本次 append 写库无关,误清会致改动滞留内存永不落库
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("a"));
|
||
mgr.advance_persisted_count(1, false); // 增量 save 成功
|
||
assert!(!mgr.needs_full_rewrite());
|
||
mgr.mark_needs_full_rewrite(); // 模拟写库期间压缩 mark
|
||
mgr.advance_persisted_count(1, false); // 又一次增量 save
|
||
assert!(mgr.needs_full_rewrite(), "增量路径 advance 不得清 dirty");
|
||
mgr.advance_persisted_count(1, true); // 直到全量重写才清
|
||
assert!(!mgr.needs_full_rewrite());
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_compress_old_messages_sets_dirty() {
|
||
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("旧1"));
|
||
mgr.push(ChatMessage::user("旧2"));
|
||
mgr.push(ChatMessage::user("保护区"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
let compressed = mgr.compress_old_messages(2);
|
||
assert_eq!(compressed.len(), 2, "应压缩 2 条");
|
||
assert!(mgr.needs_full_rewrite(), "compress 后必须全量重写");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_compress_no_op_does_not_set_dirty() {
|
||
// 边界:compress_end 范围内无可压缩(全已 compressed)→ 状态未变,不应置 dirty
|
||
// (避免无意义全量重写,compress 重试场景常见)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("a"));
|
||
let _ = mgr.compress_old_messages(1);
|
||
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
|
||
mgr.advance_persisted_count(1, true); // 模拟 save(全量)后清 dirty
|
||
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
|
||
let compressed = mgr.compress_old_messages(1);
|
||
assert!(compressed.is_empty());
|
||
// 注:此处 dirty 在二次 compress 时未变(未置位),但 advance 已清,故仍 false
|
||
assert!(!mgr.needs_full_rewrite(), "无新压缩不应置 dirty");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_replace_tool_result_content_sets_dirty() {
|
||
// 审批 replace_tool_result 改既有 tool 消息 content → 必须全量重写
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::assistant_with_tools(
|
||
"调用工具",
|
||
vec![ToolCall {
|
||
id: "call_1".into(),
|
||
call_type: "function".into(),
|
||
function: crate::provider::ToolCallFunction {
|
||
name: "read_file".into(),
|
||
arguments: "{}".into(),
|
||
},
|
||
}],
|
||
));
|
||
mgr.push(ChatMessage::tool_result("call_1", "占位结果"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
let replaced = mgr.replace_tool_result_content("call_1", "真实结果");
|
||
assert!(replaced, "应找到并替换");
|
||
assert!(mgr.needs_full_rewrite(), "replace content 后必须全量重写");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_pop_last_assistant_round_sets_dirty() {
|
||
// 重新生成 pop 末尾 assistant 轮 → len 缩短 → 必须全量重写(删 DB 尾行)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("问"));
|
||
mgr.push(ChatMessage::assistant("答"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
let popped = mgr.pop_last_assistant_round();
|
||
assert!(popped);
|
||
assert!(mgr.needs_full_rewrite(), "pop 后必须全量重写");
|
||
assert_eq!(mgr.len(), 1, "应只剩 user");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_pop_no_op_does_not_set_dirty() {
|
||
// 边界:无可弹 assistant(末尾是 user)→ 不变,不应置 dirty
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("只有 user"));
|
||
let popped = mgr.pop_last_assistant_round();
|
||
assert!(!popped);
|
||
assert!(!mgr.needs_full_rewrite(), "pop no-op 不应置 dirty");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_truncate_after_user_message_sets_dirty() {
|
||
// 编辑重生成 truncate 后续消息 status → 必须全量重写
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("原始问题"));
|
||
mgr.push(ChatMessage::assistant("答1"));
|
||
mgr.push(ChatMessage::assistant("答2"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
let count = mgr.truncate_after_user_message("原始问题").expect("应找到");
|
||
assert_eq!(count, 2);
|
||
assert!(mgr.needs_full_rewrite(), "truncate 后必须全量重写");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_replace_last_active_user_content_sets_dirty() {
|
||
// 编辑 user 消息 content → 必须全量重写
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("旧问题"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
mgr.replace_last_active_user_content("新问题").expect("应替换");
|
||
assert!(mgr.needs_full_rewrite(), "replace user content 后必须全量重写");
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_insert_at_sets_dirty() {
|
||
// 中段插入(压缩摘要/话题标记)改变 seq → 必须全量重写
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("a"));
|
||
mgr.push(ChatMessage::user("b"));
|
||
assert!(!mgr.needs_full_rewrite());
|
||
mgr.insert_at(0, ChatMessage::system("[摘要]"));
|
||
assert!(mgr.needs_full_rewrite(), "insert_at 后必须全量重写( seq 错位)");
|
||
assert_eq!(mgr.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_clear_resets_baseline_and_sets_dirty() {
|
||
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("a"));
|
||
mgr.advance_persisted_count(1, true);
|
||
assert_eq!(mgr.persisted_msg_count(), 1);
|
||
mgr.clear();
|
||
assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0");
|
||
assert!(mgr.needs_full_rewrite(), "clear 应置 dirty");
|
||
assert_eq!(mgr.len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_restore_from_messages_aligns_baseline_clears_dirty() {
|
||
// DB 加载(restore_from_messages)后:基线对齐到 len + 清 dirty
|
||
// (DB 已是该真相源,append 基线应对齐当前长度,不需全量重写)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("旧内存")); // 制造一个 dirty + 非零状态
|
||
mgr.mark_needs_full_rewrite();
|
||
// 模拟从 DB 加载 3 条消息
|
||
let loaded = vec![
|
||
ChatMessage::user("db1"),
|
||
ChatMessage::assistant("db2"),
|
||
ChatMessage::user("db3"),
|
||
];
|
||
mgr.restore_from_messages(loaded);
|
||
assert_eq!(mgr.persisted_msg_count(), 3, "基线应对齐到 DB 加载的消息数");
|
||
assert!(!mgr.needs_full_rewrite(), "DB 加载后不应需全量重写");
|
||
assert_eq!(mgr.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_mark_needs_full_rewrite_idempotent() {
|
||
// 幂等:重复 mark 不报警不改变语义(供 messages_mut 调用方多次调用兜底)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.mark_needs_full_rewrite();
|
||
mgr.mark_needs_full_rewrite();
|
||
mgr.mark_needs_full_rewrite();
|
||
assert!(mgr.needs_full_rewrite());
|
||
}
|
||
|
||
#[test]
|
||
fn phase1_append_after_dirty_stays_dirty_until_save() {
|
||
// dirty 后再 push(append):dirty 保持(下次 save 仍走全量重写收敛,不会因 push 重置)
|
||
// 验证 push 不清 dirty(否则 compress 后追加新消息会漏收敛旧行)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("a"));
|
||
mgr.mark_needs_full_rewrite(); // 模拟 compress
|
||
mgr.push(ChatMessage::user("b")); // 追加
|
||
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
|
||
// 模拟 save(全量重写)成功后推进 + 清
|
||
mgr.advance_persisted_count(2, true);
|
||
assert!(!mgr.needs_full_rewrite());
|
||
}
|
||
|
||
// ============================================================
|
||
// 视图裁剪补偿摘要(淘汰区头部 user 概要,防无声丢失)
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn eviction_inserts_summary_message_at_view_tail() {
|
||
// 触发淘汰 → 视图尾部应有一条 "[早期对话概要]" user 摘要(补偿无声丢弃的早期主题),
|
||
// 概要 ≤ EVICTION_SUMMARY_MAX_CHARS,保护消息完好,且纯视图(不污染内存全量/不落库)。
|
||
// 缓存友好(2026-10):摘要改尾部追加(push)而非头部插入(insert(0))——头部插入位移
|
||
// 整条前缀字节,使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
|
||
// 尾部追加让稳定前缀(system + 早期 history)逐轮不变,命中率最大化。
|
||
let mut mgr = ContextManager::new(cfg(200));
|
||
for i in 0..20 {
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&format!(
|
||
"审批流程与审批门禁需要适配 第{}条消息内容足够长确保淘汰文本合计超过一百字符", i
|
||
)));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&format!(
|
||
"第{}条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑", i
|
||
)));
|
||
}
|
||
}
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
|
||
// 视图尾部 = 补偿摘要(role=user,"[早期对话概要]" 前缀 + 高频主题)
|
||
let tail = msgs.last().expect("视图不应为空");
|
||
assert!(
|
||
matches!(tail.role, MessageRole::User),
|
||
"视图尾部应为 user 摘要, 实际 {:?}",
|
||
tail.role
|
||
);
|
||
assert!(
|
||
tail.content.starts_with("[早期对话概要] 高频主题: "),
|
||
"摘要格式不符, 实际 {}",
|
||
tail.content
|
||
);
|
||
assert!(
|
||
tail.content.contains("审批"),
|
||
"摘要应含被淘汰区高频主题词'审批', 实际 {}",
|
||
tail.content
|
||
);
|
||
assert!(
|
||
tail.content.chars().count() <= EVICTION_SUMMARY_MAX_CHARS,
|
||
"摘要应 ≤ {} 字符, 实际 {}",
|
||
EVICTION_SUMMARY_MAX_CHARS,
|
||
tail.content.chars().count()
|
||
);
|
||
|
||
// 保护消息完好:末条(i=19 assistant)保留;摘要 user 追加在尾部,与末尾 assistant
|
||
// 不同 role 不合并,故 msgs[倒数第2] 仍为 i=19 的 assistant
|
||
assert_eq!(
|
||
msgs[msgs.len() - 2].content,
|
||
"第19条回复内容同样较长用于撑爆上下文预算触发视图裁剪逻辑",
|
||
"保护区最新消息被误裁"
|
||
);
|
||
assert!(
|
||
matches!(msgs[msgs.len() - 2].role, MessageRole::Assistant),
|
||
"摘要前一条应为 assistant(i=19), 实际 {:?}",
|
||
msgs[msgs.len() - 2].role
|
||
);
|
||
// 首条仍是保留区真实首条 user(摘要不再抢占头部,不位移前缀)
|
||
assert!(
|
||
matches!(msgs[0].role, MessageRole::User),
|
||
"视图头部应为真实首条 user(摘要已移尾部), 实际 {:?}",
|
||
msgs[0].role
|
||
);
|
||
|
||
// 纯视图:摘要不进内存全量,被淘汰原文存储态不变
|
||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||
assert!(
|
||
mgr.all_messages_clone()
|
||
.iter()
|
||
.all(|m| !m.content.starts_with("[早期对话概要]")),
|
||
"补偿摘要不应写入存储(纯发送视图)"
|
||
);
|
||
}
|
||
|
||
fn role_tag(m: &ChatMessage) -> &'static str {
|
||
match m.role {
|
||
MessageRole::System => "system",
|
||
MessageRole::User => "user",
|
||
MessageRole::Assistant => "assistant",
|
||
MessageRole::Tool => "tool",
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn no_eviction_output_unchanged_no_summary() {
|
||
// 预算内 → 输出与改动前一致:消息序列逐条原样,无补偿摘要插入(回归护栏)。
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("第一条问题"));
|
||
mgr.push(ChatMessage::assistant("第一条回复"));
|
||
mgr.push(ChatMessage::user("第二条问题"));
|
||
mgr.push(ChatMessage::assistant("第二条回复"));
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(!trimmed);
|
||
assert_eq!(msgs.len(), 4);
|
||
let pair: Vec<(&str, &str)> = msgs
|
||
.iter()
|
||
.map(|m| (role_tag(m), m.content.as_str()))
|
||
.collect();
|
||
assert_eq!(
|
||
pair,
|
||
vec![
|
||
("user", "第一条问题"),
|
||
("assistant", "第一条回复"),
|
||
("user", "第二条问题"),
|
||
("assistant", "第二条回复"),
|
||
],
|
||
"预算内输出应与历史序列逐条一致"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn eviction_tiny_text_no_summary() {
|
||
// 被淘汰内容极短(单字消息,合计 <100 字符)→ 不值得产摘要,不插补偿摘要消息。
|
||
// 20 条消息(约 5 token/条),cfg(40) 触发裁剪,淘汰前缀合计仅二十余字符。
|
||
let mut mgr = ContextManager::new(cfg(40));
|
||
for i in 0..20 {
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&format!("问{}", i)));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&format!("答{}", i)));
|
||
}
|
||
}
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
assert!(
|
||
msgs.iter()
|
||
.all(|m| !m.content.starts_with("[早期对话概要]")),
|
||
"极短淘汰文本不应产生补偿摘要, 实际 {:?}",
|
||
msgs.iter().map(|m| format!("{:?}", m.role)).collect::<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, "裁剪污染了内存全量");
|
||
}
|
||
|
||
// ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ──
|
||
|
||
#[test]
|
||
fn push_system_anchor_appends_on_normal_tail() {
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("问"));
|
||
mgr.push(ChatMessage::assistant("答"));
|
||
let avoided = mgr.push_system_anchor(ChatMessage::system("警示"));
|
||
assert!(!avoided, "常规尾部应走追加(不避让)");
|
||
assert_eq!(mgr.len(), 3);
|
||
assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部");
|
||
}
|
||
|
||
#[test]
|
||
fn push_system_anchor_avoids_dangling_tool_call_head() {
|
||
// [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填)
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("跑工具"));
|
||
mgr.push(ChatMessage::assistant_with_tools(
|
||
"",
|
||
vec![ToolCall::new("c1".to_string(), "t", "{}")],
|
||
));
|
||
let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要"));
|
||
assert!(avoided, "末条为悬空 tool_call 头应避让插入");
|
||
// 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开
|
||
let msgs = mgr.all_messages_clone();
|
||
assert_eq!(msgs.len(), 3);
|
||
assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前");
|
||
assert!(
|
||
msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()),
|
||
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 缓存友好上下文(W1 块状裁剪 + W2 system 锚点 view 层转 user)
|
||
// ============================================================
|
||
|
||
/// 推入一个完整工具轮 [user 提问, assistant 带 tool_call, tool_result, assistant 小结]。
|
||
/// 淘汰单元随之分为「user 独立单元 + 三元组单元」,轮起点(user)是轮吸附锚点。
|
||
fn push_tool_round(mgr: &mut ContextManager, tag: &str, filler: &str) {
|
||
mgr.push(ChatMessage::user(&format!("问题{}{}", tag, filler)));
|
||
mgr.push(ChatMessage::assistant_with_tools(
|
||
"",
|
||
vec![ToolCall::new(format!("tc{}", tag), "read_file", "{}")],
|
||
));
|
||
mgr.push(ChatMessage::tool_result(format!("tc{}", tag), "结果"));
|
||
mgr.push(ChatMessage::assistant(&format!("小结{}", tag)));
|
||
}
|
||
|
||
/// 视图中真实历史的 token 量:排除尾部追加的淘汰补偿摘要(它不计入裁剪水位)。
|
||
fn view_history_tokens(msgs: &[ChatMessage]) -> u64 {
|
||
let est = TokenEstimator::default();
|
||
msgs.iter()
|
||
.filter(|m| !m.content.starts_with("[早期对话概要]"))
|
||
.map(|m| est.estimate_message(m) as u64)
|
||
.sum()
|
||
}
|
||
|
||
#[test]
|
||
fn block_trim_lands_below_target_water_level() {
|
||
// W1:开关开后超预算裁剪一次裁到 T×available 水位以下(贪心只裁到刚好塞下 available)。
|
||
let mut mgr = ContextManager::new(cfg(1000));
|
||
for i in 0..40 {
|
||
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&text));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&text));
|
||
}
|
||
}
|
||
assert!(mgr.history_tokens() > 1000, "前置:历史须超预算");
|
||
mgr.set_cache_friendly(true);
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
|
||
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||
let remaining = view_history_tokens(&msgs);
|
||
assert!(
|
||
remaining <= target,
|
||
"裁后水位应落在目标 {} 以下, 实际 {}",
|
||
target,
|
||
remaining
|
||
);
|
||
// 保护区最新消息不被动(摘要追加在尾部,末条真实消息是其前一条)
|
||
let last_real = msgs
|
||
.iter()
|
||
.rev()
|
||
.find(|m| !m.content.starts_with("[早期对话概要]"))
|
||
.expect("视图非空");
|
||
assert!(
|
||
last_real.content.contains("第39条"),
|
||
"保护区最新消息被误裁, 实际末条: {}",
|
||
last_real.content
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn block_trim_snaps_boundary_to_turn_start() {
|
||
// W1 轮边界吸附:水位在某单元边界达标但下一条不是 user 时继续扫,直到边界吸附轮起点
|
||
// (整问整答同生同死)。3 个工具轮 + 2 条过渡 + 6 条保护区,len=20 且 protect_start=14
|
||
// 恰为保护区起点;按纯水位会在过渡轮中间收手(首条为 assistant),吸附后首条必是 user。
|
||
let mut mgr = ContextManager::new(cfg(300));
|
||
let filler = "这一轮的体量用来把历史顶过预算线撑出裁剪空间";
|
||
for r in 0..3 {
|
||
push_tool_round(&mut mgr, &r.to_string(), filler);
|
||
}
|
||
mgr.push(ChatMessage::user(&format!("过渡问题A{}", filler)));
|
||
mgr.push(ChatMessage::assistant(&format!("过渡回复B{}", filler)));
|
||
for i in 0..6 {
|
||
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&text));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&text));
|
||
}
|
||
}
|
||
assert!(mgr.history_tokens() > 300, "前置:历史须超预算");
|
||
mgr.set_cache_friendly(true);
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
|
||
let first = msgs.first().expect("视图非空");
|
||
assert!(
|
||
matches!(first.role, MessageRole::User),
|
||
"裁剪边界应吸附轮起点(下一条未淘汰消息是 user), 实际首条 role={:?}",
|
||
first.role
|
||
);
|
||
let target = (300u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||
assert!(
|
||
view_history_tokens(&msgs) <= target,
|
||
"吸附同时仍应满足目标水位"
|
||
);
|
||
assert_eq!(
|
||
has_head(&msgs),
|
||
has_tail(&msgs),
|
||
"轮吸附不得劈开工具三元组"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn block_trim_pathological_falls_back_to_hard_budget() {
|
||
// W1 病态回退:单轮体量 > 块,扫到保护区仍无「水位+轮边界」齐备的边界(保护区首条
|
||
// 是 system 锚点,堵死末轮三元组边界的轮吸附)→ 回退取满足硬预算的最后单元边界,
|
||
// 硬预算安全底线不破;且回退比贪心裁得更深(贪心在首个硬预算边界即收手)。
|
||
let mut mgr = ContextManager::new(cfg(400));
|
||
let filler = "这一轮的提问体量远超块大小用来制造病态回退场景".repeat(10);
|
||
for r in 0..3 {
|
||
push_tool_round(&mut mgr, &r.to_string(), &filler);
|
||
}
|
||
// 保护区首条 system 锚点:末轮三元组边界 next 非 user,轮吸附不可达
|
||
mgr.push(ChatMessage::system("[早期锚点]"));
|
||
for i in 0..5 {
|
||
let text = format!("保护区{}需要足够长的文本来撑量", i);
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&text));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&text));
|
||
}
|
||
}
|
||
assert!(mgr.history_tokens() > 400, "前置:历史须超预算");
|
||
mgr.set_cache_friendly(true);
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "病态回退仍应触发裁剪");
|
||
|
||
let remaining = view_history_tokens(&msgs);
|
||
assert!(
|
||
remaining <= 400,
|
||
"硬预算安全底线:裁后不得超过 available, 实际 {}",
|
||
remaining
|
||
);
|
||
let first = msgs.first().expect("视图非空");
|
||
assert!(
|
||
first.content.contains("[早期锚点]"),
|
||
"回退应取满足硬预算的最后单元边界(裁到保护区起点), 实际首条: {}",
|
||
first.content
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_friendly_off_keeps_greedy_min_trim() {
|
||
// W1 开关关:走原贪心最小裁剪,裁到刚好塞下硬预算即停(高于目标水位),逐字节等价旧路径。
|
||
let mut mgr = ContextManager::new(cfg(1000));
|
||
for i in 0..40 {
|
||
let text = format!("第{}条这条消息足够长让总历史稳定超过一千token预算线", i);
|
||
if i % 2 == 0 {
|
||
mgr.push(ChatMessage::user(&text));
|
||
} else {
|
||
mgr.push(ChatMessage::assistant(&text));
|
||
}
|
||
}
|
||
// 不注入(字段默认 false)= 开关关
|
||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||
assert!(trimmed, "超预算应触发裁剪");
|
||
let remaining = view_history_tokens(&msgs);
|
||
let target = (1000u64 as f32 * crate::context_helpers::TRIM_TARGET_RATIO) as u64;
|
||
assert!(
|
||
remaining <= 1000,
|
||
"贪心裁后应塞下硬预算, 实际 {}",
|
||
remaining
|
||
);
|
||
assert!(
|
||
remaining > target,
|
||
"贪心只裁到刚好塞下(高于目标水位 {}), 实际 {}",
|
||
target,
|
||
remaining
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn system_anchor_view_becomes_user_when_cache_friendly_on() {
|
||
// W2:开关开,存储态 system 锚点在发送视图转 user;存储不动;最终视图(组装层
|
||
// system_prompt 在下标 0,不经 build_for_request)中 system 独占首条。
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("问"));
|
||
mgr.push(ChatMessage::assistant("答"));
|
||
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||
mgr.set_cache_friendly(true);
|
||
let (msgs, _t) = mgr.build_for_request(0);
|
||
assert!(!msgs.is_empty());
|
||
assert!(
|
||
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||
"开时视图不得残留 system: {:?}",
|
||
msgs.iter().map(role_tag).collect::<Vec<_>>()
|
||
);
|
||
assert!(
|
||
mgr.all_messages_clone()
|
||
.iter()
|
||
.any(|m| matches!(m.role, MessageRole::System)),
|
||
"存储态 system 锚点不应被改写(纯视图)"
|
||
);
|
||
let mut final_view = vec![ChatMessage::system("SYS_PROMPT")];
|
||
final_view.extend(msgs);
|
||
assert!(
|
||
matches!(final_view[0].role, MessageRole::System),
|
||
"组装层首条 system_prompt 不转"
|
||
);
|
||
assert_eq!(
|
||
final_view
|
||
.iter()
|
||
.filter(|m| matches!(m.role, MessageRole::System))
|
||
.count(),
|
||
1,
|
||
"最终视图应只有组装层首条 system"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn system_anchor_head_insert_still_converted() {
|
||
// W2:IPC 压缩路径 insert_at(0) 的存储首位 system 锚点,最终视图在下标 1(>0),同样转 user。
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("问"));
|
||
mgr.insert_at(0, ChatMessage::system("[摘要]"));
|
||
mgr.set_cache_friendly(true);
|
||
let (msgs, _t) = mgr.build_for_request(0);
|
||
assert!(
|
||
msgs.iter().all(|m| !matches!(m.role, MessageRole::System)),
|
||
"存储首位的 system 锚点最终视图下标 > 0,应转 user"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn system_anchor_stays_system_when_cache_friendly_off() {
|
||
// W2 开关关:不转换,视图与存储角色一致(旧行为逐字节等价)。
|
||
let mut mgr = ContextManager::new(cfg(100_000));
|
||
mgr.push(ChatMessage::user("问"));
|
||
mgr.push(ChatMessage::assistant("答"));
|
||
mgr.push_system_anchor(ChatMessage::system("[压缩摘要锚点]"));
|
||
let (msgs, _t) = mgr.build_for_request(0);
|
||
assert!(
|
||
msgs.iter().any(|m| matches!(m.role, MessageRole::System)),
|
||
"关时 system 锚点应原样进视图"
|
||
);
|
||
}
|