feat: 添加 HistoryUnit 解析单元测试,验证系统守卫和工具调用的处理逻辑
This commit is contained in:
parent
51e06c8f73
commit
f3365f8d3a
@ -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<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user