重构: context.rs拆分(sanitize独立子模块)+架构文档节点状态同步
- context.rs(1956行)拆分为 context/mod.rs(538行)+sanitize.rs(761行)+manager_tests.rs(719行) - sanitize 函数移至子模块,ContextManager 保留委托方法(零行为变更) - 46个 context 测试全绿(11 sanitize + 35 manager) - ARCHITECTURE.md: 8节点全部标记已实现,Coordinator 标记已实现
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
//! `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() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 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("truncated".to_string());
|
||||
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("active".to_string());
|
||||
let mut b = ChatMessage::user("archived 一");
|
||||
b.status = Some("archived_segment".to_string());
|
||||
let mut c = ChatMessage::user("compressed 一");
|
||||
c.status = Some("compressed".to_string());
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
|
||||
#[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_deref(), Some("compressed"));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("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("truncated".to_string());
|
||||
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_deref(),
|
||||
Some("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("truncated".to_string());
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源: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_deref(), Some("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 必空");
|
||||
}
|
||||
Reference in New Issue
Block a user