//! 上下文压缩(方案 D+E)的单元测试。 //! Unit tests for context compaction (schemes D+E). use focus_core::model::*; use focus_harness::compaction::{ apply_summary, estimate_messages, estimate_tokens, plan_compaction, DEFAULT_KEEP_RATIO, DEFAULT_THRESHOLD_RATIO, }; use focus_json::JsonValue; fn long_user_message(text: &str) -> Message { Message::user_text(text) } #[test] fn estimate_is_reasonable() { // 80 个 ASCII 字符 ≈ 20 token。 // 80 ASCII chars ≈ 20 tokens. let text = "a".repeat(80); assert_eq!(estimate_tokens(&text), 20); // 中文按字符计。 // CJK counted per char. assert_eq!(estimate_tokens("中文"), 2); // 消息开销计入。 // Per-message overhead is included. assert!(estimate_messages(&[long_user_message("hi")]) > estimate_tokens("hi")); } #[test] fn below_threshold_returns_none() { let messages: Vec = (0..5) .map(|i| long_user_message(&format!("msg {}", i))) .collect(); // 窗口极大 → 不触发。 // Huge window → no trigger. assert!(plan_compaction( &messages, 1_000_000, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO ) .is_none()); } #[test] fn above_threshold_plans_summarize_and_keep() { // 每条消息约 50+ token("x"*200 → 50 token + 4 开销);10 条约 540 token。 // Each message ≈ 50+ tokens; 10 of them ≈ 540 tokens. let messages: Vec = (0..10) .map(|_i| long_user_message(&"x".repeat(200))) .collect(); let plan = plan_compaction(&messages, 200, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan"); assert!(!plan.summarize.is_empty()); assert!(!plan.keep.is_empty()); assert!(plan.summarize.len() + plan.keep.len() == messages.len()); // 摘要指令包含两个部分。 // The summary instruction has both sections. assert!(plan.summary_instruction.contains("")); assert!(plan.summary_instruction.contains("")); // 保存的 token 为正。 // Saved tokens are positive. assert!(plan.estimated_saved_tokens > 0); } #[test] fn keeps_tool_result_pairs_intact() { // 构造:user → assistant(tool_call) → toolResult → assistant(text), // 其中 tool 部分恰好落在边界附近。 // Build: user → assistant(tool_call) → toolResult → assistant(text), // with the tool part landing near the boundary. let mut args = JsonValue::obj(); args.insert("x", "1".into()).ok(); let mut messages = vec![long_user_message(&"a".repeat(300))]; messages.push(Message::Assistant(AssistantMessage { content: vec![ContentBlock::ToolCall(ToolCall { id: "c1".into(), name: "read".into(), arguments: args, })], model: "m".into(), usage: Usage::default(), stop_reason: StopReason::ToolUse, error_message: None, timestamp: 0, })); messages.push(Message::ToolResult(ToolResultMessage { tool_call_id: "c1".into(), tool_name: "read".into(), content: vec![ContentBlock::text("r".repeat(300))], details: JsonValue::obj(), is_error: false, timestamp: 0, })); messages.push(Message::Assistant(AssistantMessage { content: vec![ContentBlock::text("final")], model: "m".into(), usage: Usage::default(), stop_reason: StopReason::Stop, error_message: None, timestamp: 0, })); let plan = plan_compaction(&messages, 100, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan"); // 若 tool_result 在 keep 中,其 assistant 调用消息必须也在 keep 中。 // If the tool_result is kept, its assistant call message must be kept too. let keep: Vec<&Message> = plan.keep.iter().collect(); if keep.iter().any(|m| matches!(m, Message::ToolResult(_))) { assert!(keep.iter().any(|m| { matches!(m, Message::Assistant(a) if a.content.iter().any(|c| c.as_tool_call().is_some())) })); } } #[test] fn apply_summary_prepends_summary_message() { let messages: Vec = (0..6) .map(|_i| long_user_message(&"x".repeat(200))) .collect(); let plan = plan_compaction(&messages, 100, 0.8, 0.6).expect("plan"); let out = apply_summary("SUMMARY TEXT", &plan); assert_eq!(out.len(), plan.keep.len() + 1); match &out[0] { Message::User(u) => { let text = u.content[0].as_text().unwrap().text.clone(); assert!(text.contains("SUMMARY TEXT")); } other => panic!("expected user summary, got {:?}", other), } // 保留部分原样在后。 // The kept part follows verbatim. assert_eq!(out[1..], plan.keep[..]); }