Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
263
crates/common/xai-tool-runtime/tests/context_extensions.rs
Normal file
263
crates/common/xai-tool-runtime/tests/context_extensions.rs
Normal 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);
|
||||
}
|
||||
226
crates/common/xai-tool-runtime/tests/error_conversion.rs
Normal file
226
crates/common/xai-tool-runtime/tests/error_conversion.rs
Normal 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());
|
||||
}
|
||||
403
crates/common/xai-tool-runtime/tests/notification_serde.rs
Normal file
403
crates/common/xai-tool-runtime/tests/notification_serde.rs
Normal 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}'));
|
||||
}
|
||||
103
crates/common/xai-tool-runtime/tests/search.rs
Normal file
103
crates/common/xai-tool-runtime/tests/search.rs
Normal 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);
|
||||
}
|
||||
177
crates/common/xai-tool-runtime/tests/should_list.rs
Normal file
177
crates/common/xai-tool-runtime/tests/should_list.rs
Normal 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());
|
||||
}
|
||||
174
crates/common/xai-tool-runtime/tests/tool_blocking.rs
Normal file
174
crates/common/xai-tool-runtime/tests/tool_blocking.rs
Normal 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);
|
||||
}
|
||||
492
crates/common/xai-tool-runtime/tests/tool_dyn.rs
Normal file
492
crates/common/xai-tool-runtime/tests/tool_dyn.rs
Normal 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>();
|
||||
}
|
||||
177
crates/common/xai-tool-runtime/tests/tool_streaming.rs
Normal file
177
crates/common/xai-tool-runtime/tests/tool_streaming.rs
Normal 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());
|
||||
}
|
||||
150
crates/common/xai-tool-runtime/tests/trait_object_safety.rs
Normal file
150
crates/common/xai-tool-runtime/tests/trait_object_safety.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue