优化: 消息落库增量写(Phase1:全量 DELETE+INSERT → 增量 INSERT + dirty 标记)

This commit is contained in:
lxy
2026-08-01 12:42:49 +08:00
parent 484080ac12
commit c34ad062e7
4 changed files with 390 additions and 11 deletions
+220
View File
@@ -717,3 +717,223 @@ fn topic_marker_take_is_idempotent_and_clears() {
// 第二次 take 必 None
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}
// ============================================================
// 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());
}