use async_trait::async_trait;
use futures_util::stream;
use lellm_agent::{AgentBuilder, ToolRegistration};
use lellm_core::{
ChatRequest, ChatResponse, ContentBlock, LlmError, Message, TokenUsage, ToolCall,
ToolDefinition,
};
use lellm_provider::{LlmProvider, ProviderEvent, ProviderStream, ResolvedModel};
use std::sync::{Arc, Mutex};
fn is_tool_result(msg: &Message) -> bool {
matches!(msg, Message::ToolResult { .. })
}
struct MultiResponseMock {
responses: Vec<ChatResponse>,
round: Mutex<usize>,
received_requests: Mutex<Vec<ChatRequest>>,
}
impl MultiResponseMock {
fn new(responses: Vec<ChatResponse>) -> Self {
Self {
responses,
round: Mutex::new(0),
received_requests: Mutex::new(Vec::new()),
}
}
fn next_response(&self) -> ChatResponse {
let mut r = self.round.lock().unwrap();
let idx = *r;
*r += 1;
self.responses.get(idx).cloned().unwrap_or_else(|| {
ChatResponse::new(
vec![ContentBlock::text("Done.")],
TokenUsage::default(),
serde_json::json!(null),
)
})
}
fn received_requests(&self) -> Vec<ChatRequest> {
self.received_requests.lock().unwrap().clone()
}
}
#[async_trait]
impl LlmProvider for MultiResponseMock {
async fn call(&self, req: &ChatRequest) -> Result<ChatResponse, LlmError> {
self.received_requests.lock().unwrap().push(req.clone());
Ok(self.next_response())
}
async fn stream(&self, req: &ChatRequest) -> Result<ProviderStream, LlmError> {
self.received_requests.lock().unwrap().push(req.clone());
let resp = self.next_response();
let tool_calls: Vec<ToolCall> = resp.tool_calls().cloned().collect();
let text: String = resp
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect();
let events = vec![
Ok(ProviderEvent::Start {
model: "mock".into(),
}),
Ok(ProviderEvent::Token { token: text }),
Ok(ProviderEvent::ResponseComplete {
tool_calls,
usage: Some(resp.usage),
}),
];
Ok(Box::pin(stream::iter(events)))
}
fn provider_id(&self) -> &str {
"mock"
}
}
async fn collect_stream_result(
mut stream: tokio::sync::mpsc::Receiver<lellm_agent::AgentEvent>,
) -> Option<lellm_agent::ToolUseResult> {
while let Some(event) = stream.recv().await {
if let lellm_agent::AgentEvent::LoopEnd { result } = event {
return Some(result);
}
}
None
}
fn make_models(responses: Vec<ChatResponse>, model_name: &str) -> (ResolvedModel, ResolvedModel) {
let p1 = Arc::new(MultiResponseMock::new(responses.clone()));
let p2 = Arc::new(MultiResponseMock::new(responses));
let m1 = ResolvedModel {
context_window: None,
provider: p1,
model: model_name.to_string(),
};
let m2 = ResolvedModel {
context_window: None,
provider: p2,
model: model_name.to_string(),
};
(m1, m2)
}
fn build_agent(model: ResolvedModel, tools: Vec<ToolRegistration>) -> lellm_agent::ToolUseLoop {
let mut builder = AgentBuilder::new(model).max_iterations(10);
for tool in tools {
builder = builder.tool(tool);
}
builder.build()
}
#[tokio::test]
async fn compare_simple_text_response() {
let (model_new, model_old) = make_models(
vec![ChatResponse::new(
vec![ContentBlock::text("Hello, world!")],
TokenUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
serde_json::json!(null),
)],
"test-model",
);
let messages = vec![Message::user_text("say hello")];
let result_new = build_agent(model_new, vec![])
.execute(messages.clone())
.await
.unwrap();
let stream_old = build_agent(model_old, vec![]).execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(result_new.iterations, 1, "should be 1 iteration");
assert_eq!(
result_new.response.has_tool_calls(),
result_old.response.has_tool_calls(),
"tool_calls flag mismatch"
);
assert!(
!result_new.response.has_tool_calls(),
"should have no tool calls"
);
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
}
#[tokio::test]
async fn compare_single_tool_call() {
let echo_def = ToolDefinition {
name: "echo".to_string(),
description: "echo back".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": { "msg": { "type": "string" } }
}),
cache_control: None,
};
let echo_reg = ToolRegistration::safe(echo_def, |args| {
let m = args
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
async move { Ok(serde_json::json!(format!("echo: {}", m))) }
});
let responses = vec![
ChatResponse::new(
vec![ContentBlock::ToolCall(ToolCall {
id: "call_1".into(),
name: "echo".into(),
arguments: serde_json::json!({"msg": "hello"}),
})],
TokenUsage::default(),
serde_json::json!(null),
),
ChatResponse::new(
vec![ContentBlock::text("The echo says: echo: hello")],
TokenUsage {
prompt_tokens: 20,
completion_tokens: 8,
total_tokens: 28,
},
serde_json::json!(null),
),
];
let (model_new, model_old) = make_models(responses, "test-model");
let messages = vec![Message::user_text("echo hello")];
let result_new = build_agent(model_new, vec![echo_reg.clone()])
.execute(messages.clone())
.await
.unwrap();
let stream_old = build_agent(model_old, vec![echo_reg]).execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(
result_new.iterations, 2,
"should be 2 iterations (LLM → Tool → LLM)"
);
assert_eq!(
result_new.tool_calls_executed, result_old.tool_calls_executed,
"tool_calls_executed mismatch"
);
assert_eq!(
result_new.tool_calls_executed, 1,
"should execute 1 tool call"
);
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
assert!(
!result_new.response.has_tool_calls(),
"new: final response should not have tool calls"
);
assert!(
!result_old.response.has_tool_calls(),
"old: final response should not have tool calls"
);
assert!(
result_new
.messages
.iter()
.any(|m| matches!(m, Message::User { .. })),
"new: should have user message"
);
assert!(
result_old
.messages
.iter()
.any(|m| matches!(m, Message::User { .. })),
"old: should have user message"
);
assert!(
result_new.messages.iter().any(|m| is_tool_result(m)),
"new: should have tool result message"
);
assert!(
result_old.messages.iter().any(|m| is_tool_result(m)),
"old: should have tool result message"
);
}
#[tokio::test]
async fn compare_multi_round_react() {
let add_def = ToolDefinition {
name: "add".to_string(),
description: "add two numbers".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "number" },
"b": { "type": "number" }
}
}),
cache_control: None,
};
let add_reg = ToolRegistration::safe(add_def, |args| {
let a = args.get("a").and_then(|v| v.as_f64()).unwrap_or(0.0);
let b = args.get("b").and_then(|v| v.as_f64()).unwrap_or(0.0);
async move { Ok(serde_json::json!(a + b)) }
});
let responses = vec![
ChatResponse::new(
vec![ContentBlock::ToolCall(ToolCall {
id: "c1".into(),
name: "add".into(),
arguments: serde_json::json!({"a": 3.0, "b": 4.0}),
})],
TokenUsage::default(),
serde_json::json!(null),
),
ChatResponse::new(
vec![ContentBlock::ToolCall(ToolCall {
id: "c2".into(),
name: "add".into(),
arguments: serde_json::json!({"a": 7.0, "b": 2.0}),
})],
TokenUsage::default(),
serde_json::json!(null),
),
ChatResponse::new(
vec![ContentBlock::text("3 + 4 = 7, 7 + 2 = 9. The answer is 9.")],
TokenUsage {
prompt_tokens: 50,
completion_tokens: 12,
total_tokens: 62,
},
serde_json::json!(null),
),
];
let (model_new, model_old) = make_models(responses, "test-model");
let messages = vec![Message::user_text("add 3+4 then add 2")];
let result_new = build_agent(model_new, vec![add_reg.clone()])
.execute(messages.clone())
.await
.unwrap();
let stream_old = build_agent(model_old, vec![add_reg]).execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(result_new.iterations, 3, "should be 3 iterations");
assert_eq!(
result_new.tool_calls_executed, result_old.tool_calls_executed,
"tool_calls_executed mismatch"
);
assert_eq!(
result_new.tool_calls_executed, 2,
"should execute 2 tool calls"
);
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
assert!(
result_new
.messages
.iter()
.any(|m| matches!(m, Message::User { .. })),
"new: should have user message"
);
assert!(
result_old
.messages
.iter()
.any(|m| matches!(m, Message::User { .. })),
"old: should have user message"
);
let new_tool_results: usize = result_new
.messages
.iter()
.filter(|m| is_tool_result(m))
.count();
let old_tool_results: usize = result_old
.messages
.iter()
.filter(|m| is_tool_result(m))
.count();
assert_eq!(
new_tool_results, old_tool_results,
"tool result count mismatch"
);
assert_eq!(new_tool_results, 2, "should have 2 tool results");
}
#[tokio::test]
async fn compare_with_system_prompt() {
let (model_new, model_old) = make_models(
vec![ChatResponse::new(
vec![ContentBlock::text("OK")],
TokenUsage::default(),
serde_json::json!(null),
)],
"test-model",
);
let messages = vec![Message::user_text("hello")];
let agent_new = AgentBuilder::new(model_new)
.system_prompt("你是测试助手".to_string())
.max_iterations(5)
.build();
let result_new = agent_new.execute(messages.clone()).await.unwrap();
let agent_old = AgentBuilder::new(model_old)
.system_prompt("你是测试助手".to_string())
.max_iterations(5)
.build();
let stream_old = agent_old.execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
}
#[tokio::test]
async fn compare_tool_registered_but_not_called() {
let echo_def = ToolDefinition {
name: "echo".to_string(),
description: "echo".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": { "msg": { "type": "string" } }
}),
cache_control: None,
};
let echo_reg = ToolRegistration::safe(echo_def, |args| {
let m = args
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
async move { Ok(serde_json::json!(format!("echo: {}", m))) }
});
let responses = vec![ChatResponse::new(
vec![ContentBlock::text("no tool needed")],
TokenUsage::default(),
serde_json::json!(null),
)];
let (model_new, model_old) = make_models(responses, "test-model");
let messages = vec![Message::user_text("hello")];
let result_new = build_agent(model_new, vec![echo_reg.clone()])
.execute(messages.clone())
.await
.unwrap();
let stream_old = build_agent(model_old, vec![echo_reg]).execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(
result_new.tool_calls_executed, result_old.tool_calls_executed,
"tool_calls_executed mismatch"
);
assert_eq!(
result_new.tool_calls_executed, 0,
"should execute 0 tool calls"
);
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
}
#[tokio::test]
async fn compare_provider_requests_equivalent() {
let response = ChatResponse::new(
vec![ContentBlock::text("done")],
TokenUsage::default(),
serde_json::json!(null),
);
let provider_new = Arc::new(MultiResponseMock::new(vec![response.clone()]));
let provider_old = Arc::new(MultiResponseMock::new(vec![response]));
let model_new = ResolvedModel {
context_window: None,
provider: Arc::clone(&provider_new) as Arc<dyn LlmProvider>,
model: "test-model".to_string(),
};
let model_old = ResolvedModel {
context_window: None,
provider: Arc::clone(&provider_old) as Arc<dyn LlmProvider>,
model: "test-model".to_string(),
};
let messages = vec![Message::user_text("test request")];
let agent_new = build_agent(model_new, vec![]);
let _result_new = agent_new.execute(messages.clone()).await.unwrap();
let agent_old = build_agent(model_old, vec![]);
let stream_old = agent_old.execute_stream(messages.clone());
let _ = collect_stream_result(stream_old).await;
let reqs_new = provider_new.received_requests();
let reqs_old = provider_old.received_requests();
assert_eq!(reqs_new.len(), 1, "new path should call provider once");
assert_eq!(reqs_old.len(), 1, "old path should call provider once");
assert_eq!(
reqs_new[0].messages.len(),
reqs_old[0].messages.len(),
"request message count should be equivalent"
);
}
#[tokio::test]
async fn compare_max_iterations_reached() {
let responses: Vec<ChatResponse> = (0..20)
.map(|i| {
ChatResponse::new(
vec![ContentBlock::ToolCall(ToolCall {
id: format!("c{}", i),
name: "loop".into(),
arguments: serde_json::json!({}),
})],
TokenUsage::default(),
serde_json::json!(null),
)
})
.collect();
let loop_def = ToolDefinition {
name: "loop".to_string(),
description: "loop tool".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
cache_control: None,
};
let loop_reg =
ToolRegistration::safe(loop_def, |_| async move { Ok(serde_json::json!("looped")) });
let (model_new, model_old) = make_models(responses.clone(), "test-model");
let messages = vec![Message::user_text("loop forever")];
let agent_new = AgentBuilder::new(model_new)
.tool(loop_reg.clone())
.max_iterations(3)
.build();
let result_new = agent_new.execute(messages.clone()).await.unwrap();
let agent_old = AgentBuilder::new(model_old)
.tool(loop_reg)
.max_iterations(3)
.build();
let stream_old = agent_old.execute_stream(messages.clone());
let result_old = collect_stream_result(stream_old)
.await
.expect("stream ended without LoopEnd");
assert_eq!(
result_new.stop_reason, result_old.stop_reason,
"stop_reason mismatch"
);
assert_eq!(
result_new.stop_reason,
lellm_agent::StopReason::MaxIterationsReached,
"both should return MaxIterationsReached"
);
assert_eq!(
result_new.iterations, result_old.iterations,
"iterations mismatch"
);
assert_eq!(result_new.iterations, 3, "should be exactly 3 iterations");
assert_eq!(
result_new.tool_calls_executed, result_old.tool_calls_executed,
"tool_calls_executed mismatch"
);
assert!(
result_new.tool_calls_executed >= 1 && result_new.tool_calls_executed < 10,
"new: should execute limited tool calls, got {}",
result_new.tool_calls_executed
);
}