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,25 @@
[package]
license = "Apache-2.0"
name = "xai-computer-hub-core"
version = "0.1.0"
edition.workspace = true
description = "Transport, ToolRegistry, and resolver abstractions for the xAI Computer Hub"
[dependencies]
async-trait = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
xai-tool-protocol = { workspace = true }
xai-tool-runtime = { workspace = true }
xai-tool-types = { workspace = true }
[dev-dependencies]
dashmap = { workspace = true }
schemars = { workspace = true }
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros", "test-util", "sync"] }
serde = { workspace = true, features = ["derive"] }
[lints]
workspace = true

View file

@ -0,0 +1,73 @@
//! `InnerDispatchForResolver` — an object-safe `ToolDispatch` that routes
//! through a `Weak<CompoundResolver>` bound to a single session.
//!
//! Tools that need to call other tools (the inner-dispatch pattern) ask
//! the runtime for an `Arc<dyn ToolDispatch>`. This adapter answers that
//! question with a resolver-backed implementation. Holding the resolver
//! by [`Weak`] lets the router own the resolver while inner-dispatch
//! handles created from the same resolver release naturally when the
//! router is torn down.
use std::sync::Weak;
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId};
use xai_tool_runtime::{
ToolCallContext, ToolDispatch, ToolError, ToolStream, TypedToolOutput, terminal_only,
};
use crate::resolver::CompoundResolver;
/// Resolver-backed `ToolDispatch` implementation.
///
/// The resolver is held by [`Weak`] so the inner-dispatch handle never
/// keeps the router alive past its natural lifetime — when the owning
/// router drops the resolver, in-flight inner calls fail cleanly with
/// [`ToolError::Custom`] keyed `computer_hub_dropped`.
///
/// Bound to a single [`SessionId`] at construction (rather than reading a
/// session from [`ToolCallContext`]) so the inner-dispatch path mirrors
/// the per-session lifetime of the outer router.
#[derive(Debug)]
pub struct InnerDispatchForResolver {
resolver: Weak<CompoundResolver>,
session_id: SessionId,
}
impl InnerDispatchForResolver {
/// Build an inner-dispatch handle bound to `session_id`, resolving
/// through `resolver`.
pub fn new(resolver: Weak<CompoundResolver>, session_id: SessionId) -> Self {
Self {
resolver,
session_id,
}
}
/// Borrow the bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl ToolDispatch for InnerDispatchForResolver {
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
let Some(resolver) = self.resolver.upgrade() else {
return terminal_only(Err(ToolError::custom(
"computer_hub_dropped",
"computer hub dropped before inner call could execute",
)));
};
resolver
.resolve_and_dispatch(&self.session_id, tool_id, args, ctx)
.await
}
}

View file

@ -0,0 +1,29 @@
//! xAI Computer Hub — transport + registry + resolver core.
//!
//! Object-safe abstractions used by every router build: a [`Transport`]
//! that authorises and dispatches calls, a [`ToolRegistry`] trait shared
//! by both storage planes, a [`CompoundResolver`] that applies the
//! local-shadows-remote rule, and the local + remote transports plus
//! inner-dispatch glue that sit on top.
#![forbid(unsafe_code)]
pub mod inner;
pub mod local;
pub mod registry;
pub mod remote;
pub mod resolver;
pub mod transport;
pub use inner::InnerDispatchForResolver;
pub use local::{LOCAL_INVOKE_SCOPE, LocalTransport};
pub use registry::{
ConnectionCleanupReport, SessionCleanupReport, ToolRegistry, ToolSessionBindOutcome,
ToolSessionUnbindOutcome,
};
pub use remote::{
ConnectionClient, RemoteToolProxy, RemoteTransport, decode_call_result, error_from_envelope,
is_workspace_unavailable, output_to_value, progress_from_frame, tool_error_from_wire,
};
pub use resolver::{CompoundResolver, ErasedTool, ResolvedTool, ToolHandle};
pub use transport::{Principal, Transport, TransportKind};

View file

@ -0,0 +1,77 @@
//! In-process transport that resolves through a [`CompoundResolver`].
//!
//! `LocalTransport` is bound to a single `(user_id, session_id)` at
//! construction. Authorisation returns a principal pre-populated with the
//! bound session and the `tool.invoke` scope; per-call dispatch resolves
//! against the bound session's view of the resolver.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId, UserId};
use xai_tool_runtime::{ToolCallContext, ToolError, ToolStream, TypedToolOutput};
use crate::resolver::CompoundResolver;
use crate::transport::{Principal, Transport, TransportKind};
/// The scope `LocalTransport::authorize` grants to its principal.
///
/// Hoisted so adapters that authorise principals through other paths
/// can match the local convention without restating the literal.
pub const LOCAL_INVOKE_SCOPE: &str = "tool.invoke";
/// Transport that dispatches against an in-process resolver.
#[derive(Debug)]
pub struct LocalTransport {
resolver: Arc<CompoundResolver>,
user_id: UserId,
session_id: SessionId,
}
impl LocalTransport {
/// Build a transport bound to `(user_id, session_id)` and resolving
/// through `resolver`.
pub fn new(resolver: Arc<CompoundResolver>, user_id: UserId, session_id: SessionId) -> Self {
Self {
resolver,
user_id,
session_id,
}
}
/// Bound user identity for this transport.
pub fn user_id(&self) -> &UserId {
&self.user_id
}
/// Bound session for this transport.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl Transport for LocalTransport {
fn kind(&self) -> TransportKind {
TransportKind::Local
}
async fn authorize(&self) -> Result<Principal, ToolError> {
Ok(Principal::new(self.user_id.clone())
.with_session(self.session_id.clone())
.with_scope(LOCAL_INVOKE_SCOPE))
}
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
self.resolver
.resolve_and_dispatch(&self.session_id, tool_id, args, ctx)
.await
}
}

View file

@ -0,0 +1,305 @@
//! Object-safe `ToolRegistry` trait shared by every storage plane.
//!
//! Two registry implementations are expected: one in-memory plane for
//! statically-registered local tools, and one connection-keyed plane fed by
//! incoming remote registrations. Both expose the same trait so the
//! router can compose them through [`crate::CompoundResolver`] without
//! caring which is which.
//!
//! Mutations are connection-scoped: each registered tool belongs to the
//! [`ConnectionId`] that introduced it. Per-tool session bindings live
//! alongside the tool's record and are mutated independently via
//! [`ToolRegistry::bind_tool_session`] / [`ToolRegistry::unbind_tool_session`].
//! Reads (`find_tool`, `list_tools`, `search`) remain session-scoped — the
//! router resolves a tool by `(session_id, tool_id)`, never by
//! connection id.
//!
//! The concrete in-memory implementation is intentionally **out of scope**
//! for this crate — it requires a concurrency story (sharded maps, an
//! actor, etc.) that belongs alongside the registry's collision matrix and
//! generation handling. Tests exercise the trait via per-test mock impls.
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, UserId,
};
use xai_tool_runtime::{SearchSnapshot, ServerSummary};
use xai_tool_types::ToolDescription;
use crate::resolver::ResolvedTool;
/// Outcome of a single [`ToolRegistry::bind_tool_session`] call.
///
/// This enum is the source of truth for storage outcomes; the wire enum
/// [`xai_tool_protocol::ToolSessionBindOutcome`] is a strict subset with
/// one extra wire-only variant. The two layers diverge deliberately:
///
/// - `Conflict` (cross-connection race on the `(session_id, tool_id)`
/// reverse-index slot) is registry-internal: the router lifts it to a
/// top-level `ServerError::ToolBindingConflict` (-32600) instead of
/// mirroring it to the wire ack, so the contended caller gets a
/// dedicated error code rather than overloading `UnknownTool`.
/// - The wire enum's `SessionNotBound` is router-injected by the
/// per-frame envelope pre-check (the connection's bound-session set
/// lives in router state, not the registry) and is never produced by
/// any registry call — so it has no counterpart here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSessionBindOutcome {
/// Added to the tool's session set.
Bound,
/// Session id was already in the tool's session set; no-op.
AlreadyBound,
/// No tool with the given id is registered against this connection.
UnknownTool,
/// Cross-connection conflict: another connection already holds the
/// `(session_id, tool_id)` reverse-index slot. The router lifts this
/// into a top-level `ToolBindingConflict` server error so the wire
/// reply uses the dedicated -32600 code instead of the structurally
/// dishonest `UnknownTool`. No registry state was mutated.
Conflict,
}
/// Outcome of a single [`ToolRegistry::unbind_tool_session`] call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSessionUnbindOutcome {
/// Removed from the tool's session set.
Unbound,
/// Session id was not in the tool's session set; no-op.
NotBound,
/// No tool with the given id is registered against this connection.
UnknownTool,
}
/// Aggregated summary of a connection-scoped cleanup pass.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConnectionCleanupReport {
/// Number of distinct `(connection, tool_id)` records dropped.
pub tools_dropped: usize,
/// Number of reverse-index `(session_id, tool_id)` rows cleaned up
/// across every session the dropped tools were bound to.
pub session_bindings_cleared: usize,
}
/// Aggregated summary of a session-scoped cleanup pass.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SessionCleanupReport {
/// Number of tools whose session set lost the unregistered session id.
pub tools_touched: usize,
/// Number of tools whose session set became empty after the
/// unregistration. The tool record itself is NOT removed — the owning
/// connection still owns it and may rebind via
/// [`ToolRegistry::bind_tool_session`] later.
pub tools_left_orphaned: usize,
}
/// Backend-agnostic registry of tools available within a router.
///
/// Methods are split into mutating (`async fn` — registration changes may
/// touch shared state and require coordination) and read-only views
/// (synchronous — implementations should answer from a consistent snapshot
/// without awaiting). The split mirrors how callers use the registry: the
/// hot path is the `find_tool` / `list_tools` view; mutations happen on the
/// rarer registration boundary.
#[async_trait]
pub trait ToolRegistry: Send + Sync + std::fmt::Debug {
/// Register a single tool against `connection_id`.
///
/// The outcome reports whether the registration created a new entry,
/// updated an existing one, was shadowed by a higher-priority
/// registration, or was rejected. `reg.sessions` may be empty — the
/// tool is registered but unreachable until
/// [`Self::bind_tool_session`] adds at least one session binding.
/// Implementations must enforce per-`(connection_id, tool_id)`
/// uniqueness within their plane.
async fn register_tool(
&self,
connection_id: ConnectionId,
reg: ToolRegistration,
) -> RegistrationOutcome;
/// Register a multi-tool batch from a single tool server against
/// `connection_id`.
///
/// Returns one [`RegistrationOutcome`] per tool in input order. Batch
/// semantics are best-effort: per-tool failures do not abort the rest
/// of the batch. The whole batch shares `reg.sessions` (which may be
/// empty).
async fn register_server(
&self,
connection_id: ConnectionId,
reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome>;
/// Drop the tool registered under `(connection_id, tool_id)`. Returns
/// `true` if a matching entry was removed, `false` if no such entry
/// existed. The tool is removed from every session it was bound to in
/// one shot — use [`Self::unbind_tool_session`] for per-session removal.
async fn unregister_tool(&self, connection_id: &ConnectionId, tool: &ToolId) -> bool;
/// Drop every tool registered by `connection_id` under `server_id`.
/// Returns the number of entries removed.
async fn unregister_server(&self, connection_id: &ConnectionId, server: &ServerId) -> usize;
/// Add `session_id` to the per-tool session set of
/// `(connection_id, tool_id)`. The caller (typically the WebSocket
/// router) is responsible for verifying that `session_id` is in the
/// connection's bound-session set before calling this method.
async fn bind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionBindOutcome;
/// Remove `session_id` from the per-tool session set of
/// `(connection_id, tool_id)`. Does not unregister the tool itself.
async fn unbind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionUnbindOutcome;
/// Drop every tool registered by `connection_id`. Used by the WebSocket
/// transport on disconnect cleanup. Returns counters describing how
/// much state was released.
async fn drop_connection(&self, connection_id: &ConnectionId) -> ConnectionCleanupReport;
/// Look up the active resolution for `(session, tool)`.
///
/// Returns `None` when no entry exists or when an entry exists but is
/// shadowed. A shadowed entry is never returned — the caller sees only
/// the active resolution.
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool>;
/// Enumerate every active tool description for `session`, filtered by
/// the requested presentation `mode`. Implementations decide how to
/// honour the mode (e.g. omit non-meta tools when `Concise` is set).
fn list_tools(&self, session: &SessionId, mode: &ToolDefinitionMode) -> Vec<ToolDescription>;
/// Enumerate active server summaries for `session`. Useful for
/// rendering connected-integrations system reminders.
fn list_servers(&self, session: &SessionId) -> Vec<ServerSummary>;
/// Run a search query against the registry's index for `session`.
/// `limit` caps the result count; the snapshot reports how many
/// matches were hidden by the cap.
fn search(&self, session: &SessionId, query: &str, limit: usize) -> SearchSnapshot;
/// Drop the binding to `session` from every tool that has it. The
/// affected tool records are NOT removed — their owning connection
/// retains them and may rebind via [`Self::bind_tool_session`]. Called
/// by the WebSocket transport when a session ends globally (no peer
/// connection still holds the binding) and by the connection actor
/// during per-disconnect cleanup.
async fn unregister_session(&self, session: &SessionId) -> SessionCleanupReport;
/// Helper: set of session ids currently bound to `(connection_id, tool_id)`.
/// Returns an empty set when the tool is not registered. Mainly used
/// by tests to assert per-tool session set invariants without leaning
/// on the reverse index.
fn tool_sessions(&self, connection_id: &ConnectionId, tool: &ToolId) -> HashSet<SessionId>;
/// All servers registered by this user across all connections.
fn list_servers_for_user(&self, user_id: &UserId) -> Vec<ServerRecord>;
/// Look up a server by its connection ID.
fn get_server_record(&self, connection_id: &ConnectionId) -> Option<ServerRecord>;
/// Look up only a server's id by its connection ID. Lighter than
/// [`Self::get_server_record`] for callers that need nothing else:
/// implementations should override the default to avoid deep-cloning
/// the whole record (notably its `metadata` JSON).
fn get_server_id(&self, connection_id: &ConnectionId) -> Option<ServerId> {
self.get_server_record(connection_id)
.map(|record| record.server_id)
}
}
/// Server identity captured at `register_server` time.
#[derive(Debug, Clone)]
pub struct ServerRecord {
pub connection_id: ConnectionId,
pub user_id: UserId,
pub server_id: ServerId,
pub description: String,
pub metadata: serde_json::Value,
pub registered_at: chrono::DateTime<chrono::Utc>,
/// Monotonic registration stamp ([`next_registration_seq`]) — the
/// stale-vs-revived discriminator for newest-wins (`registered_at` is display-only).
pub registration_seq: u64,
}
/// Process-global hybrid logical clock: per-process strictly-increasing (no ties,
/// immune to NTP step-back) and epoch-seeded so stamps also roughly order across
/// replicas — only while inter-replica clock skew stays within the revive window
/// (`tool_route_ttl_ms`); past that, TTL eviction, not seq order, is the backstop.
/// The recency key for bind newest-wins and strictly-older eviction.
static REGISTRATION_CLOCK: AtomicU64 = AtomicU64::new(0);
/// Issue the next monotonic registration stamp. See [`REGISTRATION_CLOCK`].
pub fn next_registration_seq() -> u64 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let candidate = now_ms << 10;
let mut prev = REGISTRATION_CLOCK.load(Ordering::Relaxed);
loop {
let next = candidate.max(prev + 1);
match REGISTRATION_CLOCK.compare_exchange_weak(
prev,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return next,
Err(actual) => prev = actual,
}
}
}
#[cfg(test)]
mod seq_tests {
use super::next_registration_seq;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[test]
fn next_registration_seq_is_monotonic_and_epoch_seeded_under_burst() {
let before_ms = now_ms();
let first = next_registration_seq();
let mut prev = first;
const N: u64 = 50_000;
for _ in 0..N {
let s = next_registration_seq();
assert!(s > prev, "must be strictly increasing: {prev} -> {s}");
prev = s;
}
let after_ms = now_ms();
assert!(
prev - first >= N,
"burst must advance by at least one per call: {first} -> {prev}",
);
let high = prev >> 10;
assert!(
high >= before_ms,
"high bits ({high}) must be epoch-seeded (>= {before_ms})",
);
assert!(
high <= after_ms + 1_000,
"high bits ({high}) must track wall clock (<= {after_ms} + slack)",
);
}
}

