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

195 lines
6.5 KiB
Rust
Raw 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.

//! 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<ToolResult> {
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<ToolResult> {
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<Mutex<Vec<Vec<StreamEvent>>>>,
}
impl StaticProvider {
fn tool_turn(model: &str) -> Vec<StreamEvent> {
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<StreamEvent> {
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<StreamEvent>,
}
impl StreamIterator for Replay {
fn next_event(&mut self) -> Option<StreamEvent> {
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"]);
}