Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,263 @@
//! `ToolCallContext` extension store.
use std::sync::Arc;
use xai_tool_protocol::ToolCallId;
use xai_tool_runtime::{BehaviorVersion, Cwd, ToolCallContext, TraceContext};
#[derive(Debug, PartialEq)]
struct Config {
base_url: String,
timeout_ms: u32,
}
#[derive(Debug, PartialEq)]
struct AuthToken(String);
#[derive(Debug)]
struct Counter(std::sync::atomic::AtomicUsize);
impl Counter {
fn bump(&self) {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
fn get(&self) -> usize {
self.0.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[test]
fn insert_then_get_returns_arc_of_same_value() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(Config {
base_url: "https://example".into(),
timeout_ms: 5_000,
});
let cfg = ctx
.extensions
.get::<Config>()
.expect("config must be present");
assert_eq!(cfg.base_url, "https://example");
assert_eq!(cfg.timeout_ms, 5_000);
}
#[test]
fn distinct_types_coexist() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(Config {
base_url: "u".into(),
timeout_ms: 1,
});
ctx.extensions.insert(AuthToken("token".into()));
assert!(ctx.extensions.contains::<Config>());
assert!(ctx.extensions.contains::<AuthToken>());
assert_eq!(ctx.extensions.len(), 2);
assert_eq!(ctx.extensions.get::<AuthToken>().unwrap().0, "token");
}
#[test]
fn reinsert_same_type_replaces_value() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(AuthToken("first".into()));
ctx.extensions.insert(AuthToken("second".into()));
assert_eq!(ctx.extensions.get::<AuthToken>().unwrap().0, "second");
assert_eq!(ctx.extensions.len(), 1);
}
#[test]
fn missing_type_returns_none() {
let ctx = ToolCallContext::default();
assert!(ctx.extensions.get::<Config>().is_none());
assert!(!ctx.extensions.contains::<Config>());
assert_eq!(ctx.extensions.len(), 0);
}
#[test]
fn remove_returns_value_then_none() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(Config {
base_url: "u".into(),
timeout_ms: 1,
});
let removed = ctx.extensions.remove::<Config>().expect("first remove");
assert_eq!(removed.base_url, "u");
assert!(ctx.extensions.remove::<Config>().is_none());
assert!(!ctx.extensions.contains::<Config>());
}
#[test]
fn insert_arc_shares_allocation() {
// Inserting an existing Arc means the stored value and the original
// share strong-count.
let arc = Arc::new(Config {
base_url: "shared".into(),
timeout_ms: 9,
});
let mut ctx = ToolCallContext::default();
ctx.extensions.insert_arc(arc.clone());
let from_ctx = ctx.extensions.get::<Config>().unwrap();
// Strong-count on the original Arc should reflect at least:
// - the original `arc` binding
// - the value stored in the extension map
// - the clone returned from `get`
assert!(Arc::strong_count(&arc) >= 3);
assert_eq!(*from_ctx, *arc);
}
#[test]
fn new_binds_to_specific_call_id() {
let id = ToolCallId::new("call-123").unwrap();
let ctx = ToolCallContext::new(id.clone());
assert_eq!(ctx.call_id, id);
assert_eq!(ctx.extensions.len(), 0);
}
#[tokio::test]
async fn context_can_cross_await_with_held_extension() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(Counter(0.into()));
let counter = ctx.extensions.get::<Counter>().unwrap();
counter.bump();
tokio::task::yield_now().await;
counter.bump();
assert_eq!(counter.get(), 2);
}
#[tokio::test]
async fn context_is_send_across_spawn() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(AuthToken("for-task".into()));
let handle =
tokio::spawn(async move { ctx.extensions.get::<AuthToken>().map(|t| t.0.clone()) });
let value = handle.await.unwrap();
assert_eq!(value.as_deref(), Some("for-task"));
}
#[test]
fn default_constructor_yields_fresh_call_id() {
let a = ToolCallContext::default();
let b = ToolCallContext::default();
assert_ne!(a.call_id, b.call_id, "default ids should be unique");
assert_eq!(a.extensions.len(), 0);
assert_eq!(b.extensions.len(), 0);
}
#[test]
fn clone_preserves_call_id_and_extensions() {
let mut ctx = ToolCallContext::new(ToolCallId::new("call-clone").unwrap());
ctx.extensions.insert(AuthToken("shared".into()));
let copy = ctx.clone();
assert_eq!(copy.call_id, ctx.call_id);
assert_eq!(copy.extensions.len(), 1);
// Both clones see the same Arc-backed extension value.
let from_orig = ctx.extensions.get::<AuthToken>().unwrap();
let from_copy = copy.extensions.get::<AuthToken>().unwrap();
assert_eq!(from_orig.0, from_copy.0);
// The Arc allocation is shared; mutating via one path is impossible
// (extensions are immutable through `get`), but strong-count rises
// because of the clone.
assert!(Arc::strong_count(&from_orig) >= 3);
}
#[test]
fn clone_extension_map_is_independent_after_remove() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(AuthToken("a".into()));
let mut copy = ctx.clone();
copy.extensions.remove::<AuthToken>();
assert_eq!(copy.extensions.len(), 0);
assert_eq!(
ctx.extensions.len(),
1,
"removing from the clone must not affect the original"
);
}
// ---------------------------------------------------------------------------
// Per-concept client/SDK-side extensions.
//
// These exist as separate extensions (one per concept) rather than a
// single bundle. The tests below pin three contracts:
//
// 1. Each extension round-trips through the typed-extension store
// independently of the others.
// 2. A dispatcher with only some of the concepts can install them
// individually — installing `Cwd` MUST NOT make `BehaviorVersion`
// look "present" with a default value, and vice versa.
// 3. Absence of every well-known extension is the legitimate "backend
// dispatcher" shape; tools that require one MUST treat absence as
// a hard error rather than fall back to a process-wide default.
// ---------------------------------------------------------------------------
#[test]
fn each_well_known_extension_round_trips_independently() {
let mut ctx = ToolCallContext::default();
ctx.extensions.insert(Cwd(std::path::PathBuf::from("/tmp")));
ctx.extensions.insert(BehaviorVersion("v1.0".into()));
ctx.extensions
.insert(TraceContext("traceparent: 00-...-00".into()));
assert_eq!(
ctx.extensions.get::<Cwd>().unwrap().0,
std::path::PathBuf::from("/tmp")
);
assert_eq!(ctx.extensions.get::<BehaviorVersion>().unwrap().0, "v1.0");
assert!(
ctx.extensions
.get::<TraceContext>()
.unwrap()
.0
.contains("traceparent")
);
assert_eq!(ctx.extensions.len(), 3);
}
#[test]
fn dispatcher_can_install_only_what_it_has() {
// A dispatcher that knows the cwd but not the trace context installs
// only `Cwd`. The other extensions stay absent (not "default"),
// which is the discriminator a tool can rely on.
let mut ctx = ToolCallContext::default();
ctx.extensions
.insert(Cwd(std::path::PathBuf::from("/work")));
assert!(ctx.extensions.contains::<Cwd>());
assert!(!ctx.extensions.contains::<BehaviorVersion>());
assert!(!ctx.extensions.contains::<TraceContext>());
assert_eq!(ctx.extensions.len(), 1);
// Adding `TraceContext` later does not implicitly conjure a
// `BehaviorVersion` — extensions are independent.
ctx.extensions.insert(TraceContext("tp".into()));
assert!(ctx.extensions.contains::<TraceContext>());
assert!(!ctx.extensions.contains::<BehaviorVersion>());
assert_eq!(ctx.extensions.len(), 2);
}
#[test]
fn absence_signals_backend_or_other_mode() {
// A backend dispatcher installs none of the client-side extensions.
// Tools that require any of them must treat absence as a hard error
// — this test pins the contract.
let ctx = ToolCallContext::default();
assert!(ctx.extensions.get::<Cwd>().is_none());
assert!(ctx.extensions.get::<BehaviorVersion>().is_none());
assert!(ctx.extensions.get::<TraceContext>().is_none());
assert!(!ctx.extensions.contains::<Cwd>());
assert!(!ctx.extensions.contains::<BehaviorVersion>());
assert!(!ctx.extensions.contains::<TraceContext>());
}
#[test]
fn well_known_extensions_clone_preserves_inner_value() {
let cwd = Cwd(std::path::PathBuf::from("/etc"));
let behavior = BehaviorVersion("v0".into());
let trace = TraceContext("tp".into());
assert_eq!(cwd.clone().0, cwd.0);
assert_eq!(behavior.clone().0, behavior.0);
assert_eq!(trace.clone().0, trace.0);
}

View file

@ -0,0 +1,226 @@
//! `From<ToolError> for ToolErrorWire` coverage for the struct-based ToolError.
use serde_json::json;
use xai_tool_protocol::{ToolErrorWire, ToolId};
use xai_tool_runtime::error::{ToolError, ToolErrorKind};
fn tid(name: &str) -> ToolId {
ToolId::new(name).unwrap()
}
#[test]
fn not_implemented_maps_to_custom_with_snake_case_code() {
let err = ToolError::not_implemented("nope");
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode, message, ..
} => {
assert_eq!(subcode, "not_implemented");
assert_eq!(message, "nope");
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn invalid_arguments_round_trips_message_and_details() {
let details = json!({"field": "name", "expected": "non-empty"});
let err = ToolError::invalid_arguments("bad name").with_details(details.clone());
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::InvalidArguments {
message,
details: d,
} => {
assert_eq!(message, "bad name");
assert_eq!(d, Some(details));
}
other => panic!("expected InvalidArguments, got {other:?}"),
}
}
#[test]
fn not_found_maps_to_tool_not_found() {
let err = ToolError::not_found(tid("missing"), "tool 'missing' not registered");
let wire: ToolErrorWire = err.into();
assert!(matches!(wire, ToolErrorWire::ToolNotFound { tool_id } if tool_id == tid("missing")));
}
#[test]
fn permission_denied_round_trips_reason() {
let err = ToolError::permission_denied("not authorised for write");
let wire: ToolErrorWire = err.into();
assert!(
matches!(wire, ToolErrorWire::PermissionDenied { reason } if reason == "not authorised for write")
);
}
#[test]
fn unauthorized_maps_to_custom_with_unauthorized_subcode() {
let err = ToolError::unauthorized("session expired");
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode, message, ..
} => {
assert_eq!(subcode, "unauthorized");
assert_eq!(message, "session expired");
}
other => panic!("expected Custom(unauthorized), got {other:?}"),
}
}
#[test]
fn timeout_with_details() {
let err = ToolError::timeout(tid("slow"), "image generation timed out")
.with_details(json!({"tool_id": "slow", "elapsed_ms": 2500}));
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Timeout {
tool_id,
elapsed_ms,
} => {
assert_eq!(tool_id, tid("slow"));
assert_eq!(elapsed_ms, 2_500);
}
other => panic!("expected Timeout, got {other:?}"),
}
}
#[test]
fn cancelled_round_trips_tool_id() {
let err = ToolError::cancelled(tid("paused"), "user cancelled");
let wire: ToolErrorWire = err.into();
assert!(matches!(wire, ToolErrorWire::Cancelled { tool_id } if tool_id == tid("paused")));
}
#[test]
fn rate_limited_carries_detail_message() {
let err = ToolError::rate_limited("You've reached your image generation limit.");
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode, message, ..
} => {
assert_eq!(subcode, "rate_limited");
assert_eq!(message, "You've reached your image generation limit.");
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn service_unavailable_carries_detail() {
let err = ToolError::service_unavailable("Media service temporarily down.");
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode, message, ..
} => {
assert_eq!(subcode, "service_unavailable");
assert_eq!(message, "Media service temporarily down.");
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn network_error_maps_to_custom_with_message() {
let err = ToolError::network_error("dns failure");
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode, message, ..
} => {
assert_eq!(subcode, "network_error");
assert_eq!(message, "dns failure");
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn execution_uses_detail_as_message() {
let err = ToolError::execution(
tid("worker"),
"image generation failed: model returned empty response",
)
.with_source(anyhow::anyhow!("root cause"));
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Execution { tool_id, message } => {
assert_eq!(tool_id, tid("worker"));
assert_eq!(
message,
"image generation failed: model returned empty response"
);
}
other => panic!("expected Execution, got {other:?}"),
}
}
#[test]
fn custom_round_trips_code_message_details() {
let err = ToolError::custom("billing_overflow", "quota exhausted")
.with_details(json!({"code": "billing_overflow", "limit": 1000}));
let wire: ToolErrorWire = err.into();
match wire {
ToolErrorWire::Custom {
subcode,
message,
details,
} => {
assert_eq!(subcode, "billing_overflow");
assert_eq!(message, "quota exhausted");
assert!(details.is_some());
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn variant_name_covers_all_kinds() {
let kinds = [
ToolErrorKind::NotImplemented,
ToolErrorKind::InvalidArguments,
ToolErrorKind::NotFound,
ToolErrorKind::PermissionDenied,
ToolErrorKind::Unauthorized,
ToolErrorKind::Timeout,
ToolErrorKind::Cancelled,
ToolErrorKind::RateLimited,
ToolErrorKind::ServiceUnavailable,
ToolErrorKind::NetworkError,
ToolErrorKind::Execution,
ToolErrorKind::BehaviorVersionUnsupported,
ToolErrorKind::RenderLimited,
ToolErrorKind::TerminalError,
ToolErrorKind::Custom,
];
let names: std::collections::HashSet<_> = kinds.iter().map(|k| k.as_str()).collect();
assert_eq!(names.len(), 15);
}
#[test]
fn display_shows_detail_not_kind() {
let err = ToolError::rate_limited("You've exceeded your quota.");
assert_eq!(err.to_string(), "You've exceeded your quota.");
}
#[test]
fn serde_json_error_converts_to_invalid_arguments() {
let err: ToolError = serde_json::from_str::<u32>("\"not a number\"")
.unwrap_err()
.into();
assert_eq!(err.kind, ToolErrorKind::InvalidArguments);
}
#[test]
fn with_source_preserves_detail() {
let err = ToolError::execution(tid("test"), "something broke")
.with_source(anyhow::anyhow!("inner cause"));
assert_eq!(err.detail, "something broke");
assert!(std::error::Error::source(&err).is_some());
}

View file

@ -0,0 +1,403 @@
//! Round-trip every `ToolNotification` variant through serde_json and
//! assert the wire shape is what consumers expect.
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use serde_json::{Value, json};
use xai_tool_runtime::{
BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout,
BashNotificationBase, BashOutputChunk, FileWritten, LspServerCrashed, LspServerFailed,
LspServerReady, LspServerRetrying, LspServerStarting, MonitorEvent, PlanModeEntered,
PlanModeExited, ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, TaskKind,
TaskSnapshot, ToolNotification, UserQuestionAsked,
};
fn base() -> BashNotificationBase {
BashNotificationBase {
tool_call_id: "call-1".into(),
command: "echo hi".into(),
output: b"hi\n".to_vec(),
total_bytes: 3,
truncated: false,
cwd: PathBuf::from("/tmp"),
}
}
fn round_trip(value: &ToolNotification) -> Value {
let json = serde_json::to_value(value).expect("serialize");
let back: ToolNotification = serde_json::from_value(json.clone()).expect("deserialize");
assert_eq!(*value, back, "round-trip must match");
json
}
fn assert_type_tag(json: &Value, expected: &str) {
assert_eq!(json["type"], json!(expected), "wire type tag mismatch");
}
#[test]
fn bash_output_chunk_round_trip() {
let n = ToolNotification::BashOutputChunk(BashOutputChunk { base: base() });
let json = round_trip(&n);
assert_type_tag(&json, "BashOutputChunk");
assert_eq!(json["command"], json!("echo hi"));
}
#[test]
fn bash_execution_complete_round_trip() {
let n = ToolNotification::BashExecutionComplete(BashExecutionComplete {
base: base(),
exit_code: Some(0),
signal: None,
});
let json = round_trip(&n);
assert_type_tag(&json, "BashExecutionComplete");
assert_eq!(json["exit_code"], json!(0));
}
#[test]
fn bash_execution_complete_was_signaled_helper() {
let none = BashExecutionComplete {
base: base(),
exit_code: Some(1),
signal: None,
};
assert!(!none.was_signaled());
let killed = BashExecutionComplete {
base: base(),
exit_code: None,
signal: Some("SIGKILL".into()),
};
assert!(killed.was_signaled());
}
#[test]
fn bash_execution_timeout_round_trip() {
let n = ToolNotification::BashExecutionTimeout(BashExecutionTimeout {
base: base(),
elapsed: Duration::from_secs(30),
timeout: Duration::from_secs(20),
});
let json = round_trip(&n);
assert_type_tag(&json, "BashExecutionTimeout");
}
#[test]
fn bash_execution_backgrounded_round_trip() {
let n = ToolNotification::BashExecutionBackgrounded(BashExecutionBackgrounded {
base: base(),
output_file: PathBuf::from("/tmp/out.log"),
task_id: "bg-1".into(),
});
let json = round_trip(&n);
assert_type_tag(&json, "BashExecutionBackgrounded");
assert_eq!(json["task_id"], json!("bg-1"));
}
#[test]
fn bash_execution_failed_round_trip() {
let n = ToolNotification::BashExecutionFailed(BashExecutionFailed {
tool_call_id: "call-2".into(),
command: "missing".into(),
cwd: PathBuf::from("/tmp"),
error: "not found".into(),
});
let json = round_trip(&n);
assert_type_tag(&json, "BashExecutionFailed");
}
#[test]
fn file_written_round_trip_includes_previous_content() {
let n = ToolNotification::FileWritten(FileWritten {
tool_call_id: "call-3".into(),
absolute_path: PathBuf::from("/tmp/x"),
content: "after".into(),
previous_content: Some("before".into()),
is_new_file: false,
});
let json = round_trip(&n);
assert_type_tag(&json, "FileWritten");
assert_eq!(json["previous_content"], json!("before"));
}
#[test]
fn task_completed_round_trip() {
let snap = TaskSnapshot {
task_id: "t-1".into(),
command: "echo".into(),
display_command: None,
cwd: "/tmp".into(),
start_time: SystemTime::UNIX_EPOCH,
end_time: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
output: "out".into(),
output_file: PathBuf::from("/tmp/out"),
truncated: false,
exit_code: Some(0),
signal: None,
completed: true,
kind: TaskKind::Bash,
};
assert!((snap.duration_secs() - 1.0).abs() < 0.001);
let n = ToolNotification::TaskCompleted(snap);
let json = round_trip(&n);
assert_type_tag(&json, "TaskCompleted");
}
#[test]
fn plan_mode_entered_round_trip() {
let n = ToolNotification::PlanModeEntered(PlanModeEntered {
tool_call_id: "call-4".into(),
});
let json = round_trip(&n);
assert_type_tag(&json, "PlanModeEntered");
}
#[test]
fn plan_mode_exited_round_trip() {
let n = ToolNotification::PlanModeExited(PlanModeExited {
tool_call_id: "call-5".into(),
plan_content: Some("plan".into()),
plan_file_path: ".grok/plan.md".into(),
});
let json = round_trip(&n);
assert_type_tag(&json, "PlanModeExited");
assert_eq!(json["plan_file_path"], json!(".grok/plan.md"));
}
#[test]
fn user_question_asked_round_trip() {
let n = ToolNotification::UserQuestionAsked(UserQuestionAsked {
tool_call_id: "call-6".into(),
questions_json: json!([{"q": "ok?"}]),
});
let json = round_trip(&n);
assert_type_tag(&json, "UserQuestionAsked");
}
#[test]
fn lsp_lifecycle_variants_round_trip() {
let variants = vec![
ToolNotification::LspServerStarting(LspServerStarting {
server_name: "rust".into(),
command: "rust-analyzer".into(),
}),
ToolNotification::LspServerReady(LspServerReady {
server_name: "rust".into(),
}),
ToolNotification::LspServerCrashed(LspServerCrashed {
server_name: "rust".into(),
}),
ToolNotification::LspServerRetrying(LspServerRetrying {
server_name: "rust".into(),
attempt: 1,
max_restarts: 3,
backoff_ms: 500,
}),
ToolNotification::LspServerFailed(LspServerFailed {
server_name: "rust".into(),
error: "init failed".into(),
attempts: 0,
}),
];
for v in &variants {
round_trip(v);
}
}
#[test]
fn scheduled_task_variants_round_trip() {
let fired = ToolNotification::ScheduledTaskFired(ScheduledTaskFired {
task_id: "s-1".into(),
prompt: "do thing".into(),
human_schedule: "every 5 minutes".into(),
next_fire_at: Some("2025-01-01T00:00:00Z".into()),
});
round_trip(&fired);
let removed = ToolNotification::ScheduledTaskRemoved(ScheduledTaskRemoved {
task_id: "s-1".into(),
});
round_trip(&removed);
let created = ToolNotification::ScheduledTaskCreated(ScheduledTaskCreated {
task_id: "s-2".into(),
prompt: "another".into(),
human_schedule: "once".into(),
next_fire_at: None,
});
round_trip(&created);
}
#[test]
fn monitor_event_round_trip() {
let n = ToolNotification::MonitorEvent(MonitorEvent {
task_id: "m-1".into(),
description: "errors in deploy.log".into(),
event_text: "<monitor-event>...</monitor-event>".into(),
raw_text: "...".into(),
});
let json = round_trip(&n);
assert_type_tag(&json, "MonitorEvent");
}
#[test]
fn task_kind_default_is_bash_and_round_trips() {
assert_eq!(TaskKind::default(), TaskKind::Bash);
let bash_json = serde_json::to_value(TaskKind::Bash).unwrap();
let monitor_json = serde_json::to_value(TaskKind::Monitor).unwrap();
assert_eq!(bash_json, json!("bash"));
assert_eq!(monitor_json, json!("monitor"));
}
#[test]
fn variant_count_matches_variant_name() {
let all_variants: Vec<ToolNotification> = vec![
ToolNotification::BashOutputChunk(BashOutputChunk { base: base() }),
ToolNotification::BashExecutionComplete(BashExecutionComplete {
base: base(),
exit_code: None,
signal: None,
}),
ToolNotification::BashExecutionTimeout(BashExecutionTimeout {
base: base(),
elapsed: Duration::ZERO,
timeout: Duration::ZERO,
}),
ToolNotification::BashExecutionBackgrounded(BashExecutionBackgrounded {
base: base(),
output_file: PathBuf::new(),
task_id: String::new(),
}),
ToolNotification::BashExecutionFailed(BashExecutionFailed {
tool_call_id: String::new(),
command: String::new(),
cwd: PathBuf::new(),
error: String::new(),
}),
ToolNotification::FileWritten(FileWritten {
tool_call_id: String::new(),
absolute_path: PathBuf::new(),
content: String::new(),
previous_content: None,
is_new_file: true,
}),
ToolNotification::TaskCompleted(TaskSnapshot {
task_id: String::new(),
command: String::new(),
display_command: None,
cwd: String::new(),
start_time: SystemTime::UNIX_EPOCH,
end_time: None,
output: String::new(),
output_file: PathBuf::new(),
truncated: false,
exit_code: None,
signal: None,
completed: false,
kind: TaskKind::Bash,
}),
ToolNotification::PlanModeEntered(PlanModeEntered {
tool_call_id: String::new(),
}),
ToolNotification::PlanModeExited(PlanModeExited {
tool_call_id: String::new(),
plan_content: None,
plan_file_path: String::new(),
}),
ToolNotification::UserQuestionAsked(UserQuestionAsked {
tool_call_id: String::new(),
questions_json: json!(null),
}),
ToolNotification::LspServerStarting(LspServerStarting {
server_name: String::new(),
command: String::new(),
}),
ToolNotification::LspServerReady(LspServerReady {
server_name: String::new(),
}),
ToolNotification::LspServerCrashed(LspServerCrashed {
server_name: String::new(),
}),
ToolNotification::LspServerRetrying(LspServerRetrying {
server_name: String::new(),
attempt: 0,
max_restarts: 0,
backoff_ms: 0,
}),
ToolNotification::LspServerFailed(LspServerFailed {
server_name: String::new(),
error: String::new(),
attempts: 0,
}),
ToolNotification::ScheduledTaskFired(ScheduledTaskFired {
task_id: String::new(),
prompt: String::new(),
human_schedule: String::new(),
next_fire_at: None,
}),
ToolNotification::ScheduledTaskRemoved(ScheduledTaskRemoved {
task_id: String::new(),
}),
ToolNotification::ScheduledTaskCreated(ScheduledTaskCreated {
task_id: String::new(),
prompt: String::new(),
human_schedule: String::new(),
next_fire_at: None,
}),
ToolNotification::MonitorEvent(MonitorEvent {
task_id: String::new(),
description: String::new(),
event_text: String::new(),
raw_text: String::new(),
}),
];
let names: std::collections::HashSet<_> =
all_variants.iter().map(|n| n.variant_name()).collect();
assert_eq!(
names.len(),
19,
"expected 19 distinct variant names; if you added a notification, extend the test list and `variant_name`"
);
assert_eq!(all_variants.len(), 19);
}
#[test]
fn handle_send_helpers_round_trip_through_channel() {
use futures::stream::StreamExt;
use xai_tool_runtime::ToolNotificationHandle;
let (handle, mut rx) = ToolNotificationHandle::channel();
handle.send_bash_output_chunk(BashOutputChunk { base: base() });
handle.send_lsp_ready(LspServerReady {
server_name: "rust".into(),
});
drop(handle);
let mut received = Vec::new();
futures::executor::block_on(async {
while let Some(item) = rx.next().await {
received.push(item.variant_name());
}
});
assert_eq!(received, vec!["BashOutputChunk", "LspServerReady"]);
}
#[test]
fn noop_handle_does_not_panic_or_record() {
let handle = xai_tool_runtime::ToolNotificationHandle::noop();
handle.send_bash_output_chunk(BashOutputChunk { base: base() });
handle.send_lsp_ready(LspServerReady {
server_name: "x".into(),
});
// No assertion needed — the handle drops sends silently.
}
#[test]
fn output_lossy_handles_invalid_utf8() {
let mut b = base();
b.output = vec![0xFF, b'a', b'b'];
let cow = b.output_lossy();
assert!(cow.contains("ab"));
assert!(cow.contains('\u{FFFD}'));
}

View file

@ -0,0 +1,103 @@
//! Backend-agnostic search interface — basic shape coverage.
use std::sync::Arc;
use xai_tool_runtime::{
SearchSnapshot, ServerSummary, ToolIndex, ToolSearchIndex, ToolSearchResult,
};
struct StubIndex {
summaries: Vec<ServerSummary>,
}
impl ToolSearchIndex for StubIndex {
fn search_snapshot(&self, query: &str, limit: usize) -> SearchSnapshot {
let results: Vec<_> = self
.summaries
.iter()
.flat_map(|s| s.tool_names.iter().map(move |t| (s, t)))
.filter(|(_, t)| t.contains(query))
.take(limit)
.map(|(s, t)| ToolSearchResult {
tool_name: t.clone(),
server_name: s.name.clone(),
description: format!("{} from {}", t, s.name),
score: 1.0,
parameters: Vec::new(),
input_schema: serde_json::json!({}),
})
.collect();
let returned = results.len();
SearchSnapshot {
results,
total_hidden_tools: self
.summaries
.iter()
.map(|s| s.tool_count())
.sum::<usize>()
.saturating_sub(returned),
is_ready: true,
}
}
fn list_server_summaries(&self) -> Vec<ServerSummary> {
self.summaries.clone()
}
}
#[test]
fn server_summary_tool_count_derives_from_names() {
let s = ServerSummary {
name: "linear".into(),
description: None,
tool_names: vec!["save_issue".into(), "list_issues".into(), "comment".into()],
};
assert_eq!(s.tool_count(), 3);
}
#[test]
fn server_summary_with_no_tools_reports_zero() {
let s = ServerSummary {
name: "empty".into(),
description: Some("placeholder".into()),
tool_names: Vec::new(),
};
assert_eq!(s.tool_count(), 0);
}
#[test]
fn search_index_object_safe_via_arc() {
let index = StubIndex {
summaries: vec![ServerSummary {
name: "linear".into(),
description: None,
tool_names: vec!["save_issue".into(), "list_issues".into()],
}],
};
let dyn_index: Arc<dyn ToolSearchIndex> = Arc::new(index);
let snap = dyn_index.search_snapshot("save", 10);
assert_eq!(snap.results.len(), 1);
assert_eq!(snap.results[0].tool_name, "save_issue");
assert_eq!(snap.total_hidden_tools, 1);
assert!(snap.is_ready);
let summaries = dyn_index.list_server_summaries();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].tool_count(), 2);
}
#[test]
fn tool_index_wrapper_clones_arc() {
let inner: Arc<dyn ToolSearchIndex> = Arc::new(StubIndex {
summaries: Vec::new(),
});
let wrapped = ToolIndex(inner.clone());
let copy = wrapped.clone();
// Both wrappers hold the same Arc — strong-count includes both
// wrappers and the original `inner` binding.
assert!(Arc::strong_count(&inner) >= 3);
// Debug impl renders without leaking the inner type.
let debug = format!("{wrapped:?}");
assert_eq!(debug, "ToolIndex");
drop(copy);
}

View file

@ -0,0 +1,177 @@
//! `Tool::should_list` predicate + `ToolDyn` blanket forwarding.
use std::sync::Arc;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use xai_tool_protocol::ToolId;
use xai_tool_runtime::{
ArcTool, Cwd, ListToolsContext, Tool, ToolCallContext, ToolDyn, ToolError, ToolOutput,
};
use xai_tool_types::ToolDescription;
#[derive(Deserialize, JsonSchema)]
struct NoArgs {}
#[derive(Serialize)]
struct Unit;
impl ToolOutput for Unit {}
struct AlwaysTool;
impl Tool for AlwaysTool {
type Args = NoArgs;
type Output = Unit;
fn id(&self) -> ToolId {
ToolId::new("always").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("always", "a")
}
async fn run(&self, _: ToolCallContext, _: NoArgs) -> Result<Unit, ToolError> {
Ok(Unit)
}
}
struct NeedsCwdTool;
impl Tool for NeedsCwdTool {
type Args = NoArgs;
type Output = Unit;
fn id(&self) -> ToolId {
ToolId::new("needs_cwd").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("needs_cwd", "a")
}
fn should_list(&self, ctx: &ListToolsContext) -> bool {
ctx.extensions.contains::<Cwd>()
}
async fn run(&self, _: ToolCallContext, _: NoArgs) -> Result<Unit, ToolError> {
Ok(Unit)
}
}
#[derive(Clone, Debug)]
struct AttachmentCount(usize);
struct NeedsAttachmentTool;
impl Tool for NeedsAttachmentTool {
type Args = NoArgs;
type Output = Unit;
fn id(&self) -> ToolId {
ToolId::new("needs_attachment").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("needs_attachment", "a")
}
fn should_list(&self, ctx: &ListToolsContext) -> bool {
ctx.extensions
.get::<AttachmentCount>()
.is_some_and(|c| c.0 > 0)
}
async fn run(&self, _: ToolCallContext, _: NoArgs) -> Result<Unit, ToolError> {
Ok(Unit)
}
}
// Tool::should_list (typed)
#[test]
fn default_returns_true() {
assert!(Tool::should_list(&AlwaysTool, &ListToolsContext::default()));
}
#[test]
fn reads_extensions() {
let tool = NeedsCwdTool;
assert!(!Tool::should_list(&tool, &ListToolsContext::default()));
let mut ctx = ListToolsContext::default();
ctx.extensions
.insert(Cwd(std::path::PathBuf::from("/work")));
assert!(Tool::should_list(&tool, &ctx));
}
#[test]
fn reads_custom_extension() {
let tool = NeedsAttachmentTool;
assert!(!Tool::should_list(&tool, &ListToolsContext::default()));
let mut zero = ListToolsContext::default();
zero.extensions.insert(AttachmentCount(0));
assert!(!Tool::should_list(&tool, &zero));
let mut some = ListToolsContext::default();
some.extensions.insert(AttachmentCount(3));
assert!(Tool::should_list(&tool, &some));
}
// ToolDyn blanket forwarding
#[test]
fn dyn_forwards_default() {
let tool: ArcTool = Arc::new(AlwaysTool);
assert!(tool.should_list(&ListToolsContext::default()));
}
#[test]
fn dyn_forwards_custom() {
let tool: ArcTool = Arc::new(NeedsCwdTool);
assert!(!tool.should_list(&ListToolsContext::default()));
let mut ctx = ListToolsContext::default();
ctx.extensions
.insert(Cwd(std::path::PathBuf::from("/home")));
assert!(tool.should_list(&ctx));
}
#[test]
fn arc_dyn_callable() {
let tool: Arc<dyn ToolDyn> = Arc::new(NeedsAttachmentTool);
let mut ctx = ListToolsContext::default();
ctx.extensions.insert(AttachmentCount(1));
assert!(tool.should_list(&ctx));
}
// ListToolsContext
#[test]
fn list_ctx_default_is_empty() {
let ctx = ListToolsContext::default();
assert!(ctx.extensions.is_empty());
}
#[test]
fn list_ctx_insert_and_get() {
let mut ctx = ListToolsContext::default();
ctx.extensions.insert(Cwd(std::path::PathBuf::from("/a")));
assert_eq!(
ctx.extensions.get::<Cwd>().unwrap().0,
std::path::PathBuf::from("/a")
);
}
#[test]
fn list_ctx_clone_is_independent() {
let mut ctx = ListToolsContext::default();
ctx.extensions.insert(AttachmentCount(5));
let mut copy = ctx.clone();
copy.extensions.remove::<AttachmentCount>();
assert!(ctx.extensions.contains::<AttachmentCount>());
assert!(!copy.extensions.contains::<AttachmentCount>());
}
// TypedExtensions standalone
#[test]
fn typed_extensions_insert_get_remove() {
let mut ext = xai_tool_runtime::TypedExtensions::new();
assert!(ext.is_empty());
ext.insert(42_u32);
assert_eq!(*ext.get::<u32>().unwrap(), 42);
ext.remove::<u32>();
assert!(ext.get::<u32>().is_none());
}

View file

@ -0,0 +1,174 @@
//! Default `Tool::execute` wrapping a blocking `Tool::run`.
use futures::StreamExt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use xai_tool_protocol::ToolId;
use xai_tool_runtime::{
Tool, ToolCallContext, ToolError, ToolErrorKind, ToolOutput, ToolStreamItem,
};
use xai_tool_types::ToolDescription;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct EchoArgs {
text: String,
}
#[derive(Debug, Serialize, PartialEq)]
struct EchoOutput {
text: String,
}
impl ToolOutput for EchoOutput {}
struct BlockingOk;
impl Tool for BlockingOk {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
ToolId::new("blocking_ok").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("blocking_ok", "ok")
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(EchoOutput { text: args.text })
}
}
struct BlockingErr;
impl Tool for BlockingErr {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
ToolId::new("blocking_err").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("blocking_err", "err")
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Err(ToolError::invalid_arguments(format!(
"rejected: {}",
args.text
)))
}
}
struct UnimplementedTool;
impl Tool for UnimplementedTool {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
ToolId::new("unimplemented_tool").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("unimplemented_tool", "neither")
}
}
#[tokio::test]
async fn blocking_ok_wraps_into_single_terminal() {
let tool = BlockingOk;
let mut stream = tool
.execute(
ToolCallContext::default(),
EchoArgs {
text: "hello".into(),
},
)
.await;
let first = stream.next().await.expect("expected one item");
assert!(first.is_terminal());
match first {
ToolStreamItem::Terminal(Ok(EchoOutput { text })) => assert_eq!(text, "hello"),
other => panic!("expected Terminal(Ok), got {other:?}"),
}
assert!(stream.next().await.is_none(), "stream should be exhausted");
}
#[tokio::test]
async fn blocking_err_wraps_into_single_terminal() {
let tool = BlockingErr;
let mut stream = tool
.execute(
ToolCallContext::default(),
EchoArgs {
text: "rejected".into(),
},
)
.await;
let first = stream.next().await.expect("expected one item");
match first {
ToolStreamItem::Terminal(Err(ref err)) if err.kind == ToolErrorKind::InvalidArguments => {
assert_eq!(err.detail, "rejected: rejected");
}
other => panic!("expected Terminal(Err(InvalidArguments)), got {other:?}"),
}
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn unimplemented_tool_returns_not_implemented_terminal() {
let tool = UnimplementedTool;
let item = tool
.execute(ToolCallContext::default(), EchoArgs { text: "x".into() })
.await
.next()
.await
.unwrap();
match item {
ToolStreamItem::Terminal(Err(ref err))
if err.kind == xai_tool_runtime::error::ToolErrorKind::NotImplemented =>
{
assert!(
err.detail.contains("run") && err.detail.contains("execute"),
"detail should mention both methods, got: {}",
err.detail
);
}
other => panic!("expected Terminal(Err(NotImplemented)), got {other:?}"),
}
}
#[tokio::test]
async fn run_takes_args_by_value() {
// The trait `run` consumes args; this would not compile if the
// signature accidentally borrowed.
let tool = BlockingOk;
let args = EchoArgs {
text: "consumed".into(),
};
let result = tool.run(ToolCallContext::default(), args).await.unwrap();
assert_eq!(result.text, "consumed");
}
#[tokio::test]
async fn execute_default_drains_in_one_pass() {
// A stream from the default impl should always have exactly one item.
let tool = BlockingOk;
let count = tool
.execute(ToolCallContext::default(), EchoArgs { text: "n".into() })
.await
.count()
.await;
assert_eq!(count, 1);
}

View file

@ -0,0 +1,492 @@
//! `ToolDyn` blanket impl + `ToolFamily` lookup.
//!
//! Covers the JSON-erased object-safe surface (`ToolDyn`) and the
//! variant-keyed family lookup (`ToolFamily`). Mirrors the toolbox
//! `GrokToolDyn` / `GrokToolFamily` tests but against the runtime's
//! typed `Tool` trait.
use std::sync::Arc;
use futures::StreamExt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use xai_tool_protocol::{ToolCapabilities, ToolId};
use xai_tool_runtime::{
ArcTool, ContentBlock, Tool, ToolCallContext, ToolDyn, ToolError, ToolErrorKind, ToolFamily,
ToolOutput, ToolProgress, ToolStream, ToolStreamItem, ToolVariant, with_progress,
};
use xai_tool_types::ToolDescription;
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("test tool ids are well-formed")
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct EchoArgs {
text: String,
}
#[derive(Debug, Serialize, PartialEq)]
struct EchoOutput {
text: String,
}
impl ToolOutput for EchoOutput {}
/// Blocking tool — exercises the default `Tool::execute` wrap-with-`run`
/// path through the `ToolDyn` blanket.
struct BlockingEcho;
impl Tool for BlockingEcho {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
tid("blocking_echo")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("blocking_echo", "echo the input text")
}
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities {
is_read_only: true,
..Default::default()
}
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(EchoOutput { text: args.text })
}
}
/// Streaming tool — exercises progress propagation through the blanket.
struct StreamingEcho;
impl Tool for StreamingEcho {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
tid("streaming_echo")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("streaming_echo", "stream then echo")
}
async fn execute(&self, _ctx: ToolCallContext, args: Self::Args) -> ToolStream<Self::Output> {
let progress = futures::stream::iter(vec![
ToolProgress::Text {
text: "tick".into(),
},
ToolProgress::Content {
blocks: vec![ContentBlock::Text {
text: "tock".into(),
}],
},
]);
with_progress(progress, async move { Ok(EchoOutput { text: args.text }) })
}
}
/// Output type that always fails to serialize. Drives the `Tool::Output ->
/// Value` re-encoding error path through the `ToolDyn` blanket.
struct Unencodable;
impl ToolOutput for Unencodable {}
impl Serialize for Unencodable {
fn serialize<S: serde::Serializer>(&self, _ser: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("intentionally unencodable"))
}
}
struct UnencodableTool;
impl Tool for UnencodableTool {
type Args = EchoArgs;
type Output = Unencodable;
fn id(&self) -> ToolId {
tid("unencodable")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("unencodable", "always returns a non-serializable output")
}
async fn run(
&self,
_ctx: ToolCallContext,
_args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(Unencodable)
}
}
// ── Tool with custom ToolOutput (non-empty) ──────────────────
/// Output that provides its own model-facing content blocks. The blanket
/// impl must forward these as-is rather than filling in the JSON fallback.
#[derive(Debug, Serialize)]
struct RichOutput {
value: u32,
blocks: Vec<ContentBlock>,
}
impl ToolOutput for RichOutput {
fn model_output(&self) -> Vec<ContentBlock> {
self.blocks.clone()
}
}
struct RichTool;
impl Tool for RichTool {
type Args = EchoArgs;
type Output = RichOutput;
fn id(&self) -> ToolId {
tid("rich")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("rich", "returns custom model output")
}
async fn run(
&self,
_ctx: ToolCallContext,
_args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(RichOutput {
value: 42,
blocks: vec![
ContentBlock::Text {
text: "summary".into(),
},
ContentBlock::Image {
mime_type: "image/png".into(),
data: "base64data".into(),
media_id: None,
filename: None,
path: None,
metadata: Default::default(),
},
],
})
}
}
#[tokio::test]
async fn tool_dyn_preserves_custom_model_output() {
let tool: ArcTool = Arc::new(RichTool);
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": "ignored"}))
.await;
let item = stream.next().await.expect("expected one item");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.tool_id, tid("rich"));
assert_eq!(
typed.value,
json!({"value": 42, "blocks": [
{"type": "text", "text": "summary"},
{"type": "image", "mime_type": "image/png", "data": "base64data"},
]})
);
// Custom model output preserved verbatim — no JSON fallback.
assert_eq!(typed.model_output.len(), 2);
assert_eq!(
typed.model_output[0],
ContentBlock::Text {
text: "summary".into(),
},
);
assert!(matches!(typed.model_output[1], ContentBlock::Image { .. }));
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn tool_dyn_delegates_id_description_capabilities() {
let tool: ArcTool = Arc::new(BlockingEcho);
assert_eq!(tool.id(), tid("blocking_echo"));
assert_eq!(
tool.description(&xai_tool_runtime::ListToolsContext::default())
.name,
"blocking_echo"
);
assert!(tool.capabilities().is_read_only);
}
#[tokio::test]
async fn tool_dyn_blanket_encodes_terminal_output() {
let tool: ArcTool = Arc::new(BlockingEcho);
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": "hi"}))
.await;
let item = stream.next().await.expect("expected one item");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.tool_id, tid("blocking_echo"));
assert_eq!(typed.value, json!({"text": "hi"}));
// EchoOutput uses the default ToolOutput which
// serialises self to a JSON text block (MCP-compliant).
assert_eq!(typed.model_output.len(), 1);
assert_eq!(
typed.model_output[0],
ContentBlock::Text {
text: r#"{"text":"hi"}"#.into(),
},
);
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn tool_dyn_blanket_passes_progress_through() {
let tool: ArcTool = Arc::new(StreamingEcho);
let items: Vec<_> = tool
.execute(ToolCallContext::default(), json!({"text": "done"}))
.await
.collect()
.await;
assert_eq!(items.len(), 3, "expected 2 progress + 1 terminal");
assert!(matches!(
items[0],
ToolStreamItem::Progress(ToolProgress::Text { .. }),
));
assert!(matches!(
items[1],
ToolStreamItem::Progress(ToolProgress::Content { .. }),
));
match &items[2] {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.tool_id, tid("streaming_echo"));
assert_eq!(typed.value, json!({"text": "done"}));
}
other => panic!("expected Terminal(Ok) at end, got {other:?}"),
}
}
#[tokio::test]
async fn tool_dyn_invalid_args_become_invalid_arguments_terminal() {
let tool: ArcTool = Arc::new(BlockingEcho);
// `text` is required and must be a string — `null` fails serde.
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": null}))
.await;
match stream.next().await.unwrap() {
ToolStreamItem::Terminal(Err(ref err)) if err.kind == ToolErrorKind::InvalidArguments => {
assert!(!err.detail.is_empty(), "detail should describe the failure");
}
other => panic!("expected Terminal(Err(InvalidArguments)), got {other:?}"),
}
assert!(stream.next().await.is_none(), "stream should be exhausted");
}
#[tokio::test]
async fn tool_dyn_unencodable_output_becomes_execution_terminal() {
let tool: ArcTool = Arc::new(UnencodableTool);
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": "anything"}))
.await;
match stream.next().await.unwrap() {
ToolStreamItem::Terminal(Err(ref err)) if err.kind == ToolErrorKind::Execution => {
assert!(
err.detail.contains("unencodable"),
"detail should mention the tool id, got: {}",
err.detail
);
}
other => panic!("expected Terminal(Err(Execution)), got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// ToolFamily
// ---------------------------------------------------------------------------
/// Backend-flavoured echo. Two variants share the `echo` tool id and only
/// differ in the prefix attached to the output text — enough to assert
/// the family routes lookups to distinct implementations.
struct PrefixedEcho {
prefix: &'static str,
}
impl Tool for PrefixedEcho {
type Args = EchoArgs;
type Output = EchoOutput;
fn id(&self) -> ToolId {
tid("echo")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("echo", "prefixed echo")
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(EchoOutput {
text: format!("{}{}", self.prefix, args.text),
})
}
}
const ALT_VARIANT: &str = "alt";
struct EchoFamily;
impl ToolFamily for EchoFamily {
fn id(&self) -> ToolId {
tid("echo")
}
fn get_tool(&self, variant: &ToolVariant) -> Option<ArcTool> {
match variant {
ToolVariant::Default => Some(Arc::new(PrefixedEcho { prefix: "default:" })),
ToolVariant::Variant(v) if v == ALT_VARIANT => {
Some(Arc::new(PrefixedEcho { prefix: "alt:" }))
}
ToolVariant::Variant(_) => None,
}
}
fn variants(&self) -> Vec<ToolVariant> {
vec![
ToolVariant::Default,
ToolVariant::Variant(ALT_VARIANT.into()),
]
}
}
#[tokio::test]
async fn tool_family_default_variant_routes_to_default_impl() {
let family = EchoFamily;
assert_eq!(family.id(), tid("echo"));
let tool = family
.get_tool(&ToolVariant::Default)
.expect("default variant must exist");
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": "x"}))
.await;
match stream.next().await.unwrap() {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.tool_id, tid("echo"));
assert_eq!(typed.value, json!({"text": "default:x"}));
}
other => panic!("expected default impl output, got {other:?}"),
}
}
#[tokio::test]
async fn tool_family_named_variant_routes_to_named_impl() {
let family = EchoFamily;
let tool = family
.get_tool(&ToolVariant::Variant(ALT_VARIANT.into()))
.expect("alt variant must exist");
let mut stream = tool
.execute(ToolCallContext::default(), json!({"text": "x"}))
.await;
match stream.next().await.unwrap() {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.tool_id, tid("echo"));
assert_eq!(typed.value, json!({"text": "alt:x"}));
}
other => panic!("expected alt impl output, got {other:?}"),
}
}
#[tokio::test]
async fn tool_family_unknown_variant_returns_none() {
let family = EchoFamily;
assert!(
family
.get_tool(&ToolVariant::Variant("nonexistent".into()))
.is_none()
);
}
#[tokio::test]
async fn tool_family_variants_lists_every_exposed_variant() {
let family = EchoFamily;
let variants = family.variants();
assert_eq!(variants.len(), 2);
assert!(variants.contains(&ToolVariant::Default));
assert!(variants.contains(&ToolVariant::Variant(ALT_VARIANT.into())));
}
#[tokio::test]
async fn tool_family_default_variant_name_defaults_to_none() {
let family = EchoFamily;
assert!(family.default_variant_name().is_none());
}
// ---------------------------------------------------------------------------
// Object safety / ergonomic checks
// ---------------------------------------------------------------------------
#[test]
fn tool_dyn_is_object_safe_in_arc_and_box() {
let _arc: Arc<dyn ToolDyn> = Arc::new(BlockingEcho);
let _boxed: Box<dyn ToolDyn> = Box::new(StreamingEcho);
}
#[test]
fn tool_family_is_object_safe_in_arc_and_box() {
let _arc: Arc<dyn ToolFamily> = Arc::new(EchoFamily);
let _boxed: Box<dyn ToolFamily> = Box::new(EchoFamily);
}
#[test]
fn arc_tool_alias_holds_heterogeneous_tools() {
// The whole point of `ArcTool` — many typed `Tool` impls collapse
// into one container shape via the blanket impl.
let tools: Vec<ArcTool> = vec![Arc::new(BlockingEcho), Arc::new(StreamingEcho)];
assert_eq!(tools.len(), 2);
let ids: Vec<_> = tools.iter().map(|t| t.id()).collect();
assert!(ids.contains(&tid("blocking_echo")));
assert!(ids.contains(&tid("streaming_echo")));
}
#[test]
fn tool_variant_round_trips_through_clone_and_eq() {
let a = ToolVariant::Variant("es".into());
let b = a.clone();
assert_eq!(a, b);
assert_ne!(ToolVariant::Default, ToolVariant::Variant("default".into()));
}
/// The blanket impl is what makes `ToolDyn` ergonomic. This compile-time
/// check makes sure a fresh `Tool` impl can be passed where `&dyn ToolDyn`
/// is expected without an explicit upcast.
fn _accepts_dyn(_: &dyn ToolDyn) {}
#[allow(dead_code)]
fn _compile_time_blanket_check() {
let tool = BlockingEcho;
_accepts_dyn(&tool);
let tool = StreamingEcho;
_accepts_dyn(&tool);
// The trait objects themselves must be `Send + Sync` so they can be
// shared across tasks without further bounds at the call site.
fn _is_send_sync<T: Send + Sync + ?Sized>() {}
_is_send_sync::<dyn ToolDyn>();
_is_send_sync::<dyn ToolFamily>();
}

