From f3365f8d3ad9a4ea64db2155f148340e11277522 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Fri, 3 Jul 2026 10:19:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20HistoryUnit=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=B3=BB=E7=BB=9F=E5=AE=88=E5=8D=AB=E5=92=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E7=9A=84=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/context_compressor.rs | 180 ++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 211598d..a0f65a3 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -1305,4 +1305,184 @@ mod tests { assert!(chunks.iter().all(|chunk| char_count(chunk) <= 10)); assert_eq!(chunks.concat(), "user: xxxxxxxxxxxxxxxxxxxxxxxxx"); } + + // ========================================================================= + // HistoryUnit / parse_to_units tests + // ========================================================================= + + #[test] + fn test_parse_to_units_system_guards_preserved() { + let messages = vec![ + ChatMessage::system_with_context( + "agent prompt", + Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), + ), + ChatMessage::user("hello"), + ChatMessage::assistant("hi"), + ]; + + let units = parse_to_units(&messages); + // SystemGuard + UserMessage + AssistantText = 3 + assert_eq!(units.len(), 3); + assert!(matches!(units[0], HistoryUnit::SystemGuard(_))); + assert!(matches!(units[1], HistoryUnit::UserMessage(_))); + assert!(matches!(units[2], HistoryUnit::AssistantText(_))); + } + + #[test] + fn test_parse_to_units_tool_round_atomic() { + let messages = vec![ + ChatMessage::user("read a file"), + ChatMessage::assistant_with_tool_calls( + "let me read", + vec![crate::domain::messages::ToolCall { + id: "call_1".to_string(), + name: "file_read".to_string(), + arguments: serde_json::json!({"path": "/data/test.txt"}), + }], + ), + ChatMessage::tool("call_1", "file_read", "file contents here"), + ChatMessage::assistant("the file says hello"), + ]; + + let units = parse_to_units(&messages); + assert_eq!(units.len(), 3); // UserMessage + ToolRound + AssistantText + assert!(matches!(units[0], HistoryUnit::UserMessage(_))); + assert!(matches!(units[1], HistoryUnit::ToolRound { .. })); + assert!(matches!(units[2], HistoryUnit::AssistantText(_))); + + // Verify ToolRound contains both assistant and results + if let HistoryUnit::ToolRound { assistant, results } = &units[1] { + assert_eq!(assistant.role, "assistant"); + assert!(assistant.tool_calls.is_some()); + assert_eq!(results.len(), 1); + assert_eq!(results[0].role, "tool"); + assert_eq!(results[0].tool_call_id, Some("call_1".to_string())); + } + } + + #[test] + fn test_parse_to_units_orphaned_tool_result_dropped() { + let messages = vec![ + ChatMessage::user("hello"), + // Orphaned tool result — no preceding assistant with tool_calls + ChatMessage::tool("orphan_1", "bash", "some output"), + ChatMessage::assistant("done"), + ]; + + let units = parse_to_units(&messages); + // Orphaned tool result should be dropped, leaving UserMessage + AssistantText + assert_eq!(units.len(), 2); + assert!(matches!(units[0], HistoryUnit::UserMessage(_))); + assert!(matches!(units[1], HistoryUnit::AssistantText(_))); + } + + #[test] + fn test_parse_to_units_multiple_tool_results_same_round() { + let messages = vec![ + ChatMessage::user("do multiple things"), + ChatMessage::assistant_with_tool_calls( + "doing things", + vec![ + crate::domain::messages::ToolCall { + id: "call_a".to_string(), + name: "bash".to_string(), + arguments: serde_json::json!({"command": "ls"}), + }, + crate::domain::messages::ToolCall { + id: "call_b".to_string(), + name: "file_read".to_string(), + arguments: serde_json::json!({"path": "/data/test.txt"}), + }, + ], + ), + ChatMessage::tool("call_a", "bash", "file1.txt\nfile2.txt"), + ChatMessage::tool("call_b", "file_read", "hello world"), + ]; + + let units = parse_to_units(&messages); + assert_eq!(units.len(), 2); // UserMessage + ToolRound + if let HistoryUnit::ToolRound { results, .. } = &units[1] { + assert_eq!(results.len(), 2); + assert_eq!(results[0].tool_call_id, Some("call_a".to_string())); + assert_eq!(results[1].tool_call_id, Some("call_b".to_string())); + } else { + panic!("Expected ToolRound"); + } + } + + #[test] + fn test_parse_to_units_empty_input() { + let units = parse_to_units(&[]); + assert!(units.is_empty()); + } + + #[test] + fn test_find_safe_split_point() { + let compressor = ContextCompressor::new(100_000); + // Build a list of units with known token sizes + let messages: Vec = (0..10) + .map(|i| ChatMessage::assistant(&format!("message content number {}", i))) + .collect(); + let units = parse_to_units(&messages); + + // All units are AssistantText, split at 50% token ratio + let split = compressor.find_safe_split_point(&units, 0.5); + // Should split somewhere in the middle (not 0, not len()) + assert!(split > 0 && split < units.len(), + "split {} should be between 0 and {}", split, units.len()); + } + + #[test] + fn test_compress_two_segment_no_tool_calls_in_output() { + // This test verifies the critical invariant: + // compress_two_segment output MUST NOT contain any tool_calls or tool messages. + let compressor = ContextCompressor::new(128_000); + + // Build a simple history that's well under the threshold + // so compress_two_segment returns it unchanged (no LLM call needed). + let history = vec![ + ChatMessage::system_with_context( + "You are a helpful assistant.", + Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), + ), + ChatMessage::user("hello"), + ChatMessage::assistant("hi there"), + ]; + + // Dummy config — won't be used because history is under threshold. + let config = LLMProviderConfig { + provider_type: "openai".to_string(), + name: "test".to_string(), + base_url: "http://localhost".to_string(), + api_key: "sk-test".to_string(), + extra_headers: std::collections::HashMap::new(), + llm_timeout_secs: 120, + memory_maintenance_timeout_secs: 300, + model_id: "test-model".to_string(), + temperature: None, + max_tokens: None, + context_window_tokens: Some(128_000), + model_extra: std::collections::HashMap::new(), + max_tool_iterations: 100, + tool_result_max_chars: 100_000, + context_tool_result_trim_chars: 2_000, + max_images_in_context: 10, + max_image_age_rounds: 50, + }; + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let result = runtime.block_on(compressor.compress_two_segment(&history, &config)); + assert!(result.is_ok()); + let compressed = result.unwrap(); + // Under threshold: should return unchanged (3 messages) + assert_eq!(compressed.len(), 3); + // Critical invariant: NO tool_calls or tool_call_id anywhere + for msg in &compressed { + assert!(msg.tool_calls.is_none(), + "compress_two_segment output should never contain tool_calls"); + assert!(msg.tool_call_id.is_none(), + "compress_two_segment output should never contain tool_call_id"); + } + } }