use super::*;
#[llm_tool]
async fn async_delayed_echo(
message: String,
) -> Result<String, ToolError> {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
Ok(format!("echo: {message}"))
}
#[tokio::test]
async fn tool_macro_async_fn_dispatches_with_await() {
let mut d = ToolRegistry::new();
d.register(AsyncDelayedEcho);
let result = d
.dispatch(
"async_delayed_echo",
serde_json::json!({"message": "hello async"}),
&test_ctx(),
)
.await;
assert_eq!(result.unwrap().content(), "echo: hello async");
}
#[llm_tool]
async fn async_file_reader(
path: String,
) -> Result<String, ToolError> {
tokio::fs::read_to_string(&path)
.await
.map_err(|e| ToolError::new(format!("IO error: {e}")))
}
#[tokio::test]
async fn tool_macro_async_fn_reads_file() {
let tmp = tempfile::NamedTempFile::new().expect("create tempfile");
std::fs::write(tmp.path(), "async macro content").expect("write");
let mut d = ToolRegistry::new();
d.register(AsyncFileReader);
let path_str = tmp.path().to_str().expect("path").to_owned();
let result = d
.dispatch(
"async_file_reader",
serde_json::json!({"path": path_str}),
&test_ctx(),
)
.await;
assert_eq!(result.unwrap().content(), "async macro content");
}
#[llm_tool]
fn greet_optional(
name: String,
greeting: Option<String>,
) -> Result<String, ToolError> {
let g = greeting.unwrap_or_else(|| "Hello".to_string());
Ok(format!("{g}, {name}!"))
}
#[test]
fn tool_macro_option_param_not_in_required() {
let def = definition_of(&GreetOptional).expect("schema");
let schema = &def.parameter_schema;
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert!(
required.iter().any(|v| v == "name"),
"'name' should be required, got: {required:?}"
);
assert!(
!required.iter().any(|v| v == "greeting"),
"'greeting' (Option<String>) should NOT be required, got: {required:?}"
);
}
#[tokio::test]
async fn tool_macro_option_param_missing_from_json() {
let mut d = ToolRegistry::new();
d.register(GreetOptional);
let result = d
.dispatch(
"greet_optional",
serde_json::json!({"name": "World"}),
&test_ctx(),
)
.await;
assert_eq!(result.unwrap().content(), "Hello, World!");
}
#[tokio::test]
async fn tool_macro_option_param_provided_in_json() {
let mut d = ToolRegistry::new();
d.register(GreetOptional);
let result = d
.dispatch(
"greet_optional",
serde_json::json!({"name": "World", "greeting": "Hi"}),
&test_ctx(),
)
.await;
assert_eq!(result.unwrap().content(), "Hi, World!");
}
#[llm_tool]
async fn async_optional_tool(
input: String,
suffix: Option<String>,
) -> Result<String, ToolError> {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
let s = suffix.unwrap_or_default();
Ok(format!("{input}{s}"))
}
#[tokio::test]
async fn tool_macro_async_with_optional_param() {
let mut d = ToolRegistry::new();
d.register(AsyncOptionalTool);
let r1 = d
.dispatch(
"async_optional_tool",
serde_json::json!({"input": "base"}),
&test_ctx(),
)
.await;
assert_eq!(r1.unwrap().content(), "base");
let r2 = d
.dispatch(
"async_optional_tool",
serde_json::json!({"input": "base", "suffix": "_ext"}),
&test_ctx(),
)
.await;
assert_eq!(r2.unwrap().content(), "base_ext");
}
#[test]
fn tool_macro_async_optional_schema_correctness() {
let def = definition_of(&AsyncOptionalTool).expect("schema");
let schema = &def.parameter_schema;
let required = schema["required"].as_array().expect("required array");
assert!(required.iter().any(|v| v == "input"), "'input' required");
assert!(
!required.iter().any(|v| v == "suffix"),
"'suffix' (Option) should NOT be required"
);
}
#[test]
fn into_iter_yields_all_tool_name_definition_pairs() {
let mut d = ToolRegistry::new();
d.register(SampleTool);
d.register(RunCommandTool);
let mut pairs: Vec<(&str, String)> = (&d)
.into_iter()
.map(|(name, def)| (name, def.name))
.collect();
pairs.sort();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].0, "run_command");
assert_eq!(pairs[0].1, "run_command");
assert_eq!(pairs[1].0, "sample");
assert_eq!(pairs[1].1, "sample");
}
#[test]
fn into_iter_empty_registry_yields_nothing() {
let d = ToolRegistry::new();
let count = (&d).into_iter().count();
assert_eq!(count, 0);
}
#[test]
fn into_iter_for_loop_syntax() {
let mut d = ToolRegistry::new();
d.register(SampleTool);
let mut found = false;
for (name, def) in &d {
if name == "sample" {
assert_eq!(def.description, "A sample tool");
found = true;
}
}
assert!(found, "Expected to find 'sample' tool via for-in loop");
}
#[test]
fn tool_context_conversation_id_none_by_default() {
let ctx = ToolContext::new();
assert!(ctx.conversation_id().is_none());
}
#[test]
fn tool_context_conversation_id_returns_value() {
let ctx = ToolContext::new().with_conversation_id("conv-123");
assert_eq!(ctx.conversation_id(), Some("conv-123"));
}
#[test]
fn tool_context_get_set_state_roundtrip() {
let ctx = ToolContext::new();
let val = ctx.get_state("missing", serde_json::json!("fallback"));
assert_eq!(val, serde_json::json!("fallback"));
ctx.set_state("counter", serde_json::json!(42))
.expect("set_state");
let val = ctx.get_state("counter", serde_json::json!(0));
assert_eq!(val, serde_json::json!(42));
ctx.set_state("counter", serde_json::json!(99))
.expect("set_state");
let val = ctx.get_state("counter", serde_json::json!(0));
assert_eq!(val, serde_json::json!(99));
}
#[test]
fn tool_context_state_persists_across_reads() {
let ctx = ToolContext::new();
ctx.set_state("key", serde_json::json!({"nested": true}))
.expect("set_state");
let v1 = ctx.get_state("key", serde_json::json!(null));
let v2 = ctx.get_state("key", serde_json::json!(null));
assert_eq!(v1, v2);
assert_eq!(v1, serde_json::json!({"nested": true}));
}
#[tokio::test]
async fn dispatch_passes_context_to_tool() {
struct ContextAwareTool;
impl RustTool for ContextAwareTool {
type Params = EmptyParams;
const NAME: &'static str = "ctx_tool";
const DESCRIPTION: &'static str = "Reads conversation_id from context.";
#[allow(unknown_lints)]
#[expect(clippy::unused_async_trait_impl)]
async fn call(
&self,
_params: Self::Params,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
let conv = ctx.conversation_id().unwrap_or("none");
let count = ctx.get_state("call_count", serde_json::json!(0));
let n = count.as_i64().unwrap_or(0);
ctx.set_state("call_count", serde_json::json!(n + 1))
.map_err(|e| ToolError::new(format!("set_state failed: {e}")))?;
Ok(format!("conv={conv}, call={n}").into())
}
}
let mut d = ToolRegistry::new();
d.register(ContextAwareTool);
let ctx = ToolContext::new().with_conversation_id("test-conv");
let r1 = d.dispatch("ctx_tool", serde_json::json!({}), &ctx).await;
assert_eq!(r1.unwrap().content(), "conv=test-conv, call=0");
let r2 = d.dispatch("ctx_tool", serde_json::json!({}), &ctx).await;
assert_eq!(r2.unwrap().content(), "conv=test-conv, call=1");
}
#[derive(serde::Serialize)]
struct ProcessMeta {
bytes_read: usize,
source: String,
}
struct MetadataTool;
impl RustTool for MetadataTool {
type Params = PathParams;
const NAME: &'static str = "metadata_tool";
const DESCRIPTION: &'static str = "Returns output with metadata.";
#[allow(unknown_lints)]
#[expect(clippy::unused_async_trait_impl)]
async fn call(
&self,
params: Self::Params,
_ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
ToolOutput::new(format!("processed: {}", params.path)).with_metadata(&ProcessMeta {
bytes_read: 1024,
source: params.path,
})
}
}
#[tokio::test]
async fn dispatch_preserves_tool_output_metadata() {
let mut d = ToolRegistry::new();
d.register(MetadataTool);
let result = d
.dispatch(
"metadata_tool",
serde_json::json!({"path": "/etc/hosts"}),
&test_ctx(),
)
.await
.unwrap();
assert_eq!(result.content(), "processed: /etc/hosts");
assert_eq!(result.metadata()["bytes_read"], 1024);
assert_eq!(result.metadata()["source"], "/etc/hosts");
assert_eq!(result.metadata().len(), 2);
}
#[tokio::test]
async fn dispatch_tool_output_display_uses_content() {
let output = ToolOutput::new("hello world").with_meta("ignored", serde_json::json!(true));
assert_eq!(output.to_string(), "hello world");
}
#[tokio::test]
async fn dispatch_tool_output_into_content_consumes() {
let output = ToolOutput::new("owned").with_meta("key", serde_json::json!("val"));
let content: String = output.into_content();
assert_eq!(content, "owned");
}
#[test]
fn tool_output_from_str_has_empty_metadata() {
let output: ToolOutput = "plain".into();
assert_eq!(output.content(), "plain");
assert!(output.metadata().is_empty());
}
#[test]
fn tool_output_from_string_has_empty_metadata() {
let output: ToolOutput = "owned".to_string().into();
assert_eq!(output.content(), "owned");
assert!(output.metadata().is_empty());
}
#[test]
fn tool_error_with_metadata() {
let err = ToolError::new("HTTP request failed")
.with_meta("status_code", serde_json::json!(503))
.with_meta("url", serde_json::json!("https://example.com"));
assert_eq!(err.message, "HTTP request failed");
assert_eq!(err.metadata()["status_code"], 503);
assert_eq!(err.metadata()["url"], "https://example.com");
assert_eq!(err.metadata().len(), 2);
}
#[test]
fn tool_error_without_metadata_is_empty() {
let err = ToolError::new("simple error");
assert!(err.metadata().is_empty());
}
#[test]
fn tool_error_display_ignores_metadata() {
let err = ToolError::new("visible").with_meta("hidden", serde_json::json!(true));
assert_eq!(err.to_string(), "visible");
}
#[test]
fn tool_error_equality_includes_metadata() {
let a = ToolError::new("err").with_meta("k", serde_json::json!(1));
let b = ToolError::new("err").with_meta("k", serde_json::json!(1));
let c = ToolError::new("err").with_meta("k", serde_json::json!(2));
assert_eq!(a, b);
assert_ne!(a, c);
}
struct MetadataErrorTool;
impl RustTool for MetadataErrorTool {
type Params = EmptyParams;
const NAME: &'static str = "metadata_error_tool";
const DESCRIPTION: &'static str = "Always fails with metadata.";
#[allow(unknown_lints)]
#[expect(clippy::unused_async_trait_impl)]
async fn call(
&self,
_params: Self::Params,
_ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
Err(ToolError::new("service unavailable")
.with_meta("retry_after_secs", serde_json::json!(30)))
}
}
#[tokio::test]
async fn dispatch_preserves_tool_error_metadata() {
let mut d = ToolRegistry::new();
d.register(MetadataErrorTool);
let err = d
.dispatch("metadata_error_tool", serde_json::json!({}), &test_ctx())
.await
.unwrap_err();
assert_eq!(err.message, "service unavailable");
assert_eq!(err.metadata()["retry_after_secs"], 30);
}
#[llm_tool]
fn tool_with_metadata(
input: String,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::new(format!("echoed: {input}"))
.with_meta("input_len", serde_json::json!(input.len())))
}
#[tokio::test]
async fn macro_tool_returning_tool_output_preserves_metadata() {
let mut d = ToolRegistry::new();
d.register(ToolWithMetadata);
let result = d
.dispatch(
"tool_with_metadata",
serde_json::json!({"input": "hello"}),
&test_ctx(),
)
.await
.unwrap();
assert_eq!(result.content(), "echoed: hello");
assert_eq!(result.metadata()["input_len"], 5);
}
#[test]
fn tool_output_with_metadata_struct() {
#[derive(serde::Serialize)]
struct Meta {
status: String,
count: u32,
}
let out = ToolOutput::new("done")
.with_metadata(&Meta {
status: "ok".into(),
count: 42,
})
.unwrap();
assert_eq!(out.metadata()["status"], "ok");
assert_eq!(out.metadata()["count"], 42);
assert_eq!(out.metadata().len(), 2);
}
#[test]
fn tool_output_with_metadata_merges_with_existing() {
#[derive(serde::Serialize)]
struct Extra {
source: String,
}
let out = ToolOutput::new("data")
.with_meta("version", serde_json::json!(1))
.with_metadata(&Extra {
source: "cache".into(),
})
.unwrap();
assert_eq!(out.metadata()["version"], 1);
assert_eq!(out.metadata()["source"], "cache");
assert_eq!(out.metadata().len(), 2);
}
#[test]
fn tool_output_with_metadata_rejects_non_object() {
let err = ToolOutput::new("x").with_metadata(&42_i32).unwrap_err();
assert!(
err.message.contains("JSON object"),
"Expected object error, got: {err}"
);
}
#[test]
fn tool_error_with_metadata_struct() {
#[derive(serde::Serialize)]
struct ErrorMeta {
status_code: u16,
url: String,
}
let err = ToolError::new("HTTP request failed")
.with_metadata(&ErrorMeta {
status_code: 503,
url: "https://example.com".into(),
})
.unwrap();
assert_eq!(err.message, "HTTP request failed");
assert_eq!(err.metadata()["status_code"], 503);
assert_eq!(err.metadata()["url"], "https://example.com");
assert_eq!(err.metadata().len(), 2);
}
#[test]
fn tool_output_from_metadata_populates_both() {
#[derive(serde::Serialize)]
struct Weather {
location: String,
temp_f: i32,
}
let out = ToolOutput::from_metadata(&Weather {
location: "Seattle".into(),
temp_f: 72,
})
.unwrap();
assert!(out.content().contains("Seattle"));
assert!(out.content().contains("72"));
assert_eq!(out.metadata()["location"], "Seattle");
assert_eq!(out.metadata()["temp_f"], 72);
assert_eq!(out.metadata().len(), 2);
}
#[test]
fn tool_output_from_metadata_rejects_non_object() {
let err = ToolOutput::from_metadata(&"just a string").unwrap_err();
assert!(
err.message.contains("JSON object"),
"Expected object error, got: {err}"
);
}
#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct StructuredResult {
success: bool,
code: i32,
}
#[llm_tool]
fn return_structured_struct() -> Result<StructuredResult, ToolError> {
Ok(StructuredResult {
success: true,
code: 200,
})
}
#[tokio::test]
async fn macro_tool_returning_struct_populates_metadata_automatically() {
let mut d = ToolRegistry::new();
d.register(ReturnStructuredStruct);
let result = d
.dispatch(
"return_structured_struct",
serde_json::json!({}),
&test_ctx(),
)
.await
.unwrap();
assert!(result.content().contains("success"));
assert!(result.content().contains("true"));
assert!(result.content().contains("code"));
assert!(result.content().contains("200"));
assert_eq!(result.metadata()["success"], true);
assert_eq!(result.metadata()["code"], 200);
assert_eq!(result.metadata().len(), 2);
}
#[llm_tool]
fn return_primitive() -> Result<i32, ToolError> {
Ok(42)
}
#[tokio::test]
async fn macro_tool_returning_primitive_leaves_metadata_empty() {
let mut d = ToolRegistry::new();
d.register(ReturnPrimitive);
let result = d
.dispatch("return_primitive", serde_json::json!({}), &test_ctx())
.await
.unwrap();
assert_eq!(result.content(), "42");
assert!(result.metadata().is_empty());
}
#[llm_tool]
fn return_json_wrapper() -> Result<crate::Json<StructuredResult>, ToolError> {
Ok(crate::Json(StructuredResult {
success: false,
code: 500,
}))
}
#[tokio::test]
async fn macro_tool_returning_json_wrapper_populates_metadata() {
let mut d = ToolRegistry::new();
d.register(ReturnJsonWrapper);
let result = d
.dispatch("return_json_wrapper", serde_json::json!({}), &test_ctx())
.await
.unwrap();
assert!(result.content().contains("success"));
assert_eq!(result.metadata()["success"], false);
assert_eq!(result.metadata()["code"], 500);
}