View file

@ -0,0 +1,541 @@
//! `ConnectionClient` abstraction, `RemoteToolProxy`, and
//! `RemoteTransport`.
//!
//! `ConnectionClient` is the thin contract a downstream WebSocket SDK (or
//! an in-test channel-backed mock) implements; this crate stays free of
//! tokio-runtime / tokio-tungstenite deps so callers can pick their own.
//!
//! `RemoteToolProxy` wraps a remote tool registration so it implements
//! [`ToolHandle`] — the router routes through the same handle
//! type for local and remote registrations. `RemoteTransport` is the
//! transport-side equivalent: it forwards arbitrary `(tool_id, args)`
//! pairs over a [`ConnectionClient`] without needing a per-tool handle.
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use async_trait::async_trait;
use futures::Stream;
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use serde_json::Value;
use tracing::warn;
use xai_tool_protocol::{
JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcVersion, Method,
ResponseOutcome, SessionId, ToolCallId, ToolCallParams, ToolCallProgressFrame, ToolCallResult,
ToolCapabilities, ToolErrorWire, ToolId, ToolOutputWire, UserId, WORKSPACE_UNAVAILABLE_SUBCODE,
};
use xai_tool_runtime::{
BehaviorVersion, ContentBlock, Cwd, ListToolsContext, ToolCallContext,
ToolChatCompletionResponse, ToolError, ToolErrorKind, ToolProgress, ToolStream, ToolStreamItem,
TypedToolOutput, terminal_only,
};
use xai_tool_types::ToolDescription;
use crate::resolver::ToolHandle;
use crate::transport::{Principal, Transport, TransportKind};
/// Object-safe contract for a connected remote endpoint.
///
/// Concrete implementations supply the wire transport — the Rust SDK uses
/// `tokio_tungstenite`; tests use channel-backed mocks. Implementations
/// are expected to:
///
/// - correlate request/response pairs by [`JsonRpcId`];
/// - deliver progress notifications matching `tool_call_id` to whichever
/// subscriber registered for them;
/// - surface transport-level disconnects as [`ToolError::NetworkError`].
#[async_trait]
pub trait ConnectionClient: Send + Sync + std::fmt::Debug {
/// Send a JSON-RPC request and await the matching response. Errors
/// signal a transport-level failure (write failed, connection closed
/// before the response arrived); a successful return carries the
/// response envelope verbatim, including method-level error outcomes.
async fn request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, ToolError>;
/// Subscribe to progress notifications for `tool_call_id`.
///
/// The returned stream closes when the call's terminal frame arrives,
/// when the connection drops, or when the caller drops the receiver.
/// Subscribers MUST be registered before the corresponding request is
/// sent — otherwise progress frames that arrive before subscription
/// is complete are lost.
async fn subscribe_progress(
&self,
tool_call_id: ToolCallId,
) -> BoxStream<'static, ToolCallProgressFrame>;
/// Send a one-way notification (no response expected). Useful for
/// hook frames such as cancel.
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), ToolError>;
}
/// Wraps a remote registration so it dispatches through a connection.
///
/// Identity, description, and capabilities come from the registration
/// snapshot held on the proxy; execution forwards a `tool_call_request`
/// over the connection and merges progress + terminal frames into a
/// single [`ToolStream`].
#[derive(Debug, Clone)]
pub struct RemoteToolProxy {
tool_id: ToolId,
session_id: SessionId,
description: ToolDescription,
capabilities: ToolCapabilities,
connection: Arc<dyn ConnectionClient>,
}
impl RemoteToolProxy {
/// Build a proxy bound to a single remote registration.
pub fn new(
tool_id: ToolId,
session_id: SessionId,
description: ToolDescription,
capabilities: ToolCapabilities,
connection: Arc<dyn ConnectionClient>,
) -> Self {
Self {
tool_id,
session_id,
description,
capabilities,
connection,
}
}
/// Bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl ToolHandle for RemoteToolProxy {
fn id(&self) -> ToolId {
self.tool_id.clone()
}
fn description(&self, _ctx: &ListToolsContext) -> ToolDescription {
self.description.clone()
}
fn capabilities(&self) -> ToolCapabilities {
self.capabilities.clone()
}
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
dispatch_via_connection(
Arc::clone(&self.connection),
self.tool_id.clone(),
self.session_id.clone(),
args,
ctx,
)
.await
}
}
/// Transport that forwards calls over a [`ConnectionClient`].
///
/// The transport is bound to a single `(user_id, session_id)` at
/// construction. Calls do not require a pre-built proxy — the transport
/// builds the request frame from the `tool_id` it is asked to dispatch.
#[derive(Debug)]
pub struct RemoteTransport {
connection: Arc<dyn ConnectionClient>,
session_id: SessionId,
user_id: UserId,
}
impl RemoteTransport {
/// Build a transport over `connection`, bound to `(user_id,
/// session_id)`.
pub fn new(
connection: Arc<dyn ConnectionClient>,
session_id: SessionId,
user_id: UserId,
) -> Self {
Self {
connection,
session_id,
user_id,
}
}
/// Bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
/// Bound user identifier.
pub fn user_id(&self) -> &UserId {
&self.user_id
}
}
#[async_trait]
impl Transport for RemoteTransport {
fn kind(&self) -> TransportKind {
TransportKind::Remote
}
async fn authorize(&self) -> Result<Principal, ToolError> {
Ok(Principal::new(self.user_id.clone()).with_session(self.session_id.clone()))
}
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
dispatch_via_connection(
Arc::clone(&self.connection),
tool_id,
self.session_id.clone(),
args,
ctx,
)
.await
}
}
/// Subscribe to progress for `ctx.call_id`, send the `tool_call_request`,
/// and return a stream interleaving progress frames with the eventual
/// terminal item.
///
/// Subscribing **before** sending is the contract that
/// [`ConnectionClient::subscribe_progress`] requires; doing so here keeps
/// individual transports / proxies from re-implementing the dance.
async fn dispatch_via_connection(
connection: Arc<dyn ConnectionClient>,
tool_id: ToolId,
session_id: SessionId,
arguments: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
let cwd = ctx
.extensions
.get::<Cwd>()
.map(|c| c.0.to_string_lossy().into_owned());
let behavior_version = ctx.extensions.get::<BehaviorVersion>().map(|v| v.0.clone());
let call_id = ctx.call_id;
// Subscribe BEFORE sending. The single remaining `call_id.clone()`
// is unavoidable: subscription needs an owned id and the same id has
// to land in the request params below.
let progress = connection.subscribe_progress(call_id.clone()).await;
let params = ToolCallParams {
tool_call_id: call_id,
tool_id,
arguments,
deadline_ms: None,
behavior_version,
cwd,
// The ctx `TraceContext` extension is receive-side state.
trace_context: None,
};
let request = JsonRpcRequest {
jsonrpc: JsonRpcVersion,
id: JsonRpcId::new_uuid_v7(),
session_id: Some(session_id),
method: Method::ToolCallRequest.as_wire_str().to_string(),
params: match serde_json::to_value(&params) {
Ok(v) => v,
Err(e) => {
return terminal_only(Err(ToolError::custom("request_encoding", e.to_string())));
}
},
};
// Build the response future without awaiting it here so progress and
// terminal can be polled concurrently from the returned stream.
let request_fut = Box::pin(async move { connection.request(request).await });
Box::pin(RequestStream {
tool_id: Some(params.tool_id),
progress,
request: Some(request_fut),
done: false,
})
}
/// Owned response future with `'static` lifetime so the stream can hold
/// it across polls.
type ResponseFuture = BoxFuture<'static, Result<JsonRpcResponse, ToolError>>;
/// Stream that interleaves wire-side progress frames with the eventual
/// JSON-RPC response, ending with exactly one terminal item.
struct RequestStream {
/// Consumed exactly once when the terminal is built.
tool_id: Option<ToolId>,
progress: BoxStream<'static, ToolCallProgressFrame>,
request: Option<ResponseFuture>,
done: bool,
}
impl Stream for RequestStream {
type Item = ToolStreamItem<TypedToolOutput>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.done {
return Poll::Ready(None);
}
// Poll the response first so the terminal short-circuits the
// moment it lands. Any progress frames that arrived alongside
// the response are dropped — once `Terminal` is emitted, `done`
// is set and the next poll returns `None` immediately without
// re-polling the progress stream. The router invariant is
// "`Progress* Terminal`, exactly one terminal"; dropping any
// post-terminal progress is what makes that invariant hold here.
if let Some(req_fut) = self.request.as_mut() {
match req_fut.as_mut().poll(cx) {
Poll::Ready(result) => {
self.done = true;
self.request = None;
let Some(tool_id) = self.tool_id.take() else {
return Poll::Ready(None);
};
let terminal = match result {
Ok(resp) => terminal_from_response(tool_id, resp),
Err(err) => Err(err),
};
return Poll::Ready(Some(ToolStreamItem::Terminal(terminal)));
}
Poll::Pending => {}
}
} else {
self.done = true;
return Poll::Ready(None);
}
// Poll the progress stream while the request is pending. Closing
// the progress stream is fine — the response future is still
// registered for wake-up.
match Pin::new(&mut self.progress).poll_next(cx) {
Poll::Ready(Some(frame)) => {
Poll::Ready(Some(ToolStreamItem::Progress(progress_from_frame(frame))))
}
Poll::Ready(None) | Poll::Pending => Poll::Pending,
}
}
}
/// Map a wire-side [`ToolCallProgressFrame`] into a runtime
/// [`ToolProgress`]. `kind` becomes the `Custom` subkind so callers can
/// dispatch on the producer-defined identifier without losing the body.
pub fn progress_from_frame(frame: ToolCallProgressFrame) -> ToolProgress {
ToolProgress::Custom {
subkind: frame.kind,
payload: frame.body,
}
}
/// Decode the response envelope into the terminal
/// `Result<TypedToolOutput, _>` the runtime expects.
fn terminal_from_response(
tool_id: ToolId,
resp: JsonRpcResponse,
) -> Result<TypedToolOutput, ToolError> {
match resp.outcome {
ResponseOutcome::Result(value) => decode_call_result(tool_id, value),
ResponseOutcome::Error(err) => Err(error_from_envelope(err)),
}
}
/// Decode a `tool_call_result` success body into the terminal
/// [`TypedToolOutput`]. Shared by the core remote proxy and the SDK
/// harness so both wire decoders reconstruct `chat_completion_output`
/// identically.
///
/// A body with a `tool_call_id` is decoded strictly (`response_decoding` on
/// failure), reconstructing `chat_completion_output` (an unparseable cco
/// degrades to `None`). A bare body — e.g. a hub-local tool's raw output —
/// passes through unchanged.
pub fn decode_call_result(tool_id: ToolId, value: Value) -> Result<TypedToolOutput, ToolError> {
if value.get("tool_call_id").is_none() {
return Ok(TypedToolOutput::from_value(tool_id, value));
}
let result: ToolCallResult = serde_json::from_value(value)
.map_err(|e| ToolError::custom("response_decoding", e.to_string()))?;
let chat_completion_output = result.chat_completion_output.and_then(|cco| {
serde_json::from_value::<ToolChatCompletionResponse>(cco)
.inspect_err(|e| {
warn!(tool_id = %tool_id, error = %e, "dropping unparseable chat_completion_output");
})
.ok()
});
let value = output_to_value(result.output);
Ok(TypedToolOutput::from_value(tool_id, value)
.with_chat_completion_output(chat_completion_output))
}
/// Project a wire [`ToolOutputWire`] into a JSON [`Value`].
///
/// Three shapes collapse to one runtime type:
/// - `Text` becomes a JSON string;
/// - `Json` is forwarded verbatim;
/// - `Mcp { blocks }` is re-serialised as `{ "blocks": [ContentBlock, ...] }`
/// so the same downstream decoder used for in-process content blocks
/// works without case-by-case adaptation.
pub fn output_to_value(output: ToolOutputWire) -> Value {
match output {
ToolOutputWire::Text(s) => Value::String(s),
ToolOutputWire::Json(v) => v,
ToolOutputWire::Mcp { blocks } => {
let runtime_blocks: Vec<ContentBlock> = blocks.into_iter().map(map_block).collect();
// `ContentBlock`'s derived `Serialize` impl never fails for any
// valid in-memory variant, but `to_value` is fallible at the
// type level; collapse a hypothetical failure to `Value::Null`
// before wrapping so this function stays total without an
// `unwrap`. The outer `json!` only sees a `Value` expression
// (which `to_value` round-trips infallibly), so the macro's
// hidden `to_value` call cannot panic here.
let blocks_value = serde_json::to_value(&runtime_blocks).unwrap_or(Value::Null);
serde_json::json!({ "blocks": blocks_value })
}
}
}
fn map_block(block: xai_tool_protocol::McpBlock) -> ContentBlock {
use xai_tool_protocol::McpBlock;
match block {
McpBlock::Text { text } => ContentBlock::Text { text },
McpBlock::Image { mime_type, data } => ContentBlock::Image {
mime_type,
data,
media_id: None,
filename: None,
path: None,
metadata: Default::default(),
},
McpBlock::Resource {
uri,
mime_type,
text,
} => ContentBlock::Resource {
uri,
mime_type,
text,
},
}
}
/// Decode a JSON-RPC error envelope into a [`ToolError`]. The envelope's
/// `data` field is expected to carry a serialised [`ToolErrorWire`] when
/// available; falls back to a [`ToolError::Custom`] keyed by the numeric
/// envelope code when the data shape is unknown.
pub fn error_from_envelope(err: xai_tool_protocol::JsonRpcError) -> ToolError {
if let Some(data) = err.data.clone()
&& let Ok(wire) = serde_json::from_value::<ToolErrorWire>(data)
{
return tool_error_from_wire(wire);
}
let mut e = ToolError::custom(format!("jsonrpc_{}", err.code), err.message);
if let Some(data) = err.data {
e = e.with_details(data);
}
e
}
/// Recognize the hub's `workspace_unavailable` error on an already-decoded
/// [`ToolError`]. Keys on `details["code"]` — the field that survives
/// `ToolError::custom` + `with_details` — not the numeric code or the wire
/// `Custom.subcode`.
pub fn is_workspace_unavailable(err: &ToolError) -> bool {
err.kind == ToolErrorKind::Custom
&& err
.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|v| v.as_str())
== Some(WORKSPACE_UNAVAILABLE_SUBCODE)
}
/// Map [`ToolErrorWire`] back into the runtime [`ToolError`]. The runtime
/// error variants are the source-of-truth taxonomy; the wire form is a
/// lossy projection onto stable codes for serialisation, so a few wire
/// variants land on [`ToolError::Custom`] keyed by their wire code
/// rather than a dedicated runtime variant.
pub fn tool_error_from_wire(wire: ToolErrorWire) -> ToolError {
match wire {
ToolErrorWire::InvalidArguments { message, details } => {
let e = ToolError::invalid_arguments(message);
match details {
Some(d) => e.with_details(d),
None => e,
}
}
ToolErrorWire::ToolNotFound { tool_id } => {
let detail = format!("tool not found: {tool_id}");
ToolError::not_found(tool_id, detail)
}
ToolErrorWire::PermissionDenied { reason } => ToolError::permission_denied(reason),
ToolErrorWire::Timeout {
tool_id,
elapsed_ms,
} => ToolError::new(
ToolErrorKind::Timeout,
format!("timed out after {elapsed_ms}ms"),
)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "elapsed_ms": elapsed_ms})),
ToolErrorWire::Cancelled { tool_id } => ToolError::cancelled(tool_id, "cancelled"),
ToolErrorWire::Execution { tool_id, message } => ToolError::execution(tool_id, message),
ToolErrorWire::BehaviorVersionUnsupported { tool_id, requested } => ToolError::new(
ToolErrorKind::BehaviorVersionUnsupported,
format!("behavior version {requested} not supported"),
)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "requested": requested})),
ToolErrorWire::RenderLimited {
tool_id,
card_id,
reason,
} => ToolError::new(ToolErrorKind::RenderLimited, reason)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "card_id": card_id})),
ToolErrorWire::TerminalError { tool_id, message } => {
ToolError::terminal_error(tool_id, message)
}
ToolErrorWire::Custom {
subcode,
message,
details,
} => {
let e = ToolError::custom(subcode, message);
match details {
Some(d) => e.with_details(d),
None => e,
}
}
ToolErrorWire::SessionMismatch => ToolError::custom("session_mismatch", "session mismatch"),
ToolErrorWire::TransportClosed { tool_id } => {
ToolError::network_error(format!("transport closed for {tool_id}"))
}
ToolErrorWire::UnsupportedProtocolVersion { supported } => ToolError::custom(
"unsupported_protocol_version",
format!("supported versions: {supported:?}"),
),
ToolErrorWire::PayloadTooLarge { bytes, limit } => ToolError::custom(
"payload_too_large",
format!("payload {bytes} bytes exceeds limit {limit}"),
),
ToolErrorWire::Internal { request_id, detail } => {
let e = ToolError::custom(
"internal_error",
detail.unwrap_or_else(|| "internal router error".to_owned()),
);
match request_id {
// Keep `code` alongside `request_id`: `with_details` replaces
// the `{"code": …}` object `ToolError::custom` installed.
Some(id) => e.with_details(
serde_json::json!({ "code": "internal_error", "request_id": id.as_str() }),
),
None => e,
}
}
}
}

View file

@ -0,0 +1,280 @@
//! `CompoundResolver` plus the `ResolvedTool` and `ToolHandle`
//! types it returns.
//!
//! `Tool` carries associated `Args` / `Output` types and is therefore not
//! object-safe. [`ToolHandle`] is the dyn-compatible projection used
//! by every router build: typed tools are wrapped via
//! [`ErasedTool::new`]; remote registrations expose
//! [`crate::RemoteToolProxy`] which implements [`ToolHandle`]
//! directly without an intermediate typed `Tool` impl.
use std::sync::Arc;
use async_trait::async_trait;
use futures::StreamExt;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolCapabilities, ToolId, ToolRegistration};
use xai_tool_runtime::{
ListToolsContext, Tool, ToolCallContext, ToolError, ToolOutput, ToolStream, ToolStreamItem,
TypedToolOutput, terminal_only,
};
use xai_tool_types::ToolDescription;
use crate::registry::ToolRegistry;
/// Active resolution returned by [`CompoundResolver::resolve`].
///
/// Variants share the same `tool` handle and `registration` shape; the
/// discriminant only tells callers whether the executing handle dispatches
/// in-process or forwards over a connection. Differentiating the variants
/// is useful for metrics, log tags, and the local-shadows-remote rule
/// applied when both planes register the same `tool_id`.
#[derive(Debug, Clone)]
pub enum ResolvedTool {
/// In-process tool resolved from the local registry.
Local {
/// Object-safe handle to the tool's `execute` entry point.
tool: Arc<dyn ToolHandle>,
/// Wire-shape registration record. Carries `tool_id`, the
/// schema-bearing description, capabilities, and ownership data.
registration: ToolRegistration,
},
/// Remote registration resolved through a connection-backed proxy.
Remote {
/// Object-safe handle whose `execute` forwards over the
/// owning connection.
proxy: Arc<dyn ToolHandle>,
/// Wire-shape registration record (same shape as the local
/// variant — both store the active registration so callers do not
/// have to round-trip the registry for description / capabilities).
registration: ToolRegistration,
},
}
impl ResolvedTool {
/// Borrow the registration record regardless of variant.
pub fn registration(&self) -> &ToolRegistration {
match self {
Self::Local { registration, .. } | Self::Remote { registration, .. } => registration,
}
}
/// Borrow the executing handle regardless of variant.
pub fn handle(&self) -> &Arc<dyn ToolHandle> {
match self {
Self::Local { tool, .. } => tool,
Self::Remote { proxy, .. } => proxy,
}
}
}
/// Object-safe projection of a registered tool.
///
/// The router only needs identity, description, capabilities, and a
/// JSON-typed `execute` entry point — exactly what this trait exposes.
/// Adapters that wrap a typed `Tool` impl get [`ErasedTool`] for free;
/// non-`Tool` handles (notably remote proxies) implement this trait
/// directly.
#[async_trait]
pub trait ToolHandle: Send + Sync + std::fmt::Debug {
/// Stable identity used by the router to route calls.
fn id(&self) -> ToolId;
/// Model-facing description of the tool's argument schema.
///
/// Receives the per-turn [`ListToolsContext`] so handles backed by a
/// typed [`Tool`] can produce context-aware descriptions at listing
/// time. Callers outside a listing turn pass
/// [`ListToolsContext::default`].
fn description(&self, ctx: &ListToolsContext) -> ToolDescription;
/// Per-tool capability flags.
fn capabilities(&self) -> ToolCapabilities;
/// Per-turn listing predicate.
fn should_list(&self, _ctx: &ListToolsContext) -> bool {
true
}
/// Streaming execution entry point.
///
/// Implementations encode the tool's typed `Output` to
/// [`serde_json::Value`] and surface argument-decoding failures as
/// [`ToolError::InvalidArguments`] within the terminal item.
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput>;
}
/// Type-erasing wrapper for any [`Tool`] implementation.
///
/// Decodes `args` into `T::Args`, drives `T::execute`, and re-encodes each
/// `T::Output` (terminal and progress items pass through unchanged
/// otherwise). The wrapper holds the inner tool by `Arc` so the same
/// underlying instance can back multiple registrations cheaply.
pub struct ErasedTool<T> {
inner: Arc<T>,
}
impl<T> ErasedTool<T> {
/// Wrap an `Arc<T>` for use as an [`ToolHandle`].
pub fn from_arc(inner: Arc<T>) -> Self {
Self { inner }
}
/// Wrap an owned tool, taking the `Arc` allocation internally.
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for ErasedTool<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErasedTool")
.field("inner", &self.inner)
.finish()
}
}
impl<T> Clone for ErasedTool<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
#[async_trait]
impl<T> ToolHandle for ErasedTool<T>
where
T: Tool + std::fmt::Debug + 'static,
T::Output: ToolOutput,
{
fn id(&self) -> ToolId {
self.inner.id()
}
fn description(&self, ctx: &ListToolsContext) -> ToolDescription {
self.inner.description(ctx)
}
fn capabilities(&self) -> ToolCapabilities {
self.inner.capabilities()
}
fn should_list(&self, ctx: &ListToolsContext) -> bool {
self.inner.should_list(ctx)
}
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
let typed_args: T::Args = match serde_json::from_value(args) {
Ok(a) => a,
Err(e) => {
return terminal_only(Err(ToolError::invalid_arguments(e.to_string())));
}
};
let tool_id = self.inner.id();
let stream = self.inner.execute(ctx, typed_args).await;
let mapped = stream.map(move |item| match item {
ToolStreamItem::Progress(p) => ToolStreamItem::Progress(p),
ToolStreamItem::Terminal(Ok(out)) => match serde_json::to_value(&out) {
Ok(value) => {
let custom = out.model_output();
let model_output = if custom.is_empty() {
xai_tool_runtime::extract_content_blocks(&value)
} else {
custom
};
let chat_completion_output = out.chat_completion_output();
ToolStreamItem::Terminal(Ok(TypedToolOutput {
tool_id: tool_id.clone(),
value,
model_output,
chat_completion_output,
}))
}
Err(e) => ToolStreamItem::Terminal(Err(ToolError::custom(
"output_encoding",
e.to_string(),
))),
},
ToolStreamItem::Terminal(Err(err)) => ToolStreamItem::Terminal(Err(err)),
});
Box::pin(mapped)
}
}
/// Compose a local-first lookup over one (`local_only`) or two
/// (`compound`) registries.
///
/// The lookup contract: `find_tool` is called on the local registry first;
/// only if it returns `None` is the remote registry consulted. Any local
/// registration shadows a same-id remote registration. Cross-session
/// lookups return `None` — the caller may surface this as a
/// [`ToolError::NotFound`] to keep ownership invisible to the requester.
#[derive(Debug)]
pub struct CompoundResolver {
local: Arc<dyn ToolRegistry>,
remote: Option<Arc<dyn ToolRegistry>>,
}
impl CompoundResolver {
/// Compose a resolver that consults a single local registry.
pub fn local_only(local: Arc<dyn ToolRegistry>) -> Self {
Self {
local,
remote: None,
}
}
/// Compose a resolver with both planes; `local` is consulted first.
pub fn compound(local: Arc<dyn ToolRegistry>, remote: Arc<dyn ToolRegistry>) -> Self {
Self {
local,
remote: Some(remote),
}
}
/// Borrow the local plane.
pub fn local(&self) -> &Arc<dyn ToolRegistry> {
&self.local
}
/// Borrow the optional remote plane.
pub fn remote(&self) -> Option<&Arc<dyn ToolRegistry>> {
self.remote.as_ref()
}
/// Resolve `(session, tool_id)` honouring the local-first rule.
pub fn resolve(&self, session: &SessionId, tool_id: &ToolId) -> Option<ResolvedTool> {
if let Some(hit) = self.local.find_tool(session, tool_id) {
return Some(hit);
}
self.remote
.as_ref()
.and_then(|r| r.find_tool(session, tool_id))
}
/// Resolve `(session, tool_id)` and dispatch through the active
/// handle, returning the tool's stream verbatim. Misses produce a
/// single-item terminal stream carrying [`ToolError::NotFound`].
///
/// Centralises the resolve-then-dispatch sequence so both the
/// transport-side `LocalTransport::call` and the inner-dispatch path
/// share one implementation: a future change to the miss-shape (or
/// to the dispatch contract) lands once.
pub async fn resolve_and_dispatch(
&self,
session: &SessionId,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
match self.resolve(session, &tool_id) {
Some(resolved) => resolved.handle().execute(ctx, args).await,
None => terminal_only(Err(ToolError::not_found(
tool_id.clone(),
format!("tool not found: {tool_id}"),
))),
}
}
}

View file

@ -0,0 +1,116 @@
//! Object-safe `Transport` trait plus the `Principal` value carried across
//! authorize/call boundaries.
//!
//! [`TransportKind`] is re-exported from [`xai_tool_protocol`] so the wire
//! and dispatch layers share one canonical enum and there is no duplicate
//! `Local` / `Remote` definition to keep in sync.
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId, UserId};
use xai_tool_runtime::{ToolCallContext, ToolError, ToolStream, TypedToolOutput};
pub use xai_tool_protocol::TransportKind;
/// Authenticated identity bound to a transport at handshake time.
///
/// The transport authorises **once** at connect; subsequent dispatch calls
/// carry no extra credentials. `session_ids` is plural because a JWT may
/// authorise more than one session (multi-tenant tooling sessions sharing
/// a single user identity); the router narrows by [`SessionId`] at the
/// per-call boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
/// Authenticated user identity.
pub user_id: UserId,
/// Sessions this principal is authorised to act on. Empty when the
/// transport authorises a user but has not yet bound a session
/// (e.g. a fresh harness connection that has not opened a session).
pub session_ids: Vec<SessionId>,
/// OAuth-style scopes granted to this principal, e.g. `"tool.invoke"`.
pub scopes: Vec<String>,
/// Token audiences claimed by the credential, e.g. the router's
/// expected `aud` values. Used by callers that need defence-in-depth
/// audience checks beyond what the transport already validated.
pub audiences: Vec<String>,
}
impl Principal {
/// Build a principal for `user_id` with no sessions, scopes, or
/// audiences. Use the `with_*` builders to populate the rest.
pub fn new(user_id: UserId) -> Self {
Self {
user_id,
session_ids: Vec::new(),
scopes: Vec::new(),
audiences: Vec::new(),
}
}
/// Append `session_id` to the authorised set.
pub fn with_session(mut self, session_id: SessionId) -> Self {
self.session_ids.push(session_id);
self
}
/// Append `scope` to the granted scopes.
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
/// Append `aud` to the token's audience list.
pub fn with_audience(mut self, aud: impl Into<String>) -> Self {
self.audiences.push(aud.into());
self
}
/// Whether `scope` is present in the granted scopes.
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
/// Whether `session_id` is in the principal's authorised session set.
pub fn authorizes_session(&self, session_id: &SessionId) -> bool {
self.session_ids.iter().any(|s| s == session_id)
}
}
/// Object-safe transport for dispatching tool calls.
///
/// Implementations come in two flavours: [`TransportKind::Local`] resolves
/// against an in-process registry, while [`TransportKind::Remote`] forwards
/// a `tool_call_request` over a [`crate::ConnectionClient`].
#[async_trait]
pub trait Transport: Send + Sync + std::fmt::Debug {
/// Whether the underlying transport is local (in-process) or remote
/// (forwarded over a connection).
fn kind(&self) -> TransportKind;
/// One-time authorisation handshake.
///
/// Local transports return a principal derived from the bound OS user
/// (or whatever ambient identity the host process provides). Remote
/// transports return the principal extracted from a validated
/// credential. Subsequent [`Self::call`] invocations reuse this
/// principal — the router never re-authorises per call.
async fn authorize(&self) -> Result<Principal, ToolError>;
/// Dispatch a tool call.
///
/// The returned [`ToolStream`] follows the runtime invariant: zero or
/// more `Progress` items followed by exactly one `Terminal`. A
/// not-found result is reported as a single-item terminal stream
/// carrying [`ToolError::NotFound`]; transport-level disconnects
/// surface as [`ToolError::NetworkError`].
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput>;
}

View file

@ -0,0 +1,335 @@
//! `CompoundResolver` and `ResolvedTool` coverage. Exercises local-only,
//! local-shadows-remote, remote-fallback, and cross-session scenarios.
use std::sync::Arc;
use dashmap::DashMap;
use async_trait::async_trait;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use xai_computer_hub_core::{
CompoundResolver, ConnectionCleanupReport, ErasedTool, ResolvedTool, SessionCleanupReport,
ToolHandle, ToolRegistry, ToolSessionBindOutcome, ToolSessionUnbindOutcome,
};
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, TransportKind, UserId,
};
use xai_tool_runtime::{
SearchSnapshot, ServerSummary, Tool, ToolCallContext, ToolError, ToolStreamItem,
};
use xai_tool_types::ToolDescription;
#[derive(Debug, Default, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct EmptyArgs {}
#[derive(Debug)]
struct StubTool {
id: ToolId,
}
impl Tool for StubTool {
type Args = EmptyArgs;
type Output = serde_json::Value;
fn id(&self) -> ToolId {
self.id.clone()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new(self.id.as_str(), format!("stub for {}", self.id))
}
async fn run(
&self,
_ctx: ToolCallContext,
_args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(serde_json::json!({"id": self.id.as_str()}))
}
}
#[derive(Debug)]
struct PlaneRegistry {
// Set once at construction; `TransportKind` is `Copy` so a direct
// field is the obvious choice — no interior mutability required.
transport_kind: TransportKind,
entries: DashMap<(SessionId, ToolId), ToolRegistration>,
handles: DashMap<ToolId, Arc<dyn ToolHandle>>,
}
impl PlaneRegistry {
fn new(kind: TransportKind) -> Self {
Self {
transport_kind: kind,
entries: DashMap::new(),
handles: DashMap::new(),
}
}
fn install(&self, session: &SessionId, id: &ToolId) {
let reg = ToolRegistration {
tool_id: id.clone(),
sessions: Some(vec![session.clone()]),
user_id: UserId::new("alice").expect("user id"),
server_id: None,
description: ToolDescription::new(id.as_str(), format!("stub for {id}")),
input_schema: None,
capabilities: None,
notification_schemas: None,
transport_kind: self.transport_kind,
if_match_generation: None,
metadata: None,
};
self.entries.insert((session.clone(), id.clone()), reg);
self.handles.insert(
id.clone(),
Arc::new(ErasedTool::new(StubTool { id: id.clone() })),
);
}
}
#[async_trait]
impl ToolRegistry for PlaneRegistry {
async fn register_tool(
&self,
_connection_id: ConnectionId,
_reg: ToolRegistration,
) -> RegistrationOutcome {
unreachable!("resolver tests pre-populate via install()")
}
async fn register_server(
&self,
_connection_id: ConnectionId,
_reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome> {
unreachable!()
}
async fn unregister_tool(&self, _connection_id: &ConnectionId, _tool: &ToolId) -> bool {
unreachable!()
}
async fn unregister_server(&self, _connection_id: &ConnectionId, _server: &ServerId) -> usize {
unreachable!()
}
async fn bind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionBindOutcome {
unreachable!()
}
async fn unbind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionUnbindOutcome {
unreachable!()
}
async fn drop_connection(&self, _connection_id: &ConnectionId) -> ConnectionCleanupReport {
ConnectionCleanupReport::default()
}
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool> {
let registration = self
.entries
.get(&(session.clone(), tool.clone()))?
.value()
.clone();
let handle = self.handles.get(tool)?.value().clone();
match registration.transport_kind {
TransportKind::Local => Some(ResolvedTool::Local {
tool: handle,
registration,
}),
TransportKind::Remote => Some(ResolvedTool::Remote {
proxy: handle,
registration,
}),
}
}
fn list_tools(&self, _session: &SessionId, _mode: &ToolDefinitionMode) -> Vec<ToolDescription> {
vec![]
}
fn list_servers(&self, _session: &SessionId) -> Vec<ServerSummary> {
vec![]
}
fn search(&self, _session: &SessionId, _query: &str, _limit: usize) -> SearchSnapshot {
SearchSnapshot {
results: vec![],
total_hidden_tools: 0,
is_ready: true,
}
}
async fn unregister_session(&self, _session: &SessionId) -> SessionCleanupReport {
SessionCleanupReport::default()
}
fn tool_sessions(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
) -> std::collections::HashSet<SessionId> {
std::collections::HashSet::new()
}
fn list_servers_for_user(
&self,
_user_id: &xai_tool_protocol::UserId,
) -> Vec<xai_computer_hub_core::registry::ServerRecord> {
Vec::new()
}
fn get_server_record(
&self,
_connection_id: &ConnectionId,
) -> Option<xai_computer_hub_core::registry::ServerRecord> {
None
}
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("tool id")
}
#[tokio::test]
async fn local_only_resolves_local_hits() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
local.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
match resolver.resolve(&sid("sess-1"), &tid("foo")) {
Some(ResolvedTool::Local { registration, .. }) => {
assert_eq!(registration.tool_id, tid("foo"));
}
other => panic!("expected Local, got {other:?}"),
}
}
#[tokio::test]
async fn local_only_returns_none_for_unknown() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
assert!(resolver.resolve(&sid("sess-1"), &tid("missing")).is_none());
}
#[tokio::test]
async fn compound_falls_through_to_remote_when_local_misses() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
let remote = Arc::new(PlaneRegistry::new(TransportKind::Remote));
remote.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::compound(
local as Arc<dyn ToolRegistry>,
remote as Arc<dyn ToolRegistry>,
);
match resolver.resolve(&sid("sess-1"), &tid("foo")) {
Some(ResolvedTool::Remote { registration, .. }) => {
assert_eq!(registration.tool_id, tid("foo"));
assert_eq!(registration.transport_kind, TransportKind::Remote);
}
other => panic!("expected Remote, got {other:?}"),
}
}
#[tokio::test]
async fn local_shadows_same_id_remote() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
local.install(&sid("sess-1"), &tid("foo"));
let remote = Arc::new(PlaneRegistry::new(TransportKind::Remote));
remote.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::compound(
local as Arc<dyn ToolRegistry>,
remote as Arc<dyn ToolRegistry>,
);
match resolver.resolve(&sid("sess-1"), &tid("foo")) {
Some(ResolvedTool::Local { registration, .. }) => {
assert_eq!(registration.transport_kind, TransportKind::Local);
}
other => panic!("expected local resolution to shadow remote, got {other:?}"),
}
}
#[tokio::test]
async fn cross_session_lookup_returns_none() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
local.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
assert!(resolver.resolve(&sid("sess-other"), &tid("foo")).is_none());
}
#[tokio::test]
async fn compound_returns_none_when_neither_plane_holds_id() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
let remote = Arc::new(PlaneRegistry::new(TransportKind::Remote));
let resolver = CompoundResolver::compound(
local as Arc<dyn ToolRegistry>,
remote as Arc<dyn ToolRegistry>,
);
assert!(resolver.resolve(&sid("sess-1"), &tid("foo")).is_none());
}
#[tokio::test]
async fn resolved_tool_helpers_borrow_active_handle_and_registration() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
local.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
let resolved = resolver.resolve(&sid("sess-1"), &tid("foo")).expect("hit");
assert_eq!(resolved.registration().tool_id, tid("foo"));
assert_eq!(resolved.handle().id(), tid("foo"));
}
#[tokio::test]
async fn resolve_and_dispatch_drives_the_resolved_handle() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
local.install(&sid("sess-1"), &tid("foo"));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
let mut stream = resolver
.resolve_and_dispatch(
&sid("sess-1"),
tid("foo"),
serde_json::json!({}),
ToolCallContext::default(),
)
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.value, serde_json::json!({"id": "foo"}));
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn resolve_and_dispatch_misses_yield_terminal_not_found() {
let local = Arc::new(PlaneRegistry::new(TransportKind::Local));
let resolver = CompoundResolver::local_only(local as Arc<dyn ToolRegistry>);
let mut stream = resolver
.resolve_and_dispatch(
&sid("sess-1"),
tid("missing"),
serde_json::json!(null),
ToolCallContext::default(),
)
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::NotFound =>
{
assert!(
e.detail.contains("missing"),
"detail should mention tool id: {}",
e.detail
);
}
other => panic!("expected Terminal(Err(NotFound)), got {other:?}"),
}
}

View file

@ -0,0 +1,317 @@
//! `InnerDispatchForResolver` coverage. Verifies the cycle-safe `Weak`
//! resolver semantics and the session-bound resolution path.
use std::sync::Arc;
use dashmap::DashMap;
use async_trait::async_trait;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use xai_computer_hub_core::{
CompoundResolver, ConnectionCleanupReport, ErasedTool, InnerDispatchForResolver, ResolvedTool,
SessionCleanupReport, ToolHandle, ToolRegistry, ToolSessionBindOutcome,
ToolSessionUnbindOutcome,
};
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, TransportKind, UserId,
};
use xai_tool_runtime::{
SearchSnapshot, ServerSummary, Tool, ToolCallContext, ToolDispatch, ToolError, ToolStreamItem,
};
use xai_tool_types::ToolDescription;
#[derive(Debug, Default, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct EchoArgs {
payload: String,
}
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
type Args = EchoArgs;
type Output = serde_json::Value;
fn id(&self) -> ToolId {
ToolId::new("echo").expect("tool id")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("echo", "Echoes its input.")
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(serde_json::json!({"echoed": args.payload}))
}
}
type RegistryEntry = (ToolRegistration, Arc<dyn ToolHandle>);
#[derive(Debug, Default)]
struct InMemRegistry {
entries: DashMap<(SessionId, ToolId), RegistryEntry>,
}
impl InMemRegistry {
fn install(&self, session: SessionId, tool: ToolId, handle: Arc<dyn ToolHandle>) {
let registration = ToolRegistration {
tool_id: tool.clone(),
sessions: Some(vec![session.clone()]),
user_id: UserId::new("alice").expect("user id"),
server_id: None,
description: handle.description(&xai_tool_runtime::ListToolsContext::default()),
input_schema: None,
capabilities: Some(handle.capabilities()),
notification_schemas: None,
transport_kind: TransportKind::Local,
if_match_generation: None,
metadata: None,
};
self.entries.insert((session, tool), (registration, handle));
}
}
#[async_trait]
impl ToolRegistry for InMemRegistry {
async fn register_tool(
&self,
_connection_id: ConnectionId,
_reg: ToolRegistration,
) -> RegistrationOutcome {
unreachable!()
}
async fn register_server(
&self,
_connection_id: ConnectionId,
_reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome> {
unreachable!()
}
async fn unregister_tool(&self, _connection_id: &ConnectionId, _tool: &ToolId) -> bool {
unreachable!()
}
async fn unregister_server(&self, _connection_id: &ConnectionId, _server: &ServerId) -> usize {
unreachable!()
}
async fn bind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionBindOutcome {
unreachable!()
}
async fn unbind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionUnbindOutcome {
unreachable!()
}
async fn drop_connection(&self, _connection_id: &ConnectionId) -> ConnectionCleanupReport {
ConnectionCleanupReport::default()
}
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool> {
let (registration, handle) = self
.entries
.get(&(session.clone(), tool.clone()))?
.value()
.clone();
Some(ResolvedTool::Local {
tool: handle,
registration,
})
}
fn list_tools(&self, _session: &SessionId, _mode: &ToolDefinitionMode) -> Vec<ToolDescription> {
vec![]
}
fn list_servers(&self, _session: &SessionId) -> Vec<ServerSummary> {
vec![]
}
fn search(&self, _session: &SessionId, _query: &str, _limit: usize) -> SearchSnapshot {
SearchSnapshot {
results: vec![],
total_hidden_tools: 0,
is_ready: true,
}
}
async fn unregister_session(&self, _session: &SessionId) -> SessionCleanupReport {
SessionCleanupReport::default()
}
fn tool_sessions(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
) -> std::collections::HashSet<SessionId> {
std::collections::HashSet::new()
}
fn list_servers_for_user(
&self,
_user_id: &xai_tool_protocol::UserId,
) -> Vec<xai_computer_hub_core::registry::ServerRecord> {
Vec::new()
}
fn get_server_record(
&self,
_connection_id: &ConnectionId,
) -> Option<xai_computer_hub_core::registry::ServerRecord> {
None
}
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("tool id")
}
#[tokio::test]
async fn inner_dispatch_resolves_through_bound_session() {
let registry = Arc::new(InMemRegistry::default());
registry.install(
sid("sess-1"),
tid("echo"),
Arc::new(ErasedTool::new(EchoTool)),
);
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let inner = InnerDispatchForResolver::new(Arc::downgrade(&resolver), sid("sess-1"));
assert_eq!(inner.session_id(), &sid("sess-1"));
let result = inner
.call_terminal(
tid("echo"),
serde_json::json!({"payload": "x"}),
ToolCallContext::default(),
)
.await
.expect("terminal ok");
assert_eq!(result.value, serde_json::json!({"echoed": "x"}));
}
#[tokio::test]
async fn inner_dispatch_returns_not_found_when_tool_absent() {
let registry = Arc::new(InMemRegistry::default());
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let inner = InnerDispatchForResolver::new(Arc::downgrade(&resolver), sid("sess-1"));
let mut stream = inner
.call(
tid("ghost"),
serde_json::json!(null),
ToolCallContext::default(),
)
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::NotFound =>
{
assert!(
e.detail.contains("ghost"),
"detail should mention tool id: {}",
e.detail
);
}
other => panic!("expected Terminal(NotFound), got {other:?}"),
}
}
#[tokio::test]
async fn inner_dispatch_uses_bound_session_not_context_session() {
// Even if the context were to carry a different session, the inner
// dispatch handle resolves against its construction-time session.
let registry = Arc::new(InMemRegistry::default());
registry.install(
sid("sess-A"),
tid("echo"),
Arc::new(ErasedTool::new(EchoTool)),
);
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let inner = InnerDispatchForResolver::new(Arc::downgrade(&resolver), sid("sess-B"));
let mut stream = inner
.call(
tid("echo"),
serde_json::json!({"payload": "x"}),
ToolCallContext::default(),
)
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::NotFound => {}
other => panic!("session-A registration must not be visible from session-B, got {other:?}"),
}
}
#[tokio::test]
async fn inner_dispatch_after_resolver_drop_returns_computer_hub_dropped() {
let registry = Arc::new(InMemRegistry::default());
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let weak = Arc::downgrade(&resolver);
let inner = InnerDispatchForResolver::new(weak, sid("sess-1"));
drop(resolver);
let mut stream = inner
.call(
tid("echo"),
serde_json::json!(null),
ToolCallContext::default(),
)
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::Custom =>
{
assert!(
e.detail.contains("computer_hub_dropped")
|| e.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|c| c.as_str())
== Some("computer_hub_dropped"),
"expected computer_hub_dropped code, got: {:?}",
e
);
}
other => panic!("expected Terminal(Custom(computer_hub_dropped)), got {other:?}"),
}
}
#[tokio::test]
async fn inner_dispatch_implements_object_safe_tool_dispatch() {
let registry = Arc::new(InMemRegistry::default());
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let inner: Arc<dyn ToolDispatch> = Arc::new(InnerDispatchForResolver::new(
Arc::downgrade(&resolver),
sid("sess-1"),
));
let result = inner
.call_terminal(
tid("ghost"),
serde_json::json!(null),
ToolCallContext::default(),
)
.await;
assert!(matches!(result, Err(ref e) if e.kind == xai_tool_runtime::ToolErrorKind::NotFound));
}

