195 lines
6.5 KiB
Rust
195 lines
6.5 KiB
Rust
//! 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"]);
|
||
}
|