use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use af_agent::{
CancellationToken, ChatModel, RequestContext, Tool, ToolExecutionContext, ToolRegistry,
};
use af_llm::{ChatMessage, Choice, CompletionRequest, CompletionResponse, LlmError};
struct PingTool;
#[async_trait]
impl Tool for PingTool {
fn name(&self) -> &str {
"ping"
}
fn description(&self) -> &str {
"returns pong"
}
fn parameters(&self) -> Value {
json!({"type": "object"})
}
fn output_schema(&self) -> Value {
json!({"type":"object","required":["reply"],"properties":{"reply":{"const":"pong"}}})
}
async fn call(&self, _args: Value) -> Result<Value, String> {
Ok(json!({"reply": "pong"}))
}
}
#[tokio::test]
async fn tool_registry_surfaces() {
let mut reg = ToolRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
reg.register(Arc::new(PingTool)).unwrap();
assert!(!reg.is_empty());
assert_eq!(reg.len(), 1);
assert!(reg.contains("ping"));
assert!(!reg.contains("nope"));
assert_eq!(reg.names(), ["ping"]);
let allowed = reg.filtered(["ping"]);
assert!(allowed.contains("ping"));
let denied = reg.filtered(["ghost"]);
assert!(denied.is_empty());
assert!(format!("{reg:?}").contains("ping"));
let specs = reg.specs();
assert_eq!(specs.len(), 1);
let execution = ToolExecutionContext {
request: RequestContext {
tenant_id: "tenant".parse().unwrap(),
subject_id: "subject".parse().unwrap(),
roles: Default::default(),
locale: "en".into(),
request_id: "request".parse().unwrap(),
entitlements: Default::default(),
},
session_id: "session".parse().unwrap(),
run_id: "run".parse().unwrap(),
step: 1,
call_id: "call".parse().unwrap(),
source_event_seq: 1,
interaction_resolution: None,
cancellation: CancellationToken::default(),
deadline: std::time::Instant::now() + std::time::Duration::from_secs(1),
};
let ok = reg
.execute_with_context("ping", &execution, json!({}))
.await
.unwrap();
assert_eq!(ok["reply"], "pong");
let err = reg
.execute_with_context("ghost", &execution, json!({}))
.await
.unwrap_err();
assert!(err.contains("unknown tool"));
assert!(err.contains("Available:"));
assert!(reg
.execute_with_context("Notebookping", &execution, json!({}))
.await
.is_err());
}
struct FixedModel;
#[async_trait]
impl ChatModel for FixedModel {
async fn complete_streaming(
&self,
_req: &CompletionRequest,
delta_tx: tokio::sync::mpsc::UnboundedSender<(String, bool)>,
) -> Result<CompletionResponse, LlmError> {
let _ = delta_tx.send(("ok".into(), false));
Ok(CompletionResponse {
id: "f".into(),
choices: vec![Choice {
index: 0,
message: ChatMessage::assistant("ok"),
finish_reason: None,
output_blocks: Vec::new(),
}],
usage: None,
})
}
}
#[tokio::test]
async fn type_erased_chat_model_delegates() {
let model: Arc<dyn ChatModel> = Arc::new(FixedModel);
let req = CompletionRequest::new("m", vec![ChatMessage::user("hi")]);
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::unbounded_channel();
let resp = model.complete_streaming(&req, delta_tx).await.unwrap();
assert_eq!(resp.first_content(), Some("ok"));
assert_eq!(delta_rx.recv().await.unwrap(), ("ok".into(), false));
}