View file

@ -0,0 +1,65 @@
//! Decode-side coverage for `ToolErrorWire::Internal`'s optional `detail`:
//! a populated detail must become the reconstructed `ToolError`'s message,
//! and its absence (frames from older peers) must fall back to the historic
//! constant.
use serde_json::json;
use xai_computer_hub_core::{error_from_envelope, tool_error_from_wire};
use xai_tool_protocol::{JsonRpcError, RequestId, ToolErrorWire};
use xai_tool_runtime::ToolErrorKind;
#[test]
fn internal_with_detail_reconstructs_the_wire_detail() {
let err = tool_error_from_wire(ToolErrorWire::Internal {
request_id: None,
detail: Some("cross-instance tool.call timed out".to_owned()),
});
assert_eq!(err.kind, ToolErrorKind::Custom);
assert_eq!(err.detail, "cross-instance tool.call timed out");
// The `internal_error` code survives so callers can still classify it.
assert_eq!(
err.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|v| v.as_str()),
Some("internal_error"),
);
}
#[test]
fn internal_without_detail_falls_back_to_the_historic_constant() {
let err = tool_error_from_wire(ToolErrorWire::Internal {
request_id: None,
detail: None,
});
assert_eq!(err.kind, ToolErrorKind::Custom);
assert_eq!(err.detail, "internal router error");
}
#[test]
fn internal_with_request_id_keeps_both_code_and_request_id() {
let err = tool_error_from_wire(ToolErrorWire::Internal {
request_id: Some(RequestId::new("req-7").unwrap()),
detail: Some("relay publish failed".to_owned()),
});
assert_eq!(err.detail, "relay publish failed");
let details = err.details.expect("details present");
assert_eq!(details["code"], json!("internal_error"));
assert_eq!(details["request_id"], json!("req-7"));
}
#[test]
fn envelope_with_internal_data_prefers_data_detail_over_message() {
// The hub's `-32000 "internal error"` envelope keeps its constant message;
// the harness must read the cause from `error.data`, not the message.
let err = error_from_envelope(JsonRpcError {
code: -32000,
message: "internal error".to_owned(),
data: Some(json!({
"code": "internal_error",
"detail": "cross-instance call cancelled",
})),
});
assert_eq!(err.kind, ToolErrorKind::Custom);
assert_eq!(err.detail, "cross-instance call cancelled");
}

View file

@ -0,0 +1,367 @@
//! `LocalTransport` end-to-end coverage. Verifies that the transport
//! resolves through the bound resolver, drives both blocking and
//! streaming tools, and surfaces missing tools as `Terminal(NotFound)`.
use std::sync::Arc;
use dashmap::DashMap;
use async_trait::async_trait;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use xai_computer_hub_core::{
CompoundResolver, ConnectionCleanupReport, ErasedTool, LocalTransport, ResolvedTool,
SessionCleanupReport, ToolHandle, ToolRegistry, ToolSessionBindOutcome,
ToolSessionUnbindOutcome, Transport, TransportKind,
};
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, TransportKind as WireTransportKind, UserId,
};
use xai_tool_runtime::{
SearchSnapshot, ServerSummary, Tool, ToolCallContext, ToolError, ToolProgress, ToolStream,
ToolStreamItem, terminal_only, with_progress,
};
use xai_tool_types::ToolDescription;
#[derive(Debug, Default, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct EchoArgs {
payload: String,
}
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
type Args = EchoArgs;
type Output = serde_json::Value;
fn id(&self) -> ToolId {
ToolId::new("echo").expect("tool id")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("echo", "Echoes its input.")
}
async fn run(
&self,
_ctx: ToolCallContext,
args: Self::Args,
) -> Result<Self::Output, ToolError> {
Ok(serde_json::json!({ "echoed": args.payload }))
}
}
#[derive(Debug)]
struct StreamerTool;
impl Tool for StreamerTool {
type Args = EchoArgs;
type Output = serde_json::Value;
fn id(&self) -> ToolId {
ToolId::new("streamer").expect("tool id")
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new("streamer", "Emits three progress chunks.")
}
async fn execute(&self, _ctx: ToolCallContext, args: Self::Args) -> ToolStream<Self::Output> {
let chunks = futures::stream::iter(vec![
ToolProgress::Text {
text: "tick".to_string(),
},
ToolProgress::Text {
text: "tock".to_string(),
},
ToolProgress::Text {
text: "boom".to_string(),
},
]);
with_progress(chunks, async move {
Ok(serde_json::json!({ "echoed": args.payload }))
})
}
}
type RegistryEntry = (ToolRegistration, Arc<dyn ToolHandle>);
#[derive(Debug, Default)]
struct InMemRegistry {
entries: DashMap<(SessionId, ToolId), RegistryEntry>,
}
impl InMemRegistry {
fn install(&self, session: SessionId, tool_id: ToolId, handle: Arc<dyn ToolHandle>) {
let registration = ToolRegistration {
tool_id: tool_id.clone(),
sessions: Some(vec![session.clone()]),
user_id: UserId::new("alice").expect("user id"),
server_id: None,
description: handle.description(&xai_tool_runtime::ListToolsContext::default()),
input_schema: None,
capabilities: Some(handle.capabilities()),
notification_schemas: None,
transport_kind: WireTransportKind::Local,
if_match_generation: None,
metadata: None,
};
self.entries
.insert((session, tool_id), (registration, handle));
}
}
#[async_trait]
impl ToolRegistry for InMemRegistry {
async fn register_tool(
&self,
_connection_id: ConnectionId,
_reg: ToolRegistration,
) -> RegistrationOutcome {
unreachable!("transport tests pre-populate via install()")
}
async fn register_server(
&self,
_connection_id: ConnectionId,
_reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome> {
unreachable!()
}
async fn unregister_tool(&self, _connection_id: &ConnectionId, _tool: &ToolId) -> bool {
unreachable!()
}
async fn unregister_server(&self, _connection_id: &ConnectionId, _server: &ServerId) -> usize {
unreachable!()
}
async fn bind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionBindOutcome {
unreachable!()
}
async fn unbind_tool_session(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
_session_id: &SessionId,
) -> ToolSessionUnbindOutcome {
unreachable!()
}
async fn drop_connection(&self, _connection_id: &ConnectionId) -> ConnectionCleanupReport {
ConnectionCleanupReport::default()
}
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool> {
let (registration, handle) = self
.entries
.get(&(session.clone(), tool.clone()))?
.value()
.clone();
Some(ResolvedTool::Local {
tool: handle,
registration,
})
}
fn list_tools(&self, _session: &SessionId, _mode: &ToolDefinitionMode) -> Vec<ToolDescription> {
vec![]
}
fn list_servers(&self, _session: &SessionId) -> Vec<ServerSummary> {
vec![]
}
fn search(&self, _session: &SessionId, _query: &str, _limit: usize) -> SearchSnapshot {
SearchSnapshot {
results: vec![],
total_hidden_tools: 0,
is_ready: true,
}
}
async fn unregister_session(&self, _session: &SessionId) -> SessionCleanupReport {
SessionCleanupReport::default()
}
fn tool_sessions(
&self,
_connection_id: &ConnectionId,
_tool: &ToolId,
) -> std::collections::HashSet<SessionId> {
std::collections::HashSet::new()
}
fn list_servers_for_user(
&self,
_user_id: &xai_tool_protocol::UserId,
) -> Vec<xai_computer_hub_core::registry::ServerRecord> {
Vec::new()
}
fn get_server_record(
&self,
_connection_id: &ConnectionId,
) -> Option<xai_computer_hub_core::registry::ServerRecord> {
None
}
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("tool id")
}
fn uid(s: &str) -> UserId {
UserId::new(s).expect("user id")
}
async fn collect(
stream: &mut ToolStream<xai_tool_runtime::TypedToolOutput>,
) -> Vec<ToolStreamItem<xai_tool_runtime::TypedToolOutput>> {
let mut items = Vec::new();
while let Some(item) = stream.next().await {
items.push(item);
}
items
}
#[tokio::test]
async fn dispatches_blocking_tool_to_terminal_value() {
let registry = Arc::new(InMemRegistry::default());
registry.install(
sid("sess-1"),
tid("echo"),
Arc::new(ErasedTool::new(EchoTool)),
);
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let transport = LocalTransport::new(resolver, uid("alice"), sid("sess-1"));
let mut stream = transport
.call(
tid("echo"),
serde_json::json!({"payload": "hi"}),
ToolCallContext::default(),
)
.await;
let items = collect(&mut stream).await;
assert_eq!(items.len(), 1);
match &items[0] {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.value, serde_json::json!({"echoed": "hi"}));
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn dispatches_streaming_tool_with_three_progress_then_terminal() {
let registry = Arc::new(InMemRegistry::default());
registry.install(
sid("sess-1"),
tid("streamer"),
Arc::new(ErasedTool::new(StreamerTool)),
);
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let transport = LocalTransport::new(resolver, uid("alice"), sid("sess-1"));
let mut stream = transport
.call(
tid("streamer"),
serde_json::json!({"payload": "hi"}),
ToolCallContext::default(),
)
.await;
let items = collect(&mut stream).await;
assert_eq!(items.len(), 4);
for item in &items[..3] {
assert!(matches!(item, ToolStreamItem::Progress(_)));
}
assert!(matches!(items[3], ToolStreamItem::Terminal(Ok(_))));
}
#[tokio::test]
async fn missing_tool_resolves_as_terminal_not_found() {
let registry = Arc::new(InMemRegistry::default());
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let transport = LocalTransport::new(resolver, uid("alice"), sid("sess-1"));
let mut stream = transport
.call(
tid("ghost"),
serde_json::json!(null),
ToolCallContext::default(),
)
.await;
let items = collect(&mut stream).await;
assert_eq!(items.len(), 1);
match &items[0] {
ToolStreamItem::Terminal(Err(e)) if e.kind == xai_tool_runtime::ToolErrorKind::NotFound => {
assert!(
e.detail.contains("ghost"),
"detail should mention tool id: {}",
e.detail
);
}
other => panic!("expected Terminal(Err(NotFound)), got {other:?}"),
}
}
#[tokio::test]
async fn invalid_arguments_surface_as_terminal_error() {
let registry = Arc::new(InMemRegistry::default());
registry.install(
sid("sess-1"),
tid("echo"),
Arc::new(ErasedTool::new(EchoTool)),
);
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let transport = LocalTransport::new(resolver, uid("alice"), sid("sess-1"));
let mut stream = transport
.call(
tid("echo"),
// Missing required `payload` field.
serde_json::json!({}),
ToolCallContext::default(),
)
.await;
let items = collect(&mut stream).await;
assert_eq!(items.len(), 1);
match &items[0] {
ToolStreamItem::Terminal(Err(e))
if e.kind == xai_tool_runtime::ToolErrorKind::InvalidArguments => {}
other => panic!("expected Terminal(Err(InvalidArguments)), got {other:?}"),
}
}
#[tokio::test]
async fn authorize_returns_bound_principal_with_invoke_scope() {
let registry = Arc::new(InMemRegistry::default());
let resolver = Arc::new(CompoundResolver::local_only(
registry as Arc<dyn ToolRegistry>,
));
let transport = LocalTransport::new(resolver, uid("alice"), sid("sess-1"));
let principal = transport.authorize().await.expect("authorize");
assert_eq!(principal.user_id, uid("alice"));
assert!(principal.authorizes_session(&sid("sess-1")));
assert!(principal.has_scope(xai_computer_hub_core::LOCAL_INVOKE_SCOPE));
assert_eq!(transport.kind(), TransportKind::Local);
}
#[test]
fn unused_helpers_silenced() {
// `terminal_only` is re-exported for adapter authors; touch it here so
// a future refactor that drops the import does not silently break the
// re-export surface.
let _: ToolStream<serde_json::Value> = terminal_only(Ok(serde_json::Value::Null));
}

View file

@ -0,0 +1,727 @@
//! `RemoteToolProxy` and `RemoteTransport` coverage. A channel-backed
//! mock `ConnectionClient` lets the test inspect outgoing frames and
//! drive synthetic responses + progress without any tokio I/O.
use std::sync::{Arc, Mutex};
use dashmap::DashMap;
use async_trait::async_trait;
use futures::StreamExt;
use futures::channel::{mpsc, oneshot};
use futures::stream::BoxStream;
use xai_computer_hub_core::{
ConnectionClient, RemoteToolProxy, RemoteTransport, ToolHandle, Transport, TransportKind,
};
use xai_tool_protocol::{
JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcVersion,
Method, ResponseOutcome, SessionId, ToolCallId, ToolCallParams, ToolCallProgressFrame,
ToolCallResult, ToolCapabilities, ToolErrorWire, ToolId, ToolOutputWire, UserId,
};
use xai_tool_runtime::{
ContentBlock, ToolCallContext, ToolError, ToolOutput, ToolProgress, ToolStreamItem,
};
use xai_tool_types::ToolDescription;
/// Programmable `ConnectionClient`. Each request gets a pre-staged
/// response; progress frames are pushed through per-call senders.
#[derive(Debug, Default)]
struct MockConnection {
/// Senders keyed by `tool_call_id`. Pulled out of the inner state so
/// per-call subscription touches a lock-free DashMap rather than the
/// shared Mutex that guards the rest of the queue + capture state.
progress_senders: DashMap<ToolCallId, mpsc::UnboundedSender<ToolCallProgressFrame>>,
/// Three-Vec state guarded by one Mutex. The lock provides atomic
/// pop-from-`responses` + push-to-`captured_requests` semantics that
/// some tests rely on.
inner: Mutex<MockState>,
}
#[derive(Default)]
struct MockState {
/// FIFO queue of responses to return for each `request` call.
responses: Vec<MockResponse>,
/// Captured outgoing requests so tests can assert on them.
captured_requests: Vec<JsonRpcRequest>,
/// Captured one-way notifications.
captured_notifications: Vec<JsonRpcNotification>,
}
impl std::fmt::Debug for MockState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MockState")
.field("responses_len", &self.responses.len())
.field("captured_reqs", &self.captured_requests.len())
.field("captured_notifs", &self.captured_notifications.len())
.finish()
}
}
enum MockResponse {
Ok(serde_json::Value),
Err(JsonRpcError),
/// Resolves a oneshot when the request arrives so the test can
/// release progress before allowing the response.
Gated {
gate: oneshot::Receiver<()>,
body: serde_json::Value,
},
/// Fail at the transport layer (e.g. socket dropped).
Network(String),
}
impl MockConnection {
fn enqueue_ok(&self, body: serde_json::Value) {
self.inner
.lock()
.expect("mutex")
.responses
.push(MockResponse::Ok(body));
}
fn enqueue_err(&self, code: i32, message: impl Into<String>, data: Option<serde_json::Value>) {
self.inner
.lock()
.expect("mutex")
.responses
.push(MockResponse::Err(JsonRpcError {
code,
message: message.into(),
data,
}));
}
fn enqueue_gated(&self, gate: oneshot::Receiver<()>, body: serde_json::Value) {
self.inner
.lock()
.expect("mutex")
.responses
.push(MockResponse::Gated { gate, body });
}
fn enqueue_network_failure(&self, message: impl Into<String>) {
self.inner
.lock()
.expect("mutex")
.responses
.push(MockResponse::Network(message.into()));
}
fn last_request(&self) -> Option<JsonRpcRequest> {
self.inner
.lock()
.expect("mutex")
.captured_requests
.last()
.cloned()
}
fn captured_request_count(&self) -> usize {
self.inner.lock().expect("mutex").captured_requests.len()
}
fn push_progress(&self, tool_call_id: &ToolCallId, frame: ToolCallProgressFrame) {
if let Some(tx) = self.progress_senders.get(tool_call_id) {
let _ = tx.value().unbounded_send(frame);
}
}
}
#[async_trait]
impl ConnectionClient for MockConnection {
async fn request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, ToolError> {
let response = {
let mut guard = self.inner.lock().expect("mutex");
guard.captured_requests.push(request.clone());
if guard.responses.is_empty() {
return Err(ToolError::custom(
"mock_response_missing",
"no response staged",
));
}
guard.responses.remove(0)
};
match response {
MockResponse::Ok(body) => Ok(JsonRpcResponse {
jsonrpc: JsonRpcVersion,
id: request.id,
session_id: request.session_id,
outcome: ResponseOutcome::Result(body),
}),
MockResponse::Err(err) => Ok(JsonRpcResponse {
jsonrpc: JsonRpcVersion,
id: request.id,
session_id: request.session_id,
outcome: ResponseOutcome::Error(err),
}),
MockResponse::Gated { gate, body } => {
let _ = gate.await;
Ok(JsonRpcResponse {
jsonrpc: JsonRpcVersion,
id: request.id,
session_id: request.session_id,
outcome: ResponseOutcome::Result(body),
})
}
MockResponse::Network(msg) => Err(ToolError::network_error(msg)),
}
}
async fn subscribe_progress(
&self,
tool_call_id: ToolCallId,
) -> BoxStream<'static, ToolCallProgressFrame> {
let (tx, rx) = mpsc::unbounded();
self.progress_senders.insert(tool_call_id, tx);
rx.boxed()
}
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), ToolError> {
self.inner
.lock()
.expect("mutex")
.captured_notifications
.push(notification);
Ok(())
}
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("tool id")
}
fn uid(s: &str) -> UserId {
UserId::new(s).expect("user id")
}
fn description_for(name: &str) -> ToolDescription {
ToolDescription::new(name, format!("desc for {name}"))
}
fn ok_call_result(call_id: &ToolCallId, output: ToolOutputWire) -> serde_json::Value {
serde_json::to_value(ToolCallResult {
tool_call_id: call_id.clone(),
output,
follow_ups: vec![],
reminders: vec![],
chat_completion_output: None,
})
.expect("serialise call result")
}
fn ok_call_result_with_cco(
call_id: &ToolCallId,
output: ToolOutputWire,
chat_completion_output: serde_json::Value,
) -> serde_json::Value {
serde_json::to_value(ToolCallResult {
tool_call_id: call_id.clone(),
output,
follow_ups: vec![],
reminders: vec![],
chat_completion_output: Some(chat_completion_output),
})
.expect("serialise call result")
}
#[tokio::test]
async fn proxy_sends_well_formed_tool_call_request() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
conn.enqueue_ok(ok_call_result(
&call_id,
ToolOutputWire::Text("hello".to_string()),
));
let mut stream = proxy.execute(ctx, serde_json::json!({"k": "v"})).await;
while stream.next().await.is_some() {}
let req = conn.last_request().expect("captured request");
assert_eq!(req.method, Method::ToolCallRequest.as_wire_str());
let params: ToolCallParams = serde_json::from_value(req.params).expect("decode params");
assert_eq!(params.tool_id, tid("foo"));
assert_eq!(params.tool_call_id, call_id);
assert_eq!(params.arguments, serde_json::json!({"k": "v"}));
}
#[tokio::test]
async fn progress_then_terminal_orders_correctly() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
let (gate_tx, gate_rx) = oneshot::channel();
conn.enqueue_gated(
gate_rx,
ok_call_result(&call_id, ToolOutputWire::Text("done".to_string())),
);
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
// Push two progress frames before the terminal is unblocked.
conn.push_progress(
&call_id,
ToolCallProgressFrame {
tool_call_id: call_id.clone(),
kind: "log".to_string(),
body: serde_json::json!({"text": "tick"}),
dropped_count: None,
},
);
conn.push_progress(
&call_id,
ToolCallProgressFrame {
tool_call_id: call_id.clone(),
kind: "log".to_string(),
body: serde_json::json!({"text": "tock"}),
dropped_count: None,
},
);
let first = stream.next().await.expect("first item");
let second = stream.next().await.expect("second item");
match (&first, &second) {
(ToolStreamItem::Progress(p1), ToolStreamItem::Progress(p2)) => {
match p1 {
ToolProgress::Custom { subkind, payload } => {
assert_eq!(subkind, "log");
assert_eq!(payload, &serde_json::json!({"text": "tick"}));
}
other => panic!("expected Custom progress, got {other:?}"),
}
match p2 {
ToolProgress::Custom { subkind, .. } => assert_eq!(subkind, "log"),
other => panic!("expected Custom progress, got {other:?}"),
}
}
other => panic!("expected two Progress items, got {other:?}"),
}
// Release the response and consume the terminal.
let _ = gate_tx.send(());
let terminal = stream.next().await.expect("terminal");
match terminal {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.value, serde_json::json!("done"));
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
assert!(stream.next().await.is_none());
}
#[tokio::test]
async fn json_rpc_error_response_decodes_into_tool_error() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
let wire = ToolErrorWire::ToolNotFound {
tool_id: tid("foo"),
};
conn.enqueue_err(
-32011,
"tool not found",
Some(serde_json::to_value(&wire).unwrap()),
);
let mut stream = proxy
.execute(ToolCallContext::default(), serde_json::json!(null))
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::NotFound =>
{
assert!(
e.detail.contains("foo"),
"detail should mention tool id: {}",
e.detail
);
}
other => panic!("expected Terminal(NotFound), got {other:?}"),
}
}
#[tokio::test]
async fn network_failure_surfaces_as_terminal_network_error() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
conn.enqueue_network_failure("socket closed");
let mut stream = proxy
.execute(ToolCallContext::default(), serde_json::json!(null))
.await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::NetworkError =>
{
assert!(
e.detail.contains("socket closed"),
"detail should mention cause: {}",
e.detail
);
}
other => panic!("expected Terminal(NetworkError), got {other:?}"),
}
}
#[tokio::test]
async fn mcp_output_re_serialises_into_blocks_value() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
let blocks = vec![xai_tool_protocol::McpBlock::Text {
text: "hello".to_string(),
}];
conn.enqueue_ok(ok_call_result(&call_id, ToolOutputWire::Mcp { blocks }));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
// The wire blocks round-trip through ContentBlock; assert the
// text value survives the transformation.
let blocks_value = typed
.value
.get("blocks")
.and_then(|v| v.as_array())
.cloned()
.expect("blocks array");
assert_eq!(blocks_value.len(), 1);
let block: ContentBlock =
serde_json::from_value(blocks_value[0].clone()).expect("decode runtime block");
match block {
ContentBlock::Text { text } => assert_eq!(text, "hello"),
other => panic!("expected Text block, got {other:?}"),
}
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn terminal_carries_chat_completion_output_from_wire() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("bash"),
sid("sess-1"),
description_for("bash"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
let cco = serde_json::json!({
"result": {
"sender": "assistant",
"message": "",
"code_execution_result": {
"stdout": "hi\n",
"stderr": "",
"exit_code": 0,
"command_timed_out": false
}
}
});
conn.enqueue_ok(ok_call_result_with_cco(
&call_id,
ToolOutputWire::Json(serde_json::json!({"stdout": "hi\n"})),
cco,
));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
let response = typed
.chat_completion_output()
.expect("chat completion output survives the wire");
let completion = response.result.expect("completion result present");
let exec = completion
.code_execution_result
.expect("code execution result present");
assert_eq!(exec.stdout, "hi\n");
assert_eq!(exec.exit_code, 0);
assert!(!exec.command_timed_out);
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn terminal_without_chat_completion_output_is_none() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("bash"),
sid("sess-1"),
description_for("bash"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
conn.enqueue_ok(ok_call_result(
&call_id,
ToolOutputWire::Json(serde_json::json!({"stdout": "hi\n"})),
));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert!(typed.chat_completion_output().is_none());
}
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn malformed_inner_chat_completion_output_degrades_to_none() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("bash"),
sid("sess-1"),
description_for("bash"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
conn.enqueue_ok(ok_call_result_with_cco(
&call_id,
ToolOutputWire::Json(serde_json::json!({"stdout": "hi\n"})),
serde_json::json!({"result": "not-a-completion-object"}),
));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.value, serde_json::json!({"stdout": "hi\n"}));
assert!(typed.chat_completion_output().is_none());
}
other => panic!("expected Terminal(Ok) with degraded cco, got {other:?}"),
}
}
#[tokio::test]
async fn bare_non_enveloped_success_body_passes_through() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("bash"),
sid("sess-1"),
description_for("bash"),
ToolCapabilities::default(),
conn.clone(),
);
let ctx = ToolCallContext::new(ToolCallId::new_v7());
conn.enqueue_ok(serde_json::json!({"stdout": "hi\n"}));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Ok(typed)) => {
assert_eq!(typed.value, serde_json::json!({"stdout": "hi\n"}));
assert!(typed.chat_completion_output().is_none());
}
other => panic!("expected Terminal(Ok) passthrough, got {other:?}"),
}
}
#[tokio::test]
async fn malformed_envelope_with_tool_call_id_surfaces_decode_error() {
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("bash"),
sid("sess-1"),
description_for("bash"),
ToolCapabilities::default(),
conn.clone(),
);
let ctx = ToolCallContext::new(ToolCallId::new_v7());
conn.enqueue_ok(serde_json::json!({"tool_call_id": "call_x", "output": 123}));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
let item = stream.next().await.expect("terminal");
match item {
ToolStreamItem::Terminal(Err(ref e))
if e.kind == xai_tool_runtime::ToolErrorKind::Custom =>
{
let code = e
.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|c| c.as_str());
assert_eq!(code, Some("response_decoding"), "error: {e:?}");
}
other => panic!("expected Terminal(Err) decode failure, got {other:?}"),
}
}
#[tokio::test]
async fn remote_transport_call_dispatches_via_connection() {
let conn = Arc::new(MockConnection::default());
let transport = RemoteTransport::new(conn.clone(), sid("sess-1"), uid("alice"));
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
conn.enqueue_ok(ok_call_result(&call_id, ToolOutputWire::Text("hi".into())));
let mut stream = transport
.call(tid("foo"), serde_json::json!({"k": "v"}), ctx)
.await;
let _ = stream.next().await;
assert_eq!(conn.captured_request_count(), 1);
assert_eq!(transport.kind(), TransportKind::Remote);
}
#[tokio::test]
async fn remote_transport_authorize_returns_bound_principal() {
let conn = Arc::new(MockConnection::default());
let transport = RemoteTransport::new(conn, sid("sess-1"), uid("alice"));
let principal = transport.authorize().await.expect("authorize");
assert_eq!(principal.user_id, uid("alice"));
assert!(principal.authorizes_session(&sid("sess-1")));
}
#[tokio::test]
async fn proxy_subscribe_happens_before_request_send() {
// Locks in BOTH halves of the subscribe-before-send contract:
// 1. the subscription IS active by the time `execute` returns;
// 2. the request HAS NOT been sent yet at that point.
// A future refactor that eagerly sent the request inside
// `execute` would still satisfy (1) but would break (2).
let conn = Arc::new(MockConnection::default());
let proxy = RemoteToolProxy::new(
tid("foo"),
sid("sess-1"),
description_for("foo"),
ToolCapabilities::default(),
conn.clone(),
);
let call_id = ToolCallId::new_v7();
let ctx = ToolCallContext::new(call_id.clone());
conn.enqueue_ok(ok_call_result(&call_id, ToolOutputWire::Text("ok".into())));
let mut stream = proxy.execute(ctx, serde_json::json!(null)).await;
{
// The DashMap subscription read and the captured-requests check
// are individually atomic. Single-threaded `#[tokio::test]`
// execution means no other task can mutate either between the
// two checks, so the pair is observationally simultaneous.
assert!(
conn.progress_senders.contains_key(&call_id),
"subscription must be active before request send"
);
let guard = conn.inner.lock().expect("mutex");
assert!(
guard.captured_requests.is_empty(),
"request must not be sent before stream is polled"
);
}
// Polling the stream is what actually drives the request future,
// so the captured-requests vec only fills in once we start consuming.
while stream.next().await.is_some() {}
{
let guard = conn.inner.lock().expect("mutex");
assert_eq!(
guard.captured_requests.len(),
1,
"request must have been sent during stream polling"
);
}
}
#[tokio::test]
async fn notify_round_trips_through_connection_client() {
let conn = Arc::new(MockConnection::default());
let notification = JsonRpcNotification {
jsonrpc: JsonRpcVersion,
session_id: Some(sid("sess-1")),
seq: None,
method: Method::Hook.as_wire_str().to_string(),
params: serde_json::json!({
"session_id": "sess-1",
"tool_id": "foo",
"call_id": "call-1",
"event": { "type": "Cancel" }
}),
};
let trait_handle: &dyn ConnectionClient = conn.as_ref();
trait_handle
.notify(notification.clone())
.await
.expect("notify succeeds");
let guard = conn.inner.lock().expect("mutex");
assert_eq!(guard.captured_notifications.len(), 1);
let captured = &guard.captured_notifications[0];
assert_eq!(captured.method, Method::Hook.as_wire_str());
assert_eq!(captured.session_id, Some(sid("sess-1")));
assert_eq!(
captured.params.get("event").and_then(|v| v.get("type")),
Some(&serde_json::Value::String("Cancel".to_string()))
);
assert_eq!(captured, &notification);
}
#[tokio::test]
async fn json_rpc_id_is_unique_per_call() {
let conn = Arc::new(MockConnection::default());
let transport = RemoteTransport::new(conn.clone(), sid("sess-1"), uid("alice"));
let call_a = ToolCallId::new_v7();
let call_b = ToolCallId::new_v7();
conn.enqueue_ok(ok_call_result(&call_a, ToolOutputWire::Text("a".into())));
conn.enqueue_ok(ok_call_result(&call_b, ToolOutputWire::Text("b".into())));
let mut s1 = transport
.call(
tid("foo"),
serde_json::json!(null),
ToolCallContext::new(call_a.clone()),
)
.await;
while s1.next().await.is_some() {}
let mut s2 = transport
.call(
tid("foo"),
serde_json::json!(null),
ToolCallContext::new(call_b.clone()),
)
.await;
while s2.next().await.is_some() {}
let guard = conn.inner.lock().expect("mutex");
assert_eq!(guard.captured_requests.len(), 2);
let id_a = match &guard.captured_requests[0].id {
JsonRpcId::String(s) => s.clone(),
JsonRpcId::Number(n) => n.to_string(),
};
let id_b = match &guard.captured_requests[1].id {
JsonRpcId::String(s) => s.clone(),
JsonRpcId::Number(n) => n.to_string(),
};
assert_ne!(id_a, id_b, "envelope ids must differ across calls");
}

View file

@ -0,0 +1,607 @@
//! `ToolRegistry` trait coverage via a per-test mock backed by `DashMap`
//! — lock-free per-key concurrent access mirrors the production
//! direction even at the test layer. The mock implements the
//! connection-scoped `ToolRegistry` trait surface.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use async_trait::async_trait;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use xai_computer_hub_core::{
ConnectionCleanupReport, ErasedTool, ResolvedTool, SessionCleanupReport, ToolHandle,
ToolRegistry, ToolSessionBindOutcome, ToolSessionUnbindOutcome, resolver::CompoundResolver,
};
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, TransportKind, UserId,
};
use xai_tool_runtime::{SearchSnapshot, ServerSummary, Tool, ToolCallContext, ToolError};
use xai_tool_types::ToolDescription;
#[derive(Debug, Default, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct EmptyArgs {}
#[derive(Debug)]
struct StubTool {
id: ToolId,
}
impl Tool for StubTool {
type Args = EmptyArgs;
type Output = serde_json::Value;
fn id(&self) -> ToolId {
self.id.clone()
}
fn description(&self, _ctx: &::xai_tool_runtime::ListToolsContext) -> ToolDescription {
ToolDescription::new(self.id.as_str(), format!("stub for {}", self.id))
}
async fn run(
&self,
_ctx: ToolCallContext,
_args: Self::Args,
) -> Result<Self::Output, ToolError> {
unreachable!("registry tests do not exercise execution")
}
}
#[derive(Debug, Clone)]
struct MockEntry {
registration: ToolRegistration,
sessions: HashSet<SessionId>,
}
/// Mock registry. Last-write-wins on duplicate registrations within a
/// `(connection, tool_id)` slot — pinned here so the trait contract has
/// a clear test fixture.
#[derive(Debug, Default)]
struct MockRegistry {
entries: DashMap<(ConnectionId, ToolId), MockEntry>,
by_session: DashMap<(SessionId, ToolId), ConnectionId>,
handles: DashMap<ToolId, Arc<dyn ToolHandle>>,
}
impl MockRegistry {
fn install_handle(&self, tool: Arc<dyn ToolHandle>) {
self.handles.insert(tool.id(), tool);
}
}
fn build_registration(tool: &ToolId, sessions: &[SessionId]) -> ToolRegistration {
ToolRegistration {
tool_id: tool.clone(),
sessions: Some(sessions.to_vec()),
user_id: UserId::new("alice").expect("valid user id"),
server_id: None,
description: ToolDescription::new(tool.as_str(), format!("desc for {tool}")),
input_schema: None,
capabilities: None,
notification_schemas: None,
transport_kind: TransportKind::Local,
if_match_generation: None,
metadata: None,
}
}
#[async_trait]
impl ToolRegistry for MockRegistry {
async fn register_tool(
&self,
connection_id: ConnectionId,
reg: ToolRegistration,
) -> RegistrationOutcome {
let key = (connection_id.clone(), reg.tool_id.clone());
let sessions: HashSet<SessionId> = reg
.sessions
.as_ref()
.map(|v| v.iter().cloned().collect())
.unwrap_or_default();
let updated = self
.entries
.insert(
key,
MockEntry {
registration: reg.clone(),
sessions: sessions.clone(),
},
)
.is_some();
for session in &sessions {
self.by_session.insert(
(session.clone(), reg.tool_id.clone()),
connection_id.clone(),
);
}
if updated {
RegistrationOutcome::Updated {
tool_id: reg.tool_id,
generation: 1,
}
} else {
RegistrationOutcome::Registered {
tool_id: reg.tool_id,
generation: 0,
}
}
}
async fn register_server(
&self,
connection_id: ConnectionId,
reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome> {
let mut outcomes = Vec::with_capacity(reg.tools.len());
for tool in reg.tools {
let tool_id = tool
.derive_tool_id()
.expect("test descriptions have valid tool ids");
let registration = ToolRegistration {
tool_id: tool_id.clone(),
sessions: reg.sessions.clone(),
user_id: reg.user_id.clone(),
server_id: Some(reg.server_id.clone()),
description: tool.description,
input_schema: tool.input_schema,
capabilities: tool.capabilities,
notification_schemas: tool.notification_schemas,
transport_kind: TransportKind::Remote,
if_match_generation: None,
metadata: None,
};
outcomes.push(
self.register_tool(connection_id.clone(), registration)
.await,
);
}
outcomes
}
async fn unregister_tool(&self, connection_id: &ConnectionId, tool: &ToolId) -> bool {
let Some((_, removed)) = self.entries.remove(&(connection_id.clone(), tool.clone())) else {
return false;
};
for session in &removed.sessions {
self.by_session
.remove_if(&(session.clone(), tool.clone()), |_, owner| {
owner == connection_id
});
}
true
}
async fn unregister_server(&self, connection_id: &ConnectionId, server: &ServerId) -> usize {
let to_remove: Vec<ToolId> = self
.entries
.iter()
.filter(|r| {
r.key().0 == *connection_id
&& r.value().registration.server_id.as_ref() == Some(server)
})
.map(|r| r.key().1.clone())
.collect();
let mut removed = 0usize;
for tool in to_remove {
if self.unregister_tool(connection_id, &tool).await {
removed += 1;
}
}
removed
}
async fn bind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionBindOutcome {
let key = (connection_id.clone(), tool.clone());
let Some(mut entry) = self.entries.get_mut(&key) else {
return ToolSessionBindOutcome::UnknownTool;
};
if !entry.value_mut().sessions.insert(session_id.clone()) {
return ToolSessionBindOutcome::AlreadyBound;
}
self.by_session
.insert((session_id.clone(), tool.clone()), connection_id.clone());
ToolSessionBindOutcome::Bound
}
async fn unbind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionUnbindOutcome {
let key = (connection_id.clone(), tool.clone());
let Some(mut entry) = self.entries.get_mut(&key) else {
return ToolSessionUnbindOutcome::UnknownTool;
};
if !entry.value_mut().sessions.remove(session_id) {
return ToolSessionUnbindOutcome::NotBound;
}
self.by_session
.remove_if(&(session_id.clone(), tool.clone()), |_, owner| {
owner == connection_id
});
ToolSessionUnbindOutcome::Unbound
}
async fn drop_connection(&self, connection_id: &ConnectionId) -> ConnectionCleanupReport {
let to_remove: Vec<ToolId> = self
.entries
.iter()
.filter(|r| r.key().0 == *connection_id)
.map(|r| r.key().1.clone())
.collect();
let mut report = ConnectionCleanupReport::default();
for tool in to_remove {
if let Some((_, removed)) = self.entries.remove(&(connection_id.clone(), tool.clone()))
{
report.tools_dropped += 1;
for session in removed.sessions {
if self
.by_session
.remove_if(&(session, tool.clone()), |_, owner| owner == connection_id)
.is_some()
{
report.session_bindings_cleared += 1;
}
}
}
}
report
}
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool> {
let owner = self
.by_session
.get(&(session.clone(), tool.clone()))?
.value()
.clone();
let entry = self.entries.get(&(owner, tool.clone()))?;
let registration = entry.value().registration.clone();
let handle = self.handles.get(tool)?.value().clone();
match registration.transport_kind {
TransportKind::Local => Some(ResolvedTool::Local {
tool: handle,
registration,
}),
TransportKind::Remote => Some(ResolvedTool::Remote {
proxy: handle,
registration,
}),
}
}
fn list_tools(&self, session: &SessionId, _mode: &ToolDefinitionMode) -> Vec<ToolDescription> {
self.by_session
.iter()
.filter(|r| r.key().0 == *session)
.filter_map(|r| {
let owner = r.value().clone();
let tool_id = r.key().1.clone();
self.entries
.get(&(owner, tool_id))
.map(|e| e.value().registration.description.clone())
})
.collect()
}
fn list_servers(&self, session: &SessionId) -> Vec<ServerSummary> {
let mut by_server: HashMap<ServerId, Vec<String>> = HashMap::new();
for r in self.by_session.iter().filter(|r| r.key().0 == *session) {
let owner = r.value().clone();
let tool_id = r.key().1.clone();
if let Some(entry) = self.entries.get(&(owner, tool_id)) {
let reg = &entry.value().registration;
let server = reg
.server_id
.clone()
.unwrap_or_else(|| ServerId::synthesize_for_tool(r.value(), &reg.tool_id));
by_server
.entry(server)
.or_default()
.push(reg.tool_id.as_str().to_string());
}
}
by_server
.into_iter()
.map(|(server, mut names)| {
names.sort();
ServerSummary {
name: server.into_inner(),
description: None,
tool_names: names,
}
})
.collect()
}
fn search(&self, session: &SessionId, query: &str, limit: usize) -> SearchSnapshot {
let matches: Vec<_> = self
.by_session
.iter()
.filter(|r| r.key().0 == *session)
.filter_map(|r| {
let owner = r.value().clone();
let tool_id = r.key().1.clone();
let entry = self.entries.get(&(owner, tool_id))?;
let reg = &entry.value().registration;
if reg.tool_id.as_str().contains(query) {
Some(xai_tool_runtime::ToolSearchResult {
tool_name: reg.tool_id.as_str().to_string(),
server_name: reg
.server_id
.as_ref()
.map(|s| s.as_str().to_string())
.unwrap_or_default(),
description: reg.description.description.clone(),
score: 1.0,
parameters: vec![],
input_schema: serde_json::Value::Null,
})
} else {
None
}
})
.take(limit)
.collect();
SearchSnapshot {
results: matches,
total_hidden_tools: 0,
is_ready: true,
}
}
async fn unregister_session(&self, session: &SessionId) -> SessionCleanupReport {
let pairs: Vec<(ToolId, ConnectionId)> = self
.by_session
.iter()
.filter(|r| r.key().0 == *session)
.map(|r| (r.key().1.clone(), r.value().clone()))
.collect();
let mut report = SessionCleanupReport::default();
for (tool_id, owner) in pairs {
self.by_session
.remove_if(&(session.clone(), tool_id.clone()), |_, value| {
value == &owner
});
if let Some(mut entry) = self.entries.get_mut(&(owner, tool_id)) {
entry.value_mut().sessions.remove(session);
report.tools_touched += 1;
if entry.value().sessions.is_empty() {
report.tools_left_orphaned += 1;
}
}
}
report
}
fn tool_sessions(&self, connection_id: &ConnectionId, tool: &ToolId) -> HashSet<SessionId> {
self.entries
.get(&(connection_id.clone(), tool.clone()))
.map(|r| r.value().sessions.clone())
.unwrap_or_default()
}
fn list_servers_for_user(
&self,
_user_id: &xai_tool_protocol::UserId,
) -> Vec<xai_computer_hub_core::registry::ServerRecord> {
Vec::new()
}
fn get_server_record(
&self,
_connection_id: &ConnectionId,
) -> Option<xai_computer_hub_core::registry::ServerRecord> {
None
}
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("valid session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("valid tool id")
}
fn cid(s: &str) -> ConnectionId {
ConnectionId::new(s).expect("valid connection id")
}
#[tokio::test]
async fn register_then_find_returns_local_resolution() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
let outcome = reg
.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
assert!(matches!(outcome, RegistrationOutcome::Registered { .. }));
let resolved = reg
.find_tool(&sid("sess-1"), &tid("foo"))
.expect("registration found");
match resolved {
ResolvedTool::Local { registration, .. } => {
assert_eq!(registration.tool_id, tid("foo"));
assert!(
registration
.sessions
.as_ref()
.is_some_and(|s| s.contains(&sid("sess-1")))
);
}
other => panic!("expected Local, got {other:?}"),
}
}
#[tokio::test]
async fn find_in_other_session_returns_none() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
assert!(reg.find_tool(&sid("sess-2"), &tid("foo")).is_none());
}
#[tokio::test]
async fn duplicate_registration_yields_updated_outcome() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
let first = reg
.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
let second = reg
.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
assert!(matches!(first, RegistrationOutcome::Registered { .. }));
assert!(matches!(second, RegistrationOutcome::Updated { .. }));
}
#[tokio::test]
async fn unregister_tool_removes_only_that_entry() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("bar") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
reg.register_tool(cid("c1"), build_registration(&tid("bar"), &[sid("sess-1")]))
.await;
assert!(reg.unregister_tool(&cid("c1"), &tid("foo")).await);
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_none());
assert!(reg.find_tool(&sid("sess-1"), &tid("bar")).is_some());
}
#[tokio::test]
async fn unregister_session_drops_session_binding_and_leaves_orphan_count() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("bar") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
reg.register_tool(
cid("c1"),
build_registration(&tid("bar"), &[sid("sess-1"), sid("sess-2")]),
)
.await;
let report = reg.unregister_session(&sid("sess-1")).await;
assert_eq!(report.tools_touched, 2);
// `foo` had only sess-1 → orphaned. `bar` had sess-2 left → not orphaned.
assert_eq!(report.tools_left_orphaned, 1);
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_none());
assert!(reg.find_tool(&sid("sess-1"), &tid("bar")).is_none());
assert!(reg.find_tool(&sid("sess-2"), &tid("bar")).is_some());
}
#[tokio::test]
async fn list_tools_filters_by_session() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("bar") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
reg.register_tool(cid("c1"), build_registration(&tid("bar"), &[sid("sess-2")]))
.await;
let s1 = reg.list_tools(&sid("sess-1"), &ToolDefinitionMode::Full);
let s2 = reg.list_tools(&sid("sess-2"), &ToolDefinitionMode::Full);
assert_eq!(s1.len(), 1);
assert_eq!(s1[0].name, "foo");
assert_eq!(s2.len(), 1);
assert_eq!(s2[0].name, "bar");
}
#[tokio::test]
async fn list_servers_groups_by_owning_server() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
let summaries = reg.list_servers(&sid("sess-1"));
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].tool_count(), 1);
assert_eq!(summaries[0].tool_names[0], "foo");
}
#[tokio::test]
async fn search_returns_substring_matches() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foobar") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
reg.register_tool(
cid("c1"),
build_registration(&tid("foobar"), &[sid("sess-1")]),
)
.await;
let snap = reg.search(&sid("sess-1"), "foo", 10);
assert_eq!(snap.results.len(), 2);
assert!(snap.is_ready);
assert_eq!(snap.total_hidden_tools, 0);
}
#[tokio::test]
async fn registry_drives_compound_resolver() {
let registry = Arc::new(MockRegistry::default());
registry.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
registry
.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
let resolver = CompoundResolver::local_only(registry as Arc<dyn ToolRegistry>);
assert!(resolver.resolve(&sid("sess-1"), &tid("foo")).is_some());
assert!(resolver.resolve(&sid("sess-1"), &tid("missing")).is_none());
}
#[tokio::test]
async fn bind_and_unbind_tool_session_round_trips_visibility() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[]))
.await;
// Empty sessions: tool is registered but unreachable.
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_none());
let outcome = reg
.bind_tool_session(&cid("c1"), &tid("foo"), &sid("sess-1"))
.await;
assert_eq!(outcome, ToolSessionBindOutcome::Bound);
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_some());
let again = reg
.bind_tool_session(&cid("c1"), &tid("foo"), &sid("sess-1"))
.await;
assert_eq!(again, ToolSessionBindOutcome::AlreadyBound);
let unbind = reg
.unbind_tool_session(&cid("c1"), &tid("foo"), &sid("sess-1"))
.await;
assert_eq!(unbind, ToolSessionUnbindOutcome::Unbound);
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_none());
let unknown = reg
.bind_tool_session(&cid("c1"), &tid("missing"), &sid("sess-1"))
.await;
assert_eq!(unknown, ToolSessionBindOutcome::UnknownTool);
}
#[tokio::test]
async fn drop_connection_releases_every_owned_tool() {
let reg = MockRegistry::default();
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("foo") })));
reg.install_handle(Arc::new(ErasedTool::new(StubTool { id: tid("bar") })));
reg.register_tool(cid("c1"), build_registration(&tid("foo"), &[sid("sess-1")]))
.await;
reg.register_tool(
cid("c1"),
build_registration(&tid("bar"), &[sid("sess-1"), sid("sess-2")]),
)
.await;
let report = reg.drop_connection(&cid("c1")).await;
assert_eq!(report.tools_dropped, 2);
assert_eq!(report.session_bindings_cleared, 3);
assert!(reg.find_tool(&sid("sess-1"), &tid("foo")).is_none());
assert!(reg.find_tool(&sid("sess-2"), &tid("bar")).is_none());
assert!(reg.tool_sessions(&cid("c1"), &tid("foo")).is_empty());
}

View file

@ -0,0 +1,137 @@
//! Behavioural coverage for the `Transport` trait, `Principal` builder,
//! and `TransportKind` re-export.
use async_trait::async_trait;
use serde_json::{Value, json};
use xai_computer_hub_core::{Principal, Transport, TransportKind};
use xai_tool_protocol::{SessionId, ToolId, UserId};
use xai_tool_runtime::{
ToolCallContext, ToolError, ToolStream, ToolStreamItem, TypedToolOutput, terminal_only,
};
fn uid(s: &str) -> UserId {
UserId::new(s).expect("test user id")
}
fn sid(s: &str) -> SessionId {
SessionId::new(s).expect("test session id")
}
fn tid(s: &str) -> ToolId {
ToolId::new(s).expect("test tool id")
}
#[derive(Debug)]
struct EchoTransport {
kind: TransportKind,
user: UserId,
session: SessionId,
}
#[async_trait]
impl Transport for EchoTransport {
fn kind(&self) -> TransportKind {
self.kind
}
async fn authorize(&self) -> Result<Principal, ToolError> {
Ok(Principal::new(self.user.clone())
.with_session(self.session.clone())
.with_scope("tool.invoke"))
}
async fn call(
&self,
tool_id: ToolId,
args: Value,
_ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
terminal_only(Ok(TypedToolOutput::from_value(tool_id, args)))
}
}
#[tokio::test]
async fn boxed_transport_compiles_and_dispatches() {
let boxed: Box<dyn Transport> = Box::new(EchoTransport {
kind: TransportKind::Local,
user: uid("alice"),
session: sid("sess-1"),
});
let mut stream = boxed
.call(tid("echo"), json!({"k": "v"}), ToolCallContext::default())
.await;
let item = futures::StreamExt::next(&mut stream)
.await
.expect("at least one item");
match item {
ToolStreamItem::Terminal(Ok(typed)) => assert_eq!(typed.value, json!({"k": "v"})),
other => panic!("expected Terminal(Ok), got {other:?}"),
}
}
#[tokio::test]
async fn kind_distinguishes_local_and_remote() {
let local = EchoTransport {
kind: TransportKind::Local,
user: uid("alice"),
session: sid("sess-1"),
};
let remote = EchoTransport {
kind: TransportKind::Remote,
user: uid("alice"),
session: sid("sess-1"),
};
assert_eq!(local.kind(), TransportKind::Local);
assert_eq!(remote.kind(), TransportKind::Remote);
assert_ne!(local.kind(), remote.kind());
}
#[tokio::test]
async fn authorize_returns_bound_principal() {
let t = EchoTransport {
kind: TransportKind::Local,
user: uid("alice"),
session: sid("sess-1"),
};
let principal = t.authorize().await.expect("authorize succeeds");
assert_eq!(principal.user_id, uid("alice"));
assert!(principal.authorizes_session(&sid("sess-1")));
assert!(!principal.authorizes_session(&sid("sess-other")));
assert!(principal.has_scope("tool.invoke"));
assert!(!principal.has_scope("admin"));
}
#[test]
fn principal_builder_chains_in_order() {
let principal = Principal::new(uid("alice"))
.with_session(sid("sess-a"))
.with_session(sid("sess-b"))
.with_scope("tool.invoke")
.with_scope("tool.search")
.with_audience("dispatcher.example");
assert_eq!(principal.session_ids, vec![sid("sess-a"), sid("sess-b")]);
assert_eq!(principal.scopes, vec!["tool.invoke", "tool.search"]);
assert_eq!(principal.audiences, vec!["dispatcher.example"]);
}
#[test]
fn principal_supports_multi_session_tokens() {
let p = Principal::new(uid("alice"))
.with_session(sid("sess-1"))
.with_session(sid("sess-2"));
assert!(p.authorizes_session(&sid("sess-1")));
assert!(p.authorizes_session(&sid("sess-2")));
assert!(!p.authorizes_session(&sid("sess-3")));
assert_eq!(p.session_ids.len(), 2);
}
#[test]
fn principal_default_state_is_empty() {
let p = Principal::new(uid("alice"));
assert!(p.session_ids.is_empty());
assert!(p.scopes.is_empty());
assert!(p.audiences.is_empty());
assert!(!p.has_scope("anything"));
assert!(!p.authorizes_session(&sid("sess")));
}

View file

@ -0,0 +1,203 @@
//! `is_workspace_unavailable` recognizer coverage, pinned against the real
//! wire decode path (`error_from_envelope` / `tool_error_from_wire`).
use serde_json::json;
use xai_computer_hub_core::{error_from_envelope, is_workspace_unavailable, tool_error_from_wire};
use xai_tool_protocol::{
JsonRpcError, ToolErrorWire, WORKSPACE_UNAVAILABLE_SUBCODE, WorkspaceGonePhase,
WorkspaceGoneReason, WorkspaceUnavailableDetails, workspace_unavailable_wire,
};
use xai_tool_runtime::{ToolError, ToolErrorKind};
const REASONS: [WorkspaceGoneReason; 5] = [
WorkspaceGoneReason::IdleTimeout,
WorkspaceGoneReason::Disconnect,
WorkspaceGoneReason::Shutdown,
WorkspaceGoneReason::NotBound,
WorkspaceGoneReason::InstanceGone,
];
const PHASES: [WorkspaceGonePhase; 2] = [
WorkspaceGonePhase::InFlightCancelled,
WorkspaceGonePhase::RouteMissing,
];
fn envelope_for(wire: &ToolErrorWire) -> JsonRpcError {
JsonRpcError {
// -32005 is the best-effort numeric companion (`tool_server_gone`);
// recognition keys on `data.details.code`, not the numeric.
code: -32005,
message: "workspace server gone".to_owned(),
data: Some(serde_json::to_value(wire).unwrap()),
}
}
#[test]
fn round_trip_through_envelope_is_recognized_for_every_reason_and_phase() {
for reason in REASONS {
for phase in PHASES {
let wire = workspace_unavailable_wire(reason, phase);
let err = error_from_envelope(envelope_for(&wire));
assert!(
is_workspace_unavailable(&err),
"should recognize {reason:?}/{phase:?}",
);
assert_eq!(err.kind, ToolErrorKind::Custom);
// The full structured payload survives into `ToolError::details`,
// so a caller can branch on code/reason/phase/retryable.
let details: WorkspaceUnavailableDetails =
serde_json::from_value(err.details.expect("details survive")).unwrap();
assert_eq!(
details,
WorkspaceUnavailableDetails {
code: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
reason,
phase,
retryable: true,
},
);
}
}
}
#[test]
fn tool_error_from_wire_directly_is_recognized() {
let wire = workspace_unavailable_wire(
WorkspaceGoneReason::Disconnect,
WorkspaceGonePhase::RouteMissing,
);
let err = tool_error_from_wire(wire);
assert!(is_workspace_unavailable(&err));
let details = err.details.expect("details survive");
assert_eq!(details["code"], json!(WORKSPACE_UNAVAILABLE_SUBCODE));
assert_eq!(details["reason"], json!("disconnect"));
assert_eq!(details["phase"], json!("route_missing"));
assert_eq!(details["retryable"], json!(true));
}
#[test]
fn wire_to_tool_error_to_wire_preserves_outer_subcode() {
// Keying the identity on details.code lets From<ToolError> for ToolErrorWire
// rebuild the outer subcode on re-serialization.
let original = workspace_unavailable_wire(
WorkspaceGoneReason::IdleTimeout,
WorkspaceGonePhase::InFlightCancelled,
);
let tool_error = tool_error_from_wire(original);
let back: ToolErrorWire = tool_error.into();
let ToolErrorWire::Custom { subcode, .. } = back else {
panic!("expected Custom variant");
};
assert_eq!(subcode, WORKSPACE_UNAVAILABLE_SUBCODE);
}
#[test]
fn recognized_with_unknown_reason_and_phase() {
// Recognition is decoupled from the typed reason/phase enums: a newer hub
// emitting unknown values is still recognized (it keys only on `code`).
let wire = ToolErrorWire::Custom {
subcode: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
message: "from a newer hub".to_owned(),
details: Some(json!({
"code": WORKSPACE_UNAVAILABLE_SUBCODE,
"reason": "brand_new_reason",
"phase": "brand_new_phase",
"retryable": true,
})),
};
let err = error_from_envelope(envelope_for(&wire));
assert!(is_workspace_unavailable(&err));
// End-to-end decode → typed-parse → `Unknown`, the path consumers read by.
let details: WorkspaceUnavailableDetails =
serde_json::from_value(err.details.expect("details survive")).unwrap();
assert_eq!(details.reason, WorkspaceGoneReason::Unknown);
assert_eq!(details.phase, WorkspaceGonePhase::Unknown);
}
#[test]
fn decoded_custom_with_none_details_is_recognized_via_canonical_code() {
// Wire `details: None` decodes through `ToolError::custom`, which repopulates
// `details = {"code": subcode}`, so it IS recognized — contrast the hand-built
// no-details case in `custom_error_without_any_details_is_not_recognized`.
let wire = ToolErrorWire::Custom {
subcode: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
message: "no structured details".to_owned(),
details: None,
};
let err = error_from_envelope(envelope_for(&wire));
assert_eq!(err.kind, ToolErrorKind::Custom);
assert!(is_workspace_unavailable(&err));
}
#[test]
fn decoded_custom_without_code_key_is_not_recognized() {
// The central correctness property: recognition keys on the surviving
// `details.code`, NOT the outer `Custom.subcode`. Here the outer subcode
// matches, but `with_details` overwrote the auto-populated `code`, so the
// decoded error must NOT be recognized.
let wire = ToolErrorWire::Custom {
subcode: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
message: "details lack code".to_owned(),
details: Some(json!({ "reason": "disconnect" })),
};
let err = error_from_envelope(envelope_for(&wire));
assert_eq!(err.kind, ToolErrorKind::Custom);
assert!(!is_workspace_unavailable(&err));
}
#[test]
fn different_custom_code_is_not_recognized() {
let wire = ToolErrorWire::Custom {
subcode: "some_other_error".to_owned(),
message: "nope".to_owned(),
details: Some(json!({ "code": "some_other_error" })),
};
let err = error_from_envelope(envelope_for(&wire));
assert_eq!(err.kind, ToolErrorKind::Custom);
assert!(!is_workspace_unavailable(&err));
}
#[test]
fn numeric_only_tool_server_gone_without_data_is_not_recognized() {
// Recognition is by the data payload, never the numeric code: a bare -32005
// with no `data` decodes to a `jsonrpc_-32005` custom error, not recognized.
let err = error_from_envelope(JsonRpcError {
code: -32005,
message: "tool server gone".to_owned(),
data: None,
});
assert!(!is_workspace_unavailable(&err));
}
#[test]
fn custom_error_without_any_details_is_not_recognized() {
// Hand-built Custom with no `details` (no `code`) — unlike a wire `details:
// None`, nothing repopulates `code` here, so it is not recognized.
let err = ToolError::new(ToolErrorKind::Custom, "no details at all");
assert!(!is_workspace_unavailable(&err));
}
#[test]
fn non_custom_error_with_matching_code_is_not_recognized() {
// The kind guard matters: a non-Custom error carrying a matching
// `details.code` must still be rejected.
let err = ToolError::new(ToolErrorKind::NetworkError, "socket closed")
.with_details(json!({ "code": WORKSPACE_UNAVAILABLE_SUBCODE }));
assert_ne!(err.kind, ToolErrorKind::Custom);
assert!(!is_workspace_unavailable(&err));
}
#[test]
fn non_custom_decoded_error_is_not_recognized() {
let wire = ToolErrorWire::ToolNotFound {
tool_id: xai_tool_protocol::ToolId::new("ns:tool").unwrap(),
};
let err = error_from_envelope(envelope_for(&wire));
assert_ne!(err.kind, ToolErrorKind::Custom);
assert!(!is_workspace_unavailable(&err));
assert!(!is_workspace_unavailable(&ToolError::network_error(
"socket closed"
)));
}