focus/crates/focus-core/tests/agent_loop.rs

370 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Integration tests for the agent loop, driven by the mock provider.
use focus_core::event::VecSink;
use focus_core::model::*;
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
use focus_core::{Agent, AgentConfig};
use focus_json::JsonValue;
use mock::{make_text_response, make_tool_call_response, MockProvider};
/// A simple echo tool that returns its arguments as text.
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes the arguments back"
}
fn parameters(&self) -> JsonValue {
let mut o = JsonValue::obj();
let mut props = JsonValue::obj();
props.insert("text", JsonValue::Str("the text".into())).ok();
o.insert("type", "object".into()).ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
args: &JsonValue,
_on_update: Option<&ToolUpdateSink<'_>>,
) -> Result<ToolResult, focus_core::CoreError> {
let text = args.get_str("text").unwrap_or("(none)").to_string();
Ok(ToolResult::text(format!("echo: {}", text)))
}
}
fn agent_with(provider: MockProvider) -> Agent {
let config = AgentConfig {
model: "mock-model".into(),
..Default::default()
};
let tools = ToolRegistry::with(Box::new(EchoTool));
Agent::new(config, Box::new(provider), tools)
}
#[test]
fn single_text_response_completes() {
let provider = MockProvider::single_text("hello there", StopReason::Stop);
let mut agent = agent_with(provider);
let mut sink = VecSink::new();
agent.prompt("hi", &mut sink).expect("run failed");
assert_eq!(sink.count("agent_start"), 1);
assert_eq!(sink.count("agent_end"), 1);
assert_eq!(sink.count("message_end"), 2); // user + assistant
// Transcript: user + assistant
let msgs = agent.messages();
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0], Message::User(_)));
let assistant = match &msgs[1] {
Message::Assistant(a) => a,
_ => panic!("expected assistant message"),
};
let text = assistant
.content
.iter()
.find_map(|c| c.as_text())
.expect("assistant has text");
assert_eq!(text.text, "hello there");
assert_eq!(assistant.stop_reason, StopReason::Stop);
}
#[test]
fn tool_call_then_text_completes_two_turns() {
// Turn 1: model calls "echo" tool. Turn 2: model gives final text.
let mut args = JsonValue::obj();
args.insert("text", "world".into()).ok();
let provider = MockProvider::new(vec![
make_tool_call_response(None, "echo", "call_1", args, StopReason::ToolUse),
make_text_response("all done", StopReason::Stop),
]);
let mut agent = agent_with(provider);
let mut sink = VecSink::new();
agent.prompt("run echo", &mut sink).expect("run failed");
// Two turns.
assert_eq!(sink.count("turn_end"), 2);
assert_eq!(sink.count("tool_execution_end"), 1);
assert_eq!(sink.count("agent_end"), 1);
// Transcript: user, assistant(toolcall), toolResult, assistant(text)
let msgs = agent.messages();
assert_eq!(msgs.len(), 4);
assert!(matches!(msgs[0], Message::User(_)));
assert!(matches!(
&msgs[1],
Message::Assistant(a) if a.content.iter().any(|c| c.as_tool_call().is_some())
));
assert!(matches!(
&msgs[2],
Message::ToolResult(tr) if tr.tool_name == "echo" && !tr.is_error
));
let text = match &msgs[3] {
Message::Assistant(a) => a
.content
.iter()
.find_map(|c| c.as_text())
.map(|t| t.text.clone()),
_ => None,
};
assert_eq!(text.as_deref(), Some("all done"));
// The tool result content should contain the echo.
if let Message::ToolResult(tr) = &msgs[2] {
let result_text = tr
.content
.iter()
.find_map(|c| c.as_text())
.map(|t| t.text.clone());
assert_eq!(result_text.as_deref(), Some("echo: world"));
}
}
#[test]
fn unknown_tool_produces_error_result() {
let provider = MockProvider::new(vec![
make_tool_call_response(
None,
"nonexistent",
"call_1",
JsonValue::obj(),
StopReason::ToolUse,
),
make_text_response("ok", StopReason::Stop),
]);
let mut agent = agent_with(provider);
let mut sink = VecSink::new();
agent.prompt("go", &mut sink).expect("run failed");
// The tool result should be an error.
let msgs = agent.messages();
assert!(matches!(msgs[2], Message::ToolResult(_)));
if let Message::ToolResult(tr) = &msgs[2] {
assert!(tr.is_error, "expected error for unknown tool");
}
}
#[test]
fn error_stop_reason_ends_run_immediately() {
let provider = MockProvider::new(vec![make_text_response("oops", StopReason::Error)]);
let mut agent = agent_with(provider);
let mut sink = VecSink::new();
agent.prompt("hi", &mut sink).expect("run failed");
assert_eq!(sink.count("turn_end"), 1);
assert_eq!(sink.count("agent_end"), 1);
let assistant = match agent.messages().last().unwrap() {
Message::Assistant(a) => a,
_ => panic!(),
};
assert_eq!(assistant.stop_reason, StopReason::Error);
}
#[test]
fn continue_requires_non_assistant_last_message() {
let provider = MockProvider::single_text("hi", StopReason::Stop);
let mut agent = agent_with(provider);
let mut sink = VecSink::new();
agent.prompt("hello", &mut sink).expect("run failed");
// Last message is now assistant — continue must fail.
let result = agent.r#continue(&mut sink);
assert!(result.is_err());
}
/// 回放脚本化事件的 mock provider。目前只有本测试文件使用因此内联在此
/// 若将来多个集成测试文件都需要,再抽成独立的测试工具 crate。
/// A mock provider that replays scripted events. Only this test file uses it
/// today, so it lives inline here; extract to a shared test-utils crate if
/// multiple integration test files need it later.
mod mock {
use focus_core::model::*;
use focus_core::provider::{
ProviderRequest, StreamEvent, StreamIterator, StreamProvider, StreamResult,
};
use focus_core::CoreError;
use std::sync::{Arc, Mutex};
/// 构造一个回放「响应列表」的 mock provider——每个响应是一个回合的事件列表。
/// Build a mock provider that replays a list of *responses*, where each
/// response is a list of events for one assistant turn.
#[derive(Debug, Clone)]
pub struct MockProvider {
/// 响应队列;每次回合消费一个。
/// Queue of responses; each response is consumed by one turn.
responses: Arc<Mutex<Vec<Vec<StreamEvent>>>>,
}
impl MockProvider {
/// 用一组响应构造 mock provider。
/// Build a mock provider from a list of responses.
pub fn new(responses: Vec<Vec<StreamEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
}
}
/// 一个总是返回单条文本响应(带停止原因)的 provider。
/// A provider that always returns a single text response with stop reason.
pub fn single_text(text: &str, stop: StopReason) -> Self {
Self::new(vec![make_text_response(text, stop)])
}
}
impl StreamProvider for MockProvider {
fn stream(&self, _request: &ProviderRequest) -> StreamResult {
let mut queue = self.responses.lock().expect("mock poisoned");
let events = queue
.first()
.cloned()
.ok_or_else(|| CoreError::Provider("mock provider exhausted".into()))?;
if !matches!(
events.last(),
Some(StreamEvent::Done { .. }) | Some(StreamEvent::Error { .. })
) {
return Err(CoreError::Provider(
"mock response must end with Done or Error".into(),
));
}
queue.remove(0);
Ok(Box::new(MockStream { events }))
}
}
/// 一个回放固定事件序列的流。
/// A stream that replays a fixed event sequence.
struct MockStream {
events: Vec<StreamEvent>,
}
impl StreamIterator for MockStream {
fn next_event(&mut self) -> Option<StreamEvent> {
if self.events.is_empty() {
None
} else {
Some(self.events.remove(0))
}
}
}
/// 构造一个简单的文本响应事件序列。
/// Build a simple text-response event sequence.
pub fn make_text_response(text: &str, stop: StopReason) -> Vec<StreamEvent> {
let model = "mock-model".to_string();
let timestamp = now_ms();
let partial_empty = AssistantMessage {
content: vec![],
model: model.clone(),
usage: Usage::default(),
stop_reason: stop,
error_message: None,
timestamp,
};
let partial = partial_empty.clone();
let mut text_start = partial.clone();
text_start.content.push(ContentBlock::Text(TextContent {
text: String::new(),
signature: None,
}));
let mut text_delta = text_start.clone();
if let Some(ContentBlock::Text(t)) = text_delta.content.get_mut(0) {
t.text = text.to_string();
}
let text_end = text_delta.clone();
let mut final_msg = text_end.clone();
final_msg.usage = Usage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
};
final_msg.stop_reason = stop;
vec![
StreamEvent::Start {
partial: partial_empty,
},
StreamEvent::TextStart {
content_index: 0,
partial: text_start,
},
StreamEvent::TextDelta {
content_index: 0,
delta: text.into(),
partial: text_delta,
},
StreamEvent::TextEnd {
content_index: 0,
partial: text_end,
},
StreamEvent::Done { message: final_msg },
]
}
/// 构造一个工具调用响应:模型请求调用一个工具。
/// Build a tool-call response: the model asks to call a tool.
pub fn make_tool_call_response(
thinking_text: Option<&str>,
tool_name: &str,
tool_id: &str,
arguments: focus_json::JsonValue,
stop: StopReason,
) -> Vec<StreamEvent> {
let model = "mock-model".to_string();
let timestamp = now_ms();
let mut content: Vec<ContentBlock> = Vec::new();
if let Some(t) = thinking_text {
content.push(ContentBlock::text(t));
}
content.push(ContentBlock::ToolCall(ToolCall {
id: tool_id.to_string(),
name: tool_name.to_string(),
arguments,
}));
let mut msg = AssistantMessage {
content: content.clone(),
model: model.clone(),
usage: Usage::default(),
stop_reason: stop,
error_message: None,
timestamp,
};
msg.usage = Usage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
};
vec![
StreamEvent::Start {
partial: AssistantMessage {
content: vec![],
model: model.clone(),
usage: Usage::default(),
stop_reason: stop,
error_message: None,
timestamp,
},
},
StreamEvent::ToolCallStart {
content_index: 0,
partial: msg.clone(),
},
StreamEvent::ToolCallEnd {
content_index: 0,
tool_call: if let Some(ContentBlock::ToolCall(tc)) = content.last() {
tc.clone()
} else {
unreachable!()
},
partial: msg.clone(),
},
StreamEvent::Done { message: msg },
]
}
}