View file

@ -0,0 +1,177 @@
//! Streaming `Tool::execute` overrides — interleaving Progress with a
//! single Terminal item.
use std::pin::Pin;
use futures::stream::{self, Stream, StreamExt};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use xai_tool_protocol::{StreamingSpec, ToolCapabilities, ToolId};
use xai_tool_runtime::{
ContentBlock, Tool, ToolCallContext, ToolError, ToolErrorKind, ToolOutput, ToolProgress,
ToolStream, ToolStreamItem, with_progress,
};
use xai_tool_types::ToolDescription;
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct EmptyArgs {}
#[derive(Debug, Serialize, PartialEq)]
struct UnitOutput {
pub value: u32,
}
impl ToolOutput for UnitOutput {}
struct StreamingOk;
impl Tool for StreamingOk {
type Args = EmptyArgs;
type Output = UnitOutput;
fn id(&self) -> ToolId {
ToolId::new("streaming_ok").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("streaming_ok", "ok")
}
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities {
streaming: Some(StreamingSpec {
subkind: "streaming_ok_chunk".to_owned(),
max_delta_bytes: None,
}),
..Default::default()
}
}
async fn execute(&self, _ctx: ToolCallContext, _args: Self::Args) -> ToolStream<UnitOutput> {
let progress: Pin<Box<dyn Stream<Item = ToolProgress> + Send>> =
Box::pin(stream::iter(vec![
ToolProgress::Text {
text: "first".into(),
},
ToolProgress::Text {
text: "second".into(),
},
ToolProgress::Content {
blocks: vec![ContentBlock::Text {
text: "third block".into(),
}],
},
]));
with_progress(progress, async move { Ok(UnitOutput { value: 7 }) })
}
}
struct StreamingErr;
impl Tool for StreamingErr {
type Args = EmptyArgs;
type Output = UnitOutput;
fn id(&self) -> ToolId {
ToolId::new("streaming_err").unwrap()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("streaming_err", "err")
}
async fn execute(&self, _ctx: ToolCallContext, _args: Self::Args) -> ToolStream<UnitOutput> {
let progress = stream::iter(vec![ToolProgress::Text {
text: "before terminal".into(),
}]);
with_progress(progress, async move {
Err(ToolError::custom("intentional_failure", "boom"))
})
}
}
#[tokio::test]
async fn streaming_ok_emits_progress_then_terminal() {
let tool = StreamingOk;
let ctx = ToolCallContext::default();
let mut items: Vec<_> = tool.execute(ctx, EmptyArgs {}).await.collect().await;
assert_eq!(items.len(), 4, "expected 3 progress + 1 terminal");
let terminal = items.pop().unwrap();
assert!(terminal.is_terminal(), "last item must be Terminal");
let earlier_terminals = items.iter().filter(|i| i.is_terminal()).count();
assert_eq!(earlier_terminals, 0, "Terminal must only appear last");
match terminal {
ToolStreamItem::Terminal(Ok(out)) => assert_eq!(out, UnitOutput { value: 7 }),
other => panic!("expected Terminal(Ok), got {other:?}"),
}
let progress_kinds: Vec<_> = items
.into_iter()
.map(|item| match item {
ToolStreamItem::Progress(p) => p,
ToolStreamItem::Terminal(_) => unreachable!(),
})
.collect();
assert_eq!(progress_kinds.len(), 3);
assert!(matches!(progress_kinds[0], ToolProgress::Text { .. }));
assert!(matches!(progress_kinds[2], ToolProgress::Content { .. }));
}
#[tokio::test]
async fn streaming_err_propagates_through_terminal() {
let tool = StreamingErr;
let ctx = ToolCallContext::default();
let items: Vec<_> = tool.execute(ctx, EmptyArgs {}).await.collect().await;
assert_eq!(items.len(), 2);
match &items[0] {
ToolStreamItem::Progress(ToolProgress::Text { text }) => {
assert_eq!(text, "before terminal");
}
other => panic!("expected Progress(Text), got {other:?}"),
}
match &items[1] {
ToolStreamItem::Terminal(Err(err)) if err.kind == ToolErrorKind::Custom => {
let code = err
.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(code, "intentional_failure");
assert_eq!(err.detail, "boom");
}
other => panic!("expected Terminal(Err(Custom)), got {other:?}"),
}
}
#[tokio::test]
async fn streaming_progress_count_is_independent_of_args() {
// Distinct invocations on the same tool produce the same shape.
let tool = StreamingOk;
for _ in 0..3 {
let count = tool
.execute(ToolCallContext::default(), EmptyArgs {})
.await
.count()
.await;
assert_eq!(count, 4);
}
}
#[tokio::test]
async fn empty_progress_still_yields_terminal() {
// Building `with_progress` on an empty stream still produces exactly
// one terminal item — the same shape `terminal_only` produces.
let progress = stream::iter(Vec::<ToolProgress>::new());
let mut stream = with_progress(progress, async move { Ok::<u32, ToolError>(99) });
let item = stream.next().await.unwrap();
match item {
ToolStreamItem::Terminal(Ok(v)) => assert_eq!(v, 99),
other => panic!("expected Terminal(Ok(99)), got {other:?}"),
}
assert!(stream.next().await.is_none());
}

