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

181 lines
5.8 KiB
Rust

//! Integration tests for the agent loop, driven by the mock provider.
use focus_core::event::VecSink;
use focus_core::mock::{make_text_response, make_tool_call_response, MockProvider};
use focus_core::model::*;
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
use focus_core::{Agent, AgentConfig};
use focus_json::JsonValue;
/// 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());
}