//! tool 模块(`Tool` trait 与注册表)的集成测试。 //! Integration tests for the tool module (`Tool` trait and registry). use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink}; use focus_core::CoreResult; use focus_json::JsonValue; #[derive(Debug)] struct EchoTool; impl Tool for EchoTool { fn name(&self) -> &str { "echo" } fn description(&self) -> &str { "echoes input" } fn parameters(&self) -> JsonValue { JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::READ } fn execute( &self, _id: &str, args: &JsonValue, _on_update: Option<&ToolUpdateSink<'_>>, ) -> CoreResult { let text = focus_json::to_string(args); Ok(ToolResult::text(text)) } } #[test] fn registry_lookup() { let r = ToolRegistry::with(Box::new(EchoTool)); assert!(r.get("echo").is_some()); assert!(r.get("nope").is_none()); assert_eq!(r.len(), 1); } #[test] fn tool_definitions_shape() { let r = ToolRegistry::with(Box::new(EchoTool)); let defs = r.tool_definitions(); let arr = match &defs { JsonValue::Arr(a) => a, _ => panic!("expected array"), }; let first = &arr[0]; assert_eq!(first.get_str("name"), Some("echo")); assert_eq!(first.get_str("description"), Some("echoes input")); } #[test] fn read_effects_compatible() { assert!(ToolEffects::READ.compatible_with(ToolEffects::READ)); // 写与读不兼容。 // write vs read is not compatible assert!(!ToolEffects::WRITE.compatible_with(ToolEffects::READ)); // 触网/开进程会阻止并行。 // network/process block parallelism assert!(!ToolEffects::NETWORK.compatible_with(ToolEffects::NONE)); assert!(!ToolEffects::PROCESS.compatible_with(ToolEffects::NONE)); } /// 工具的增量更新(on_update)必须被 agent 转发为 ToolExecutionUpdate 事件, /// 而不是等工具结束才一次性出现(回归:此前 agent 把 on_update 传成 None)。 /// A tool's incremental updates (on_update) must be forwarded by the agent as /// ToolExecutionUpdate events, not appear only when the tool finishes /// (regression: the agent used to pass None for on_update). #[test] fn tool_updates_stream_to_agent_events() { use focus_core::event::{AgentEvent, VecSink}; use focus_core::model::ContentBlock; use focus_core::provider::{ ProviderRequest, StreamEvent, StreamIterator, StreamProvider, StreamResult, }; use focus_core::{Agent, AgentConfig}; use std::sync::{Arc, Mutex}; /// 在 execute 期间通过 on_update 推送两段增量文本的工具。 /// A tool that pushes two incremental texts via on_update during execute. #[derive(Debug)] struct StreamingTool; impl Tool for StreamingTool { fn name(&self) -> &str { "stream" } fn description(&self) -> &str { "streams updates" } fn parameters(&self) -> JsonValue { JsonValue::obj() } fn effects(&self) -> ToolEffects { ToolEffects::READ } fn execute( &self, _id: &str, _args: &JsonValue, on_update: Option<&ToolUpdateSink<'_>>, ) -> CoreResult { let cb = on_update.expect("agent must pass on_update"); cb(focus_core::tool::ToolUpdate { content: vec![ContentBlock::text("line one")], details: JsonValue::obj(), }); cb(focus_core::tool::ToolUpdate { content: vec![ContentBlock::text("line two")], details: JsonValue::obj(), }); Ok(ToolResult::text("done")) } } /// 回放固定事件序列的 mock provider:第一段模型调用 stream 工具, /// 第二段给出最终文本。 /// A mock provider replaying fixed event sequences: turn 1 calls the /// stream tool, turn 2 gives the final text. #[derive(Debug, Clone, Default)] struct StaticProvider { responses: Arc>>>, } impl StaticProvider { fn tool_turn(model: &str) -> Vec { let mut reducer = focus_core::provider::ProviderEventReducer::new(model); let mut events = reducer.tool_call_start(0, "stream"); events.extend(reducer.finalize_events()); let message = reducer.finish().expect("final message"); events.push(StreamEvent::Done { message }); events } fn text_turn(model: &str) -> Vec { let mut reducer = focus_core::provider::ProviderEventReducer::new(model); let mut events = reducer.text_delta("final text"); events.extend(reducer.finalize_events()); let message = reducer.finish().expect("final message"); events.push(StreamEvent::Done { message }); events } } impl StreamProvider for StaticProvider { fn stream(&self, _request: &ProviderRequest) -> StreamResult { let mut queue = self.responses.lock().expect("not poisoned"); if queue.is_empty() { return Err(focus_core::CoreError::Provider("mock exhausted".into())); } let events = queue.remove(0); Ok(Box::new(Replay { events })) } } struct Replay { events: Vec, } impl StreamIterator for Replay { fn next_event(&mut self) -> Option { if self.events.is_empty() { None } else { Some(self.events.remove(0)) } } } let provider = StaticProvider { responses: Arc::new(Mutex::new(vec![ StaticProvider::tool_turn("m"), StaticProvider::text_turn("m"), ])), }; let config = AgentConfig { model: "m".into(), ..Default::default() }; let tools = ToolRegistry::with(Box::new(StreamingTool)); let mut agent = Agent::new(config, Box::new(provider), tools); let mut sink = VecSink::new(); agent.prompt("go", &mut sink).expect("run failed"); let updates: Vec<&String> = sink .events .iter() .filter_map(|e| match e { AgentEvent::ToolExecutionUpdate { partial_result, .. } => { partial_result.content[0].as_text().map(|t| &t.text) } _ => None, }) .collect(); assert_eq!(updates, vec!["line one", "line two"]); }