View file

@ -0,0 +1,150 @@
//! `ToolDispatch` is intentionally object-safe so an impl can be
//! stored as `Box<dyn ToolDispatch>` (or `Arc<dyn ToolDispatch>`) for
//! shared dynamic dispatch. `Tool` is NOT object-safe — its associated
//! types make sense only via a typed-erasure adapter that lives downstream
//! of this crate.
use async_trait::async_trait;
use futures::StreamExt;
use serde_json::{Value, json};
use xai_tool_protocol::ToolId;
use xai_tool_runtime::{
ToolCallContext, ToolDispatch, ToolError, ToolErrorKind, ToolProgress, ToolStream,
ToolStreamItem, TypedToolOutput, terminal_only, with_progress,
};
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("test tool ids are well-formed")
}
struct EchoDispatch;
#[async_trait]
impl ToolDispatch for EchoDispatch {
async fn call(
&self,
tool_id: ToolId,
args: Value,
_ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
if tool_id.as_str() == "echo" {
terminal_only(Ok(TypedToolOutput::from_value(tool_id, args)))
} else {
terminal_only(Err(ToolError::not_found(
tool_id.clone(),
format!("tool '{}' not registered", tool_id),
)))
}
}
}
struct ProgressDispatch;
#[async_trait]
impl ToolDispatch for ProgressDispatch {
async fn call(
&self,
tool_id: ToolId,
args: Value,
_ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
let progress = futures::stream::iter(vec![
ToolProgress::Text {
text: "tick".into(),
},
ToolProgress::Text {
text: "tock".into(),
},
]);
let tid = tool_id.clone();
with_progress(progress, async move {
Ok(TypedToolOutput::from_value(tid, args))
})
}
}
/// Constructs a stream that ends without a `Terminal` item — drives the
/// `call_terminal` default-impl recovery path.
struct EmptyStreamDispatch;
#[async_trait]
impl ToolDispatch for EmptyStreamDispatch {
async fn call(
&self,
_tool_id: ToolId,
_args: Value,
_ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
Box::pin(futures::stream::empty())
}
}
#[tokio::test]
async fn dispatch_is_object_safe() {
let boxed: Box<dyn ToolDispatch> = Box::new(EchoDispatch);
let mut stream = boxed
.call(tid("echo"), json!({"k": "v"}), ToolCallContext::default())
.await;
let item = stream.next().await.unwrap();
match item {
ToolStreamItem::Terminal(Ok(typed)) => assert_eq!(typed.value, json!({"k": "v"})),
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn arc_dispatch_is_object_safe() {
let arc: std::sync::Arc<dyn ToolDispatch> = std::sync::Arc::new(EchoDispatch);
let result = arc
.call_terminal(tid("echo"), json!(42), ToolCallContext::default())
.await;
assert_eq!(result.unwrap().value, json!(42));
}
#[tokio::test]
async fn unknown_tool_returns_not_found() {
let dispatch = EchoDispatch;
let result = dispatch
.call_terminal(tid("missing"), json!(null), ToolCallContext::default())
.await;
match result {
Err(ref err) if err.kind == ToolErrorKind::NotFound => {
assert!(
err.detail.contains("missing"),
"detail should mention the tool id, got: {}",
err.detail
);
}
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn call_terminal_drops_progress_items() {
let dispatch = ProgressDispatch;
let result = dispatch
.call_terminal(tid("any"), json!("x"), ToolCallContext::default())
.await;
assert_eq!(result.unwrap().value, json!("x"));
}
#[tokio::test]
async fn call_terminal_surfaces_no_terminal_as_custom_error() {
let dispatch = EmptyStreamDispatch;
let result = dispatch
.call_terminal(tid("any"), json!(null), ToolCallContext::default())
.await;
match result {
Err(ref err) if err.kind == ToolErrorKind::Custom => {
let code = err
.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(code, "stream_no_terminal");
}
other => panic!("expected Custom(stream_no_terminal), got {other:?}"),
}
}