//! 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 { 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>>>, } impl MockProvider { /// 用一组响应构造 mock provider。 /// Build a mock provider from a list of responses. pub fn new(responses: Vec>) -> 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, } impl StreamIterator for MockStream { fn next_event(&mut self) -> Option { 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 { 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 { let model = "mock-model".to_string(); let timestamp = now_ms(); let mut content: Vec = 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 }, ] } }