use super::*;
use crate::extract::{Context, Json, RawArgs, State};
use crate::protocol::Content;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Default)]
struct TerminalStatusStore {
outstanding: Option<InputRequests>,
}
#[async_trait::async_trait]
impl crate::async_task::TaskStore for TerminalStatusStore {
async fn create_task(
&self,
_tool_name: &str,
_arguments: serde_json::Value,
_ttl: Option<u64>,
_owner: crate::async_task::TaskOwner,
) -> crate::async_task::Result<(String, crate::async_task::CancellationToken)> {
unimplemented!("not used by this focused status test")
}
async fn task_owner(
&self,
_task_id: &str,
) -> crate::async_task::Result<Option<crate::async_task::TaskOwner>> {
Ok(Some(None))
}
async fn get_task(
&self,
_task_id: &str,
) -> crate::async_task::Result<Option<crate::protocol::TaskObject>> {
Ok(None)
}
async fn get_task_result(
&self,
_task_id: &str,
) -> crate::async_task::Result<Option<crate::async_task::TaskSnapshot>> {
Ok(None)
}
async fn wait_for_completion(
&self,
_task_id: &str,
) -> crate::async_task::Result<Option<crate::async_task::TaskSnapshot>> {
Ok(None)
}
async fn list_tasks(
&self,
_status_filter: Option<TaskStatus>,
) -> crate::async_task::Result<Vec<crate::protocol::TaskObject>> {
Ok(Vec::new())
}
async fn require_input(
&self,
_task_id: &str,
_requests: InputRequests,
_message: Option<&str>,
) -> crate::async_task::Result<bool> {
Ok(false)
}
async fn outstanding_input_requests(
&self,
_task_id: &str,
) -> crate::async_task::Result<Option<InputRequests>> {
Ok(self.outstanding.clone())
}
async fn apply_input_responses(
&self,
_task_id: &str,
_responses: InputResponses,
) -> crate::async_task::Result<Option<crate::async_task::AppliedInputResponses>> {
Ok(None)
}
async fn set_ttl(&self, _task_id: &str, _ttl_ms: u64) -> crate::async_task::Result<bool> {
Ok(false)
}
async fn set_status(
&self,
_task_id: &str,
_status: TaskStatus,
_message: Option<&str>,
) -> crate::async_task::Result<bool> {
Ok(false)
}
async fn complete_task(
&self,
_task_id: &str,
_result: CallToolResult,
) -> crate::async_task::Result<bool> {
Ok(false)
}
async fn fail_task(
&self,
_task_id: &str,
_error: crate::error::JsonRpcError,
) -> crate::async_task::Result<bool> {
Ok(false)
}
async fn cancel_task(
&self,
_task_id: &str,
_reason: Option<&str>,
) -> crate::async_task::Result<Option<crate::protocol::TaskObject>> {
Ok(None)
}
}
#[tokio::test]
async fn live_working_reports_a_terminal_race_through_the_task_policy() {
let live = Arc::new(LiveTask {
store: Arc::new(TerminalStatusStore::default()),
error_policy: crate::router::TaskErrorPolicy::new(|context| {
assert_eq!(context.operation(), crate::router::TaskOperation::Execute);
assert!(matches!(
context.failure(),
crate::router::TaskFailure::Internal(_)
));
crate::error::JsonRpcError::internal_error("mapped terminal race")
}),
input_ready: tokio::sync::Notify::new(),
cancellation: Arc::new(crate::task_execution::LiveTaskCancellation::new()),
});
let context = TaskContext::with_live("task_terminal".into(), live);
let error = context
.working("resumed")
.await
.expect_err("a terminal Task cannot accept a progress update");
assert!(matches!(
error,
crate::error::Error::JsonRpc(error) if error.message == "mapped terminal race"
));
}
fn mapped_live(store: TerminalStatusStore) -> Arc<LiveTask> {
Arc::new(LiveTask {
store: Arc::new(store),
error_policy: crate::router::TaskErrorPolicy::new(|context| {
assert_eq!(context.operation(), crate::router::TaskOperation::Execute);
assert!(matches!(
context.failure(),
crate::router::TaskFailure::Internal(_)
));
crate::error::JsonRpcError::internal_error("mapped missing input state")
}),
input_ready: tokio::sync::Notify::new(),
cancellation: Arc::new(crate::task_execution::LiveTaskCancellation::new()),
})
}
#[tokio::test]
async fn pending_input_reports_a_disappeared_task_instead_of_empty_answers() {
let pending = PendingInput {
live: mapped_live(TerminalStatusStore::default()),
task_id: "task_missing".into(),
asked: vec!["approval".into()],
};
let error = pending
.wait()
.await
.expect_err("a missing outstanding-input snapshot must not resume the handler");
assert!(matches!(
error,
crate::error::Error::JsonRpc(error) if error.message == "mapped missing input state"
));
}
#[tokio::test]
async fn pending_input_reports_missing_responses_instead_of_empty_answers() {
let pending = PendingInput {
live: mapped_live(TerminalStatusStore {
outstanding: Some(InputRequests::default()),
}),
task_id: "task_missing".into(),
asked: vec!["approval".into()],
};
let error = pending
.wait()
.await
.expect_err("a missing response snapshot must not resume the handler");
assert!(matches!(
error,
crate::error::Error::JsonRpc(error) if error.message == "mapped missing input state"
));
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GreetInput {
name: String,
}
#[tokio::test]
async fn test_builder_tool() {
let tool = ToolBuilder::new("greet")
.description("Greet someone")
.handler(|input: GreetInput| async move {
Ok(CallToolResult::text(format!("Hello, {}!", input.name)))
})
.build();
assert_eq!(tool.name, "greet");
assert_eq!(tool.description.as_deref(), Some("Greet someone"));
let result = tool.call(serde_json::json!({"name": "World"})).await;
assert!(!result.is_error);
}
#[tokio::test]
async fn direct_call_to_live_only_tool_returns_an_error_without_running_it() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let observed = calls.clone();
let tool = ToolBuilder::new("live_only")
.live_task_handler(move |_task: TaskContext, _input: NoParams| {
observed.fetch_add(1, Ordering::SeqCst);
async move { Ok(TaskOutcome::Completed(CallToolResult::text("unreachable"))) }
})
.build();
let error = tool
.call_outcome(serde_json::json!({}))
.await
.expect_err("a direct call cannot run a live-only handler");
let Error::Tool(error) = error else {
panic!("expected a tool error");
};
assert_eq!(
error.message,
"tool has no synchronous or MRTR handler; it can only be invoked as a task"
);
let result = tool.call(serde_json::json!({})).await;
assert!(result.is_error);
assert_eq!(
result.first_text(),
Some(
"Tool error: tool has no synchronous or MRTR handler; it can only be invoked as a task"
)
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn test_mrtr_builder_preserves_input_required_outcome() {
let tool = ToolBuilder::new("continue")
.mrtr_handler::<NoParams, _, _>(|_ctx, _input| async move {
Ok(RequestOutcome::input_required(
crate::protocol::InputRequiredResult::new().with_request_state("signed-state"),
))
})
.build();
let outcome = tool.call_outcome(serde_json::json!({})).await.unwrap();
assert_eq!(
outcome
.as_input_required()
.and_then(|result| result.request_state.as_deref()),
Some("signed-state")
);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn mrtr_builder_composes_guards_and_layers() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let rounds = Arc::new(AtomicUsize::new(0));
let observed = rounds.clone();
let tool = ToolBuilder::new("guarded_continue")
.mrtr_handler::<NoParams, _, _>(|_ctx, _input| async move {
Ok(RequestOutcome::input_required(
crate::protocol::InputRequiredResult::new().with_request_state("continue"),
))
})
.layer(TimeoutLayer::new(Duration::from_secs(1)))
.guard(move |_request| {
observed.fetch_add(1, Ordering::SeqCst);
Ok(())
})
.build();
for _ in 0..2 {
assert!(
tool.call_outcome(serde_json::json!({}))
.await
.unwrap()
.as_input_required()
.is_some()
);
}
assert_eq!(rounds.load(Ordering::SeqCst), 2);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn built_mrtr_tool_accepts_a_guard() {
let tool = ToolBuilder::new("denied_continue")
.mrtr_handler::<NoParams, _, _>(|_ctx, _input| async move {
Ok(RequestOutcome::input_required(
crate::protocol::InputRequiredResult::new().with_request_state("unreachable"),
))
})
.build()
.with_guard(|_request| Err("MRTR access denied".to_string()));
let outcome = tool.call_outcome(serde_json::json!({})).await.unwrap();
let result = outcome
.as_complete()
.expect("guard rejection is a complete tool error");
assert!(result.is_error);
assert_eq!(result.first_text(), Some("MRTR access denied"));
}
#[tokio::test]
async fn test_raw_handler() {
let tool = ToolBuilder::new("echo")
.description("Echo input")
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::json(args))
})
.build();
let result = tool.call(serde_json::json!({"foo": "bar"})).await;
assert!(!result.is_error);
}
#[test]
fn test_invalid_tool_name_empty() {
let err = ToolBuilder::try_new("").err().expect("should fail");
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn test_invalid_tool_name_too_long() {
let long_name = "a".repeat(65);
let err = ToolBuilder::try_new(long_name).err().expect("should fail");
assert!(err.to_string().contains("exceeds maximum"));
}
#[test]
fn test_invalid_tool_name_bad_chars() {
let err = ToolBuilder::try_new("my tool!").err().expect("should fail");
assert!(err.to_string().contains("invalid character"));
}
#[test]
#[should_panic(expected = "cannot be empty")]
fn test_new_panics_on_empty_name() {
ToolBuilder::new("");
}
#[test]
#[should_panic(expected = "exceeds maximum")]
fn test_new_panics_on_too_long_name() {
ToolBuilder::new("a".repeat(65));
}
#[test]
#[should_panic(expected = "invalid character")]
fn test_new_panics_on_invalid_chars() {
ToolBuilder::new("my tool!");
}
#[test]
fn test_valid_tool_names() {
let names = [
"my_tool",
"my-tool",
"my.tool",
"my/tool",
"user-profile/update",
"MyTool123",
"a",
&"a".repeat(64),
];
for name in names {
assert!(
ToolBuilder::try_new(name).is_ok(),
"Expected '{}' to be valid",
name
);
}
}
#[tokio::test]
async fn test_context_aware_handler() {
use crate::context::notification_channel;
use crate::protocol::{ProgressToken, RequestId};
#[derive(Debug, Deserialize, JsonSchema)]
struct ProcessInput {
count: i32,
}
let tool = ToolBuilder::new("process")
.description("Process with context")
.extractor_handler(
(),
|ctx: Context, Json(input): Json<ProcessInput>| async move {
for i in 0..input.count {
if ctx.is_cancelled() {
return Ok(CallToolResult::error("Cancelled"));
}
ctx.report_progress(i as f64, Some(input.count as f64), None)
.await;
}
Ok(CallToolResult::text(format!(
"Processed {} items",
input.count
)))
},
)
.build();
assert_eq!(tool.name, "process");
let (tx, mut rx) = notification_channel(10);
let ctx = RequestContext::new(RequestId::Number(1))
.with_progress_token(ProgressToken::Number(42))
.with_notification_sender(tx);
let result = tool
.call_with_context(ctx, serde_json::json!({"count": 3}))
.await;
assert!(!result.is_error);
let mut progress_count = 0;
while rx.try_recv().is_ok() {
progress_count += 1;
}
assert_eq!(progress_count, 3);
}
#[tokio::test]
async fn test_context_aware_handler_cancellation() {
use crate::protocol::RequestId;
use std::sync::atomic::{AtomicI32, Ordering};
#[derive(Debug, Deserialize, JsonSchema)]
struct LongRunningInput {
iterations: i32,
}
let iterations_completed = Arc::new(AtomicI32::new(0));
let iterations_ref = iterations_completed.clone();
let tool = ToolBuilder::new("long_running")
.description("Long running task")
.extractor_handler(
(),
move |ctx: Context, Json(input): Json<LongRunningInput>| {
let completed = iterations_ref.clone();
async move {
for i in 0..input.iterations {
if ctx.is_cancelled() {
return Ok(CallToolResult::error("Cancelled"));
}
completed.fetch_add(1, Ordering::SeqCst);
tokio::task::yield_now().await;
if i == 2 {
ctx.cancellation_token().cancel();
}
}
Ok(CallToolResult::text("Done"))
}
},
)
.build();
let ctx = RequestContext::new(RequestId::Number(1));
let result = tool
.call_with_context(ctx, serde_json::json!({"iterations": 10}))
.await;
assert!(result.is_error);
assert_eq!(iterations_completed.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_tool_builder_with_enhanced_fields() {
let output_schema = serde_json::json!({
"type": "object",
"properties": {
"greeting": {"type": "string"}
}
});
let tool = ToolBuilder::new("greet")
.title("Greeting Tool")
.description("Greet someone")
.output_schema(output_schema.clone())
.icon("https://example.com/icon.png")
.icon_with_meta(
"https://example.com/icon-large.png",
Some("image/png".to_string()),
Some(vec!["96x96".to_string()]),
)
.handler(|input: GreetInput| async move {
Ok(CallToolResult::text(format!("Hello, {}!", input.name)))
})
.build();
assert_eq!(tool.name, "greet");
assert_eq!(tool.title.as_deref(), Some("Greeting Tool"));
assert_eq!(tool.description.as_deref(), Some("Greet someone"));
assert_eq!(tool.output_schema, Some(output_schema));
assert!(tool.icons.is_some());
assert_eq!(tool.icons.as_ref().unwrap().len(), 2);
let def = tool.definition();
assert_eq!(def.title.as_deref(), Some("Greeting Tool"));
assert!(def.output_schema.is_some());
assert!(def.icons.is_some());
}
#[tokio::test]
async fn test_handler_with_state() {
let shared = Arc::new("shared-state".to_string());
let tool = ToolBuilder::new("stateful")
.description("Uses shared state")
.extractor_handler(
shared,
|State(state): State<Arc<String>>, Json(input): Json<GreetInput>| async move {
Ok(CallToolResult::text(format!(
"{}: Hello, {}!",
state, input.name
)))
},
)
.build();
let result = tool.call(serde_json::json!({"name": "World"})).await;
assert!(!result.is_error);
}
#[tokio::test]
async fn test_handler_with_state_and_context() {
use crate::protocol::RequestId;
let shared = Arc::new(42_i32);
let tool =
ToolBuilder::new("stateful_ctx")
.description("Uses state and context")
.extractor_handler(
shared,
|State(state): State<Arc<i32>>,
_ctx: Context,
Json(input): Json<GreetInput>| async move {
Ok(CallToolResult::text(format!(
"{}: Hello, {}!",
state, input.name
)))
},
)
.build();
let ctx = RequestContext::new(RequestId::Number(1));
let result = tool
.call_with_context(ctx, serde_json::json!({"name": "World"}))
.await;
assert!(!result.is_error);
}
#[tokio::test]
async fn test_handler_no_params() {
let tool = ToolBuilder::new("no_params")
.description("Takes no parameters")
.extractor_handler((), |Json(_): Json<NoParams>| async {
Ok(CallToolResult::text("no params result"))
})
.build();
assert_eq!(tool.name, "no_params");
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
let result = tool.call(serde_json::json!({"unexpected": "value"})).await;
assert!(!result.is_error);
let schema = tool.definition().input_schema;
assert_eq!(schema.get("type").unwrap().as_str().unwrap(), "object");
}
#[tokio::test]
async fn test_handler_with_state_no_params() {
let shared = Arc::new("shared_value".to_string());
let tool = ToolBuilder::new("with_state_no_params")
.description("Takes no parameters but has state")
.extractor_handler(
shared,
|State(state): State<Arc<String>>, Json(_): Json<NoParams>| async move {
Ok(CallToolResult::text(format!("state: {}", state)))
},
)
.build();
assert_eq!(tool.name, "with_state_no_params");
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "state: shared_value");
let schema = tool.definition().input_schema;
assert_eq!(schema.get("type").unwrap().as_str().unwrap(), "object");
}
#[tokio::test]
async fn test_handler_no_params_with_context() {
let tool = ToolBuilder::new("no_params_with_context")
.description("Takes no parameters but has context")
.extractor_handler((), |_ctx: Context, Json(_): Json<NoParams>| async move {
Ok(CallToolResult::text("context available"))
})
.build();
assert_eq!(tool.name, "no_params_with_context");
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "context available");
}
#[tokio::test]
async fn test_handler_with_state_and_context_no_params() {
let shared = Arc::new("shared".to_string());
let tool = ToolBuilder::new("state_context_no_params")
.description("Has state and context, no params")
.extractor_handler(
shared,
|State(state): State<Arc<String>>, _ctx: Context, Json(_): Json<NoParams>| async move {
Ok(CallToolResult::text(format!("state: {}", state)))
},
)
.build();
assert_eq!(tool.name, "state_context_no_params");
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "state: shared");
}
#[tokio::test]
async fn test_raw_handler_with_state() {
let prefix = Arc::new("prefix:".to_string());
let tool = ToolBuilder::new("raw_with_state")
.description("Raw handler with state")
.extractor_handler(
prefix,
|State(state): State<Arc<String>>, RawArgs(args): RawArgs| async move {
Ok(CallToolResult::text(format!("{} {}", state, args)))
},
)
.build();
assert_eq!(tool.name, "raw_with_state");
let result = tool.call(serde_json::json!({"key": "value"})).await;
assert!(!result.is_error);
assert!(result.first_text().unwrap().starts_with("prefix:"));
}
#[tokio::test]
async fn test_raw_handler_with_state_and_context() {
let prefix = Arc::new("prefix:".to_string());
let tool = ToolBuilder::new("raw_state_context")
.description("Raw handler with state and context")
.extractor_handler(
prefix,
|State(state): State<Arc<String>>, _ctx: Context, RawArgs(args): RawArgs| async move {
Ok(CallToolResult::text(format!("{} {}", state, args)))
},
)
.build();
assert_eq!(tool.name, "raw_state_context");
let result = tool.call(serde_json::json!({"key": "value"})).await;
assert!(!result.is_error);
assert!(result.first_text().unwrap().starts_with("prefix:"));
}
#[tokio::test]
async fn test_tool_with_timeout_layer() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
#[derive(Debug, Deserialize, JsonSchema)]
struct SlowInput {
delay_ms: u64,
}
let tool = ToolBuilder::new("slow_tool")
.description("A slow tool")
.handler(|input: SlowInput| async move {
tokio::time::sleep(Duration::from_millis(input.delay_ms)).await;
Ok(CallToolResult::text("completed"))
})
.layer(TimeoutLayer::new(Duration::from_millis(50)))
.build();
let result = tool.call(serde_json::json!({"delay_ms": 10})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "completed");
let result = tool.call(serde_json::json!({"delay_ms": 200})).await;
assert!(result.is_error);
let msg = result.first_text().unwrap().to_lowercase();
assert!(
msg.contains("timed out") || msg.contains("timeout") || msg.contains("elapsed"),
"Expected timeout error, got: {}",
msg
);
}
#[tokio::test]
async fn test_tool_with_concurrency_limit_layer() {
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tower::limit::ConcurrencyLimitLayer;
#[derive(Debug, Deserialize, JsonSchema)]
struct WorkInput {
id: u32,
}
let max_concurrent = Arc::new(AtomicU32::new(0));
let current_concurrent = Arc::new(AtomicU32::new(0));
let max_ref = max_concurrent.clone();
let current_ref = current_concurrent.clone();
let tool = ToolBuilder::new("concurrent_tool")
.description("A concurrent tool")
.handler(move |input: WorkInput| {
let max = max_ref.clone();
let current = current_ref.clone();
async move {
let prev = current.fetch_add(1, Ordering::SeqCst);
max.fetch_max(prev + 1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
current.fetch_sub(1, Ordering::SeqCst);
Ok(CallToolResult::text(format!("completed {}", input.id)))
}
})
.layer(ConcurrencyLimitLayer::new(2))
.build();
let handles: Vec<_> = (0..4)
.map(|i| {
let t = tool.call(serde_json::json!({"id": i}));
tokio::spawn(t)
})
.collect();
for handle in handles {
let result = handle.await.unwrap();
assert!(!result.is_error);
}
assert!(max_concurrent.load(Ordering::SeqCst) <= 2);
}
#[tokio::test]
async fn test_tool_with_multiple_layers() {
use std::time::Duration;
use tower::limit::ConcurrencyLimitLayer;
use tower::timeout::TimeoutLayer;
#[derive(Debug, Deserialize, JsonSchema)]
struct Input {
value: String,
}
let tool = ToolBuilder::new("multi_layer_tool")
.description("Tool with multiple layers")
.handler(|input: Input| async move {
Ok(CallToolResult::text(format!("processed: {}", input.value)))
})
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.layer(ConcurrencyLimitLayer::new(10))
.build();
let result = tool.call(serde_json::json!({"value": "test"})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "processed: test");
}
#[test]
fn test_tool_catch_error_clone() {
let tool = ToolBuilder::new("test")
.description("test")
.extractor_handler((), |RawArgs(_args): RawArgs| async {
Ok(CallToolResult::text("ok"))
})
.build();
let _clone = tool.call(serde_json::json!({}));
}
#[test]
fn test_tool_catch_error_debug() {
#[derive(Debug, Clone)]
struct DebugService;
impl Service<ToolRequest> for DebugService {
type Response = CallToolResult;
type Error = crate::error::Error;
type Future = Pin<
Box<
dyn Future<Output = std::result::Result<CallToolResult, crate::error::Error>>
+ Send,
>,
>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _req: ToolRequest) -> Self::Future {
Box::pin(async { Ok(CallToolResult::text("ok")) })
}
}
let catch_error = ToolCatchError::new(DebugService);
let debug = format!("{:?}", catch_error);
assert!(debug.contains("ToolCatchError"));
}
#[test]
fn test_tool_request_new() {
use crate::protocol::RequestId;
let ctx = RequestContext::new(RequestId::Number(42));
let args = serde_json::json!({"key": "value"});
let req = ToolRequest::new(ctx.clone(), args.clone());
assert_eq!(req.args, args);
}
#[test]
fn test_no_params_schema() {
let schema = schemars::schema_for!(NoParams);
let schema_value = serde_json::to_value(&schema).unwrap();
assert_eq!(
schema_value.get("type").and_then(|v| v.as_str()),
Some("object"),
"NoParams should generate type: object schema"
);
}
#[test]
fn test_no_params_deserialize() {
let from_empty_object: NoParams = serde_json::from_str("{}").unwrap();
assert_eq!(from_empty_object, NoParams);
let from_null: NoParams = serde_json::from_str("null").unwrap();
assert_eq!(from_null, NoParams);
let from_object_with_fields: NoParams =
serde_json::from_str(r#"{"unexpected": "value"}"#).unwrap();
assert_eq!(from_object_with_fields, NoParams);
}
#[tokio::test]
async fn test_no_params_type_in_handler() {
let tool = ToolBuilder::new("status")
.description("Get status")
.handler(|_input: NoParams| async move { Ok(CallToolResult::text("OK")) })
.build();
let schema = tool.definition().input_schema;
assert_eq!(
schema.get("type").and_then(|v| v.as_str()),
Some("object"),
"NoParams handler should produce type: object schema"
);
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
}
#[tokio::test]
async fn test_serde_json_value_handler_has_type_object() {
let tool = ToolBuilder::new("any_input")
.description("Accepts any input")
.handler(|_input: serde_json::Value| async move { Ok(CallToolResult::text("ok")) })
.build();
let schema = tool.definition().input_schema;
assert_eq!(
schema.get("type").and_then(|v| v.as_str()),
Some("object"),
"serde_json::Value handler should produce schema with type: object"
);
}
#[tokio::test]
async fn test_tool_with_name_prefix() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Input {
value: String,
}
let tool = ToolBuilder::new("query")
.description("Query something")
.title("Query Tool")
.handler(|input: Input| async move { Ok(CallToolResult::text(&input.value)) })
.build();
let prefixed = tool.with_name_prefix("db");
assert_eq!(prefixed.name, "db.query");
assert_eq!(prefixed.description.as_deref(), Some("Query something"));
assert_eq!(prefixed.title.as_deref(), Some("Query Tool"));
let result = prefixed
.call(serde_json::json!({"value": "test input"}))
.await;
assert!(!result.is_error);
match &result.content[0] {
Content::Text { text, .. } => assert_eq!(text, "test input"),
_ => panic!("Expected text content"),
}
}
#[tokio::test]
async fn test_tool_with_name_prefix_multiple_levels() {
let tool = ToolBuilder::new("action")
.description("Do something")
.handler(|_: NoParams| async move { Ok(CallToolResult::text("done")) })
.build();
let prefixed = tool.with_name_prefix("level1");
assert_eq!(prefixed.name, "level1.action");
let double_prefixed = prefixed.with_name_prefix("level0");
assert_eq!(double_prefixed.name, "level0.level1.action");
}
#[tokio::test]
async fn test_no_params_handler_basic() {
let tool = ToolBuilder::new("get_status")
.description("Get current status")
.no_params_handler(|| async { Ok(CallToolResult::text("OK")) })
.build();
assert_eq!(tool.name, "get_status");
assert_eq!(tool.description.as_deref(), Some("Get current status"));
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "OK");
let result = tool.call(serde_json::json!(null)).await;
assert!(!result.is_error);
let schema = tool.definition().input_schema;
assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
}
#[tokio::test]
async fn test_no_params_handler_with_captured_state() {
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let counter_ref = counter.clone();
let tool = ToolBuilder::new("increment")
.description("Increment counter")
.no_params_handler(move || {
let c = counter_ref.clone();
async move {
let prev = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(CallToolResult::text(format!("Incremented from {}", prev)))
}
})
.build();
let _ = tool.call(serde_json::json!({})).await;
let _ = tool.call(serde_json::json!({})).await;
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "Incremented from 2");
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn test_no_params_handler_with_layer() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let tool = ToolBuilder::new("slow_status")
.description("Slow status check")
.no_params_handler(|| async {
tokio::time::sleep(Duration::from_millis(10)).await;
Ok(CallToolResult::text("done"))
})
.layer(TimeoutLayer::new(Duration::from_secs(1)))
.build();
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "done");
}
#[tokio::test]
async fn test_no_params_handler_timeout() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let tool = ToolBuilder::new("very_slow_status")
.description("Very slow status check")
.no_params_handler(|| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(CallToolResult::text("done"))
})
.layer(TimeoutLayer::new(Duration::from_millis(50)))
.build();
let result = tool.call(serde_json::json!({})).await;
assert!(result.is_error);
let msg = result.first_text().unwrap().to_lowercase();
assert!(
msg.contains("timed out") || msg.contains("timeout") || msg.contains("elapsed"),
"Expected timeout error, got: {}",
msg
);
}
#[tokio::test]
async fn test_no_params_handler_with_multiple_layers() {
use std::time::Duration;
use tower::limit::ConcurrencyLimitLayer;
use tower::timeout::TimeoutLayer;
let tool = ToolBuilder::new("multi_layer_status")
.description("Status with multiple layers")
.no_params_handler(|| async { Ok(CallToolResult::text("status ok")) })
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.layer(ConcurrencyLimitLayer::new(10))
.build();
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "status ok");
}
#[tokio::test]
async fn test_guard_allows_request() {
#[derive(Debug, Deserialize, JsonSchema)]
#[allow(dead_code)]
struct DeleteInput {
id: String,
confirm: bool,
}
let tool = ToolBuilder::new("delete")
.description("Delete a record")
.handler(|input: DeleteInput| async move {
Ok(CallToolResult::text(format!("deleted {}", input.id)))
})
.guard(|req: &ToolRequest| {
let confirm = req
.args
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirm {
return Err("Must set confirm=true to delete".to_string());
}
Ok(())
})
.build();
let result = tool
.call(serde_json::json!({"id": "abc", "confirm": true}))
.await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "deleted abc");
}
#[tokio::test]
async fn test_guard_rejects_request() {
#[derive(Debug, Deserialize, JsonSchema)]
#[allow(dead_code)]
struct DeleteInput2 {
id: String,
confirm: bool,
}
let tool = ToolBuilder::new("delete2")
.description("Delete a record")
.handler(|input: DeleteInput2| async move {
Ok(CallToolResult::text(format!("deleted {}", input.id)))
})
.guard(|req: &ToolRequest| {
let confirm = req
.args
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirm {
return Err("Must set confirm=true to delete".to_string());
}
Ok(())
})
.build();
let result = tool
.call(serde_json::json!({"id": "abc", "confirm": false}))
.await;
assert!(result.is_error);
assert!(
result
.first_text()
.unwrap()
.contains("Must set confirm=true")
);
}
#[tokio::test]
async fn test_guard_with_layer() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let tool = ToolBuilder::new("guarded_timeout")
.description("Guarded with timeout")
.handler(|input: GreetInput| async move {
Ok(CallToolResult::text(format!("Hello, {}!", input.name)))
})
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.guard(|_req: &ToolRequest| Ok(()))
.build();
let result = tool.call(serde_json::json!({"name": "World"})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "Hello, World!");
}
#[tokio::test]
async fn test_guard_on_no_params_handler() {
let allowed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let allowed_clone = allowed.clone();
let tool = ToolBuilder::new("status")
.description("Get status")
.no_params_handler(|| async { Ok(CallToolResult::text("ok")) })
.guard(move |_req: &ToolRequest| {
if allowed_clone.load(std::sync::atomic::Ordering::Relaxed) {
Ok(())
} else {
Err("Access denied".to_string())
}
})
.build();
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "ok");
allowed.store(false, std::sync::atomic::Ordering::Relaxed);
let result = tool.call(serde_json::json!({})).await;
assert!(result.is_error);
assert!(result.first_text().unwrap().contains("Access denied"));
}
#[tokio::test]
async fn test_guard_on_no_params_handler_with_layer() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let tool = ToolBuilder::new("status_layered")
.description("Get status with layers")
.no_params_handler(|| async { Ok(CallToolResult::text("ok")) })
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.guard(|_req: &ToolRequest| Ok(()))
.build();
let result = tool.call(serde_json::json!({})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "ok");
}
#[tokio::test]
async fn test_guard_on_extractor_handler() {
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
prefix: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct QueryInput {
query: String,
}
let state = Arc::new(AppState {
prefix: "db".to_string(),
});
let tool = ToolBuilder::new("search")
.description("Search")
.extractor_handler(
state,
|State(app): State<Arc<AppState>>, Json(input): Json<QueryInput>| async move {
Ok(CallToolResult::text(format!(
"{}: {}",
app.prefix, input.query
)))
},
)
.guard(|req: &ToolRequest| {
let query = req.args.get("query").and_then(|v| v.as_str()).unwrap_or("");
if query.is_empty() {
return Err("Query cannot be empty".to_string());
}
Ok(())
})
.build();
let result = tool.call(serde_json::json!({"query": "hello"})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "db: hello");
let result = tool.call(serde_json::json!({"query": ""})).await;
assert!(result.is_error);
assert!(
result
.first_text()
.unwrap()
.contains("Query cannot be empty")
);
}
#[tokio::test]
async fn test_guard_on_extractor_handler_with_layer() {
use std::sync::Arc;
use std::time::Duration;
use tower::timeout::TimeoutLayer;
#[derive(Clone)]
struct AppState2 {
prefix: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct QueryInput2 {
query: String,
}
let state = Arc::new(AppState2 {
prefix: "db".to_string(),
});
let tool = ToolBuilder::new("search2")
.description("Search with layer and guard")
.extractor_handler(
state,
|State(app): State<Arc<AppState2>>, Json(input): Json<QueryInput2>| async move {
Ok(CallToolResult::text(format!(
"{}: {}",
app.prefix, input.query
)))
},
)
.layer(TimeoutLayer::new(Duration::from_secs(5)))
.guard(|_req: &ToolRequest| Ok(()))
.build();
let result = tool.call(serde_json::json!({"query": "hello"})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "db: hello");
}
#[tokio::test]
async fn test_tool_with_guard_post_build() {
let tool = ToolBuilder::new("admin_action")
.description("Admin action")
.handler(|_input: GreetInput| async move { Ok(CallToolResult::text("done")) })
.build();
let guarded = tool.with_guard(|req: &ToolRequest| {
let name = req.args.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name == "admin" {
Ok(())
} else {
Err("Only admin allowed".to_string())
}
});
let result = guarded.call(serde_json::json!({"name": "admin"})).await;
assert!(!result.is_error);
let result = guarded.call(serde_json::json!({"name": "user"})).await;
assert!(result.is_error);
assert!(result.first_text().unwrap().contains("Only admin allowed"));
}
#[tokio::test]
async fn test_with_guard_preserves_tool_metadata() {
let tool = ToolBuilder::new("my_tool")
.description("A tool")
.title("My Tool")
.read_only()
.handler(|_input: GreetInput| async move { Ok(CallToolResult::text("done")) })
.build();
let guarded = tool.with_guard(|_req: &ToolRequest| Ok(()));
assert_eq!(guarded.name, "my_tool");
assert_eq!(guarded.description.as_deref(), Some("A tool"));
assert_eq!(guarded.title.as_deref(), Some("My Tool"));
assert!(guarded.annotations.is_some());
}
#[tokio::test]
async fn test_guard_group_pattern() {
let require_auth = |req: &ToolRequest| {
let token = req
.args
.get("_token")
.and_then(|v| v.as_str())
.unwrap_or("");
if token == "valid" {
Ok(())
} else {
Err("Authentication required".to_string())
}
};
let tool1 = ToolBuilder::new("action1")
.description("Action 1")
.handler(|_input: GreetInput| async move { Ok(CallToolResult::text("action1")) })
.build();
let tool2 = ToolBuilder::new("action2")
.description("Action 2")
.handler(|_input: GreetInput| async move { Ok(CallToolResult::text("action2")) })
.build();
let guarded1 = tool1.with_guard(require_auth);
let guarded2 = tool2.with_guard(require_auth);
let r1 = guarded1
.call(serde_json::json!({"name": "test", "_token": "invalid"}))
.await;
let r2 = guarded2
.call(serde_json::json!({"name": "test", "_token": "invalid"}))
.await;
assert!(r1.is_error);
assert!(r2.is_error);
let r1 = guarded1
.call(serde_json::json!({"name": "test", "_token": "valid"}))
.await;
let r2 = guarded2
.call(serde_json::json!({"name": "test", "_token": "valid"}))
.await;
assert!(!r1.is_error);
assert!(!r2.is_error);
}
#[tokio::test]
async fn test_input_validation_returns_tool_error() {
#[derive(Debug, Deserialize, JsonSchema)]
struct StrictInput {
name: String,
count: u32,
}
let tool = ToolBuilder::new("strict_tool")
.description("requires specific input")
.handler(|input: StrictInput| async move {
Ok(CallToolResult::text(format!(
"{}: {}",
input.name, input.count
)))
})
.build();
let result = tool
.call(serde_json::json!({"name": "test", "count": 5}))
.await;
assert!(!result.is_error);
let result = tool.call(serde_json::json!({"name": "test"})).await;
assert!(result.is_error);
let text = result.first_text().unwrap();
assert!(text.contains("Invalid input"), "got: {text}");
let result = tool
.call(serde_json::json!({"name": "test", "count": "not_a_number"}))
.await;
assert!(result.is_error);
let text = result.first_text().unwrap();
assert!(text.contains("Invalid input"), "got: {text}");
}
#[tokio::test]
async fn test_input_schema_override_with_raw_args() {
let custom = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "minLength": 1 }
},
"required": ["query"]
});
let tool = ToolBuilder::new("query")
.description("Query with a custom schema")
.input_schema(custom.clone())
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::json(args))
})
.build();
let schema = tool.definition().input_schema;
assert_eq!(schema, custom);
let result = tool.call(serde_json::json!({"query": "hello"})).await;
assert!(!result.is_error);
}
#[tokio::test]
async fn test_input_schema_override_wins_over_typed_handler() {
let custom = serde_json::json!({
"type": "object",
"title": "GreetOverride",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 64 }
},
"required": ["name"],
"additionalProperties": false
});
let tool = ToolBuilder::new("greet")
.description("Greet someone with a hand-tuned schema")
.input_schema(custom.clone())
.handler(|input: GreetInput| async move {
Ok(CallToolResult::text(format!("Hello, {}!", input.name)))
})
.build();
let schema = tool.definition().input_schema;
assert_eq!(schema, custom);
assert_eq!(schema["title"], "GreetOverride");
let result = tool.call(serde_json::json!({"name": "World"})).await;
assert!(!result.is_error);
}
#[tokio::test]
async fn test_input_schema_override_preserves_2020_12_constructs() {
let custom = serde_json::json!({
"type": "object",
"properties": {
"filter": {
"oneOf": [
{ "type": "string" },
{
"type": "object",
"properties": { "field": { "type": "string" } },
"required": ["field"]
}
]
}
},
"required": ["filter"]
});
let tool = ToolBuilder::new("filter_tool")
.description("Demonstrates oneOf preservation")
.input_schema(custom.clone())
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::json(args))
})
.build();
let schema = tool.definition().input_schema;
assert_eq!(schema, custom);
let one_of = schema["properties"]["filter"]["oneOf"]
.as_array()
.expect("oneOf must survive as an array");
assert_eq!(one_of.len(), 2);
assert_eq!(one_of[0]["type"], "string");
assert_eq!(one_of[1]["type"], "object");
}
#[tokio::test]
async fn test_input_schema_override_adds_type_object_if_missing() {
let custom_no_type = serde_json::json!({
"properties": {
"x": { "type": "number" }
}
});
let tool = ToolBuilder::new("typeless")
.description("Schema missing top-level type")
.input_schema(custom_no_type)
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::json(args))
})
.build();
let schema = tool.definition().input_schema;
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["x"].is_object());
}
#[tokio::test]
async fn plain_tool_clone_still_runs() {
let tool = ToolBuilder::new("greet")
.description("Greet someone")
.handler(|input: GreetInput| async move {
Ok(CallToolResult::text(format!("Hello, {}!", input.name)))
})
.build();
let cloned = tool.clone();
let result = cloned.call(serde_json::json!({"name": "World"})).await;
assert!(!result.is_error);
assert_eq!(result.first_text().unwrap(), "Hello, World!");
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn mrtr_tool_clone_still_runs() {
let tool = ToolBuilder::new("continue")
.mrtr_handler::<NoParams, _, _>(|_ctx, _input| async move {
Ok(RequestOutcome::input_required(
crate::protocol::InputRequiredResult::new().with_request_state("signed-state"),
))
})
.build();
let cloned = tool.clone();
let outcome = cloned.call_outcome(serde_json::json!({})).await.unwrap();
assert_eq!(
outcome
.as_input_required()
.and_then(|result| result.request_state.as_deref()),
Some("signed-state"),
"a dropped mrtr_handler would fall through to the absent `service` \
and panic on `.expect(...)` in `call_outcome_with_context` instead"
);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn mrtr_tool_with_name_prefix_still_runs() {
let tool = ToolBuilder::new("continue")
.mrtr_handler::<NoParams, _, _>(|_ctx, _input| async move {
Ok(RequestOutcome::Complete(CallToolResult::text("done")))
})
.build();
let prefixed = tool.with_name_prefix("ns");
assert_eq!(prefixed.name, "ns.continue");
let outcome = prefixed.call_outcome(serde_json::json!({})).await.unwrap();
let result = outcome
.as_complete()
.expect("mrtr_handler must survive the prefix");
assert_eq!(result.first_text().unwrap(), "done");
}
fn live_ctx() -> RequestContext {
RequestContext::new(crate::protocol::RequestId::Number(0))
}
#[tokio::test]
async fn live_only_tool_clone_carries_the_live_handler() {
let tool = ToolBuilder::new("run")
.description("Completes immediately")
.live_task_handler(|_task: TaskContext, _input: NoParams| async move {
Ok(TaskOutcome::Completed(CallToolResult::text("ran")))
})
.build();
let cloned = tool.clone();
let handler = cloned
.live_handler
.as_ref()
.expect("clone must carry the live handler");
let outcome = handler
.call(
live_ctx(),
TaskContext::new("t1".to_string()),
serde_json::json!({}),
)
.await
.unwrap();
match outcome {
TaskOutcome::Completed(result) => assert_eq!(result.first_text().unwrap(), "ran"),
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn live_only_tool_with_name_prefix_carries_the_live_handler() {
let tool = ToolBuilder::new("run")
.description("Completes immediately")
.live_task_handler(|_task: TaskContext, _input: NoParams| async move {
Ok(TaskOutcome::Completed(CallToolResult::text("ran")))
})
.build();
let prefixed = tool.with_name_prefix("ns");
assert_eq!(prefixed.name, "ns.run");
let handler = prefixed
.live_handler
.as_ref()
.expect("with_name_prefix must carry the live handler");
let outcome = handler
.call(
live_ctx(),
TaskContext::new("t1".to_string()),
serde_json::json!({}),
)
.await
.unwrap();
match outcome {
TaskOutcome::Completed(result) => assert_eq!(result.first_text().unwrap(), "ran"),
other => panic!("expected Completed, got {other:?}"),
}
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn live_plus_mrtr_fallback_clone_carries_both_handlers() {
let tool = ToolBuilder::new("multi")
.description("Live plus MRTR fallback")
.live_task_handler(|_task: TaskContext, _input: NoParams| async move {
Ok(TaskOutcome::Completed(CallToolResult::text("live")))
})
.fallback_mrtr_handler(|_ctx: RequestContext, _input: NoParams| async move {
Ok(RequestOutcome::Complete(CallToolResult::text("fallback")))
})
.build();
let cloned = tool.clone();
let outcome = cloned.call_outcome(serde_json::json!({})).await.unwrap();
let result = outcome
.as_complete()
.expect("mrtr fallback must survive the clone");
assert_eq!(result.first_text().unwrap(), "fallback");
let handler = cloned
.live_handler
.as_ref()
.expect("clone must carry the live handler alongside the fallback");
let outcome = handler
.call(
live_ctx(),
TaskContext::new("t1".to_string()),
serde_json::json!({}),
)
.await
.unwrap();
match outcome {
TaskOutcome::Completed(result) => assert_eq!(result.first_text().unwrap(), "live"),
other => panic!("expected Completed, got {other:?}"),
}
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn live_plus_mrtr_fallback_with_name_prefix_carries_both_handlers() {
let tool = ToolBuilder::new("multi")
.description("Live plus MRTR fallback")
.live_task_handler(|_task: TaskContext, _input: NoParams| async move {
Ok(TaskOutcome::Completed(CallToolResult::text("live")))
})
.fallback_mrtr_handler(|_ctx: RequestContext, _input: NoParams| async move {
Ok(RequestOutcome::Complete(CallToolResult::text("fallback")))
})
.build();
let prefixed = tool.with_name_prefix("ns");
assert_eq!(prefixed.name, "ns.multi");
let outcome = prefixed.call_outcome(serde_json::json!({})).await.unwrap();
let result = outcome
.as_complete()
.expect("mrtr fallback must survive the prefix");
assert_eq!(result.first_text().unwrap(), "fallback");
let handler = prefixed
.live_handler
.as_ref()
.expect("with_name_prefix must carry the live handler alongside the fallback");
let outcome = handler
.call(
live_ctx(),
TaskContext::new("t1".to_string()),
serde_json::json!({}),
)
.await
.unwrap();
match outcome {
TaskOutcome::Completed(result) => assert_eq!(result.first_text().unwrap(), "live"),
other => panic!("expected Completed, got {other:?}"),
}
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn live_plus_mrtr_fallback_with_guard_rejects_both_paths() {
let tool = ToolBuilder::new("multi")
.description("Live plus MRTR fallback")
.live_task_handler(|_task: TaskContext, _input: NoParams| async move {
Ok(TaskOutcome::Completed(CallToolResult::text(
"should not run (live)",
)))
})
.fallback_mrtr_handler(|_ctx: RequestContext, _input: NoParams| async move {
Ok(RequestOutcome::Complete(CallToolResult::text(
"should not run (fallback)",
)))
})
.build()
.with_guard(|_req: &ToolRequest| Err("nope".to_string()));
let outcome = tool.call_outcome(serde_json::json!({})).await.unwrap();
let result = outcome
.as_complete()
.expect("guard rejection is a complete tool error");
assert!(result.is_error);
assert_eq!(result.first_text().unwrap(), "nope");
let handler = tool
.live_handler
.as_ref()
.expect("with_guard must not drop the live handler");
let outcome = handler
.call(
live_ctx(),
TaskContext::new("t1".to_string()),
serde_json::json!({}),
)
.await
.unwrap();
match outcome {
TaskOutcome::Completed(result) => {
assert!(result.is_error);
assert_eq!(result.first_text().unwrap(), "nope");
}
other => panic!("expected a rejected-but-terminal Completed, got {other:?}"),
}
}