feat: 添加 HistoryUnit 解析单元测试,验证系统守卫和工具调用的处理逻辑

This commit is contained in:
oudecheng 2026-07-03 10:19:33 +08:00
parent 51e06c8f73
commit f3365f8d3a

View File

@ -1305,4 +1305,184 @@ mod tests {
assert!(chunks.iter().all(|chunk| char_count(chunk) <= 10)); assert!(chunks.iter().all(|chunk| char_count(chunk) <= 10));
assert_eq!(chunks.concat(), "user: xxxxxxxxxxxxxxxxxxxxxxxxx"); 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<ChatMessage> = (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");
}
}
} }