Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
16
crates/common/xai-tool-protocol/Cargo.toml
Normal file
16
crates/common/xai-tool-protocol/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-tool-protocol"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Wire-protocol types for the xAI Computer Hub"
|
||||
|
||||
[dependencies]
|
||||
xai-tool-types = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
106
crates/common/xai-tool-protocol/src/capabilities.rs
Normal file
106
crates/common/xai-tool-protocol/src/capabilities.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! Per-tool capabilities and notification schemas.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-tool wire-traveling capabilities. Defaults conservatively (no
|
||||
/// progress, no cancel, single concurrency, no hooks).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolCapabilities {
|
||||
/// Streaming declaration. `None` — the default for every tool today —
|
||||
/// means the tool never emits partial-result progress.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub streaming: Option<StreamingSpec>,
|
||||
|
||||
/// Tool honours `hook { Cancel }`.
|
||||
#[serde(default)]
|
||||
pub supports_cancel: bool,
|
||||
|
||||
/// Maximum concurrent invocations the tool will accept. `None` is
|
||||
/// unlimited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrency: Option<u32>,
|
||||
|
||||
/// Mirrors `Tool::is_read_only`; used by doom-loop detection.
|
||||
#[serde(default)]
|
||||
pub is_read_only: bool,
|
||||
|
||||
/// Lifecycle hooks the tool opts in to receive.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hooks: Vec<HookKind>,
|
||||
|
||||
/// Opaque per-tool behaviour version. Bytewise-compared (NOT semver).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub behavior_version: Option<String>,
|
||||
|
||||
/// Per-tool override for the per-frame size cap. Service clamps to the
|
||||
/// 16 MiB hard ceiling.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_frame_bytes: Option<u32>,
|
||||
|
||||
/// Per-call timeout override (defaults to 60_000ms when omitted).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
|
||||
/// Multi-agent write-coordination scope. Tools that mutate external
|
||||
/// state must declare `Write` so the computer hub routes them to the
|
||||
/// leader agent only. Absence is treated as `Read`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_scope: Option<ToolScope>,
|
||||
}
|
||||
|
||||
/// How a tool streams partial results. Declared once in
|
||||
/// [`ToolCapabilities::streaming`] and consumed at the source to stamp a
|
||||
/// self-describing progress envelope; downstream layers dispatch on that
|
||||
/// envelope rather than the tool's identity.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamingSpec {
|
||||
/// Stable snake_case discriminator the tool stamps on its
|
||||
/// `ToolProgress::Custom.subkind` (e.g. `"bash_output_chunk"`).
|
||||
pub subkind: String,
|
||||
|
||||
/// Per-frame `delta` byte cap (UTF-8-safe). Unset falls back to the
|
||||
/// runtime's 16 KiB default. Independent of
|
||||
/// [`ToolCapabilities::max_frame_bytes`], which caps whole frames.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_delta_bytes: Option<u32>,
|
||||
}
|
||||
|
||||
/// Lifecycle hook a tool may opt in to receive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookKind {
|
||||
OnSessionOpen,
|
||||
OnSessionClose,
|
||||
OnToolCallStart,
|
||||
OnToolCallResult,
|
||||
OnCancel,
|
||||
OnNotification,
|
||||
}
|
||||
|
||||
/// Multi-agent write-coordination scope.
|
||||
///
|
||||
/// Tools that mutate external state must declare `Write` so the computer hub
|
||||
/// routes them to the leader agent only. Absence is treated as `Read`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolScope {
|
||||
/// Tool does not mutate external state.
|
||||
Read,
|
||||
/// Tool mutates external state.
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Per-tool notification schemas. Keys are the notification `kind` strings
|
||||
/// the computer hub validates against.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NotificationSchemas {
|
||||
/// Schemas for notifications the tool emits to subscribers.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub outbound: HashMap<String, serde_json::Value>,
|
||||
|
||||
/// Schemas for notifications the harness sends to the tool.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub inbound: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
38
crates/common/xai-tool-protocol/src/connection.rs
Normal file
38
crates/common/xai-tool-protocol/src/connection.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Connection-shape and tool-definition-mode enums.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Role of a WebSocket connection. The computer hub uses this to decide
|
||||
/// which methods are valid on a given socket.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionKind {
|
||||
Harness,
|
||||
ToolServer,
|
||||
}
|
||||
|
||||
/// How the computer hub exposes the registered tool set to the model.
|
||||
///
|
||||
/// `Concise` carries a configurable meta-tool pair so callers can choose
|
||||
/// the model-facing names of the search/invoke meta-tools per session.
|
||||
///
|
||||
/// Wire form is adjacently tagged on `mode`: `Full` serialises as
|
||||
/// `{"mode": "full"}` (an object, not a bare string), and `Concise` as
|
||||
/// `{"mode": "concise", "meta_search": "...", "meta_call": "..."}`.
|
||||
///
|
||||
/// `Copy` is intentionally NOT derived: `Concise`'s [`crate::ToolId`]
|
||||
/// fields wrap heap strings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum ToolDefinitionMode {
|
||||
/// Every `ToolDescription` is sent to the model directly.
|
||||
Full,
|
||||
/// Only the meta-tool pair is sent; everything else is discoverable
|
||||
/// through the search meta-tool.
|
||||
Concise {
|
||||
/// Model-facing name of the search/discovery meta-tool.
|
||||
meta_search: crate::ToolId,
|
||||
/// Model-facing name of the call/invoke meta-tool.
|
||||
meta_call: crate::ToolId,
|
||||
},
|
||||
}
|
||||
257
crates/common/xai-tool-protocol/src/envelope.rs
Normal file
257
crates/common/xai-tool-protocol/src/envelope.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
//! JSON-RPC 2.0 envelope types with the Grok `session_id` / `seq`
|
||||
//! extensions.
|
||||
//!
|
||||
//! Two distinct id concepts coexist in this crate:
|
||||
//!
|
||||
//! - [`JsonRpcId`] (this module) is the JSON-RPC envelope `id` field —
|
||||
//! string OR number on the wire, per-connection, sender-allocated.
|
||||
//! - [`crate::RequestId`] is an opaque newtype wrapping a string, used
|
||||
//! internally as a correlator (e.g. to key in-flight maps). Convert
|
||||
//! between them via [`JsonRpcId::from_request_id`] /
|
||||
//! [`JsonRpcId::as_request_id`].
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
|
||||
use crate::{FrameSeq, IdError, RequestId, SessionId};
|
||||
|
||||
/// JSON-RPC 2.0 protocol version marker.
|
||||
///
|
||||
/// Serializes as the literal string `"2.0"` and rejects any other value on
|
||||
/// deserialize.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct JsonRpcVersion;
|
||||
|
||||
impl JsonRpcVersion {
|
||||
pub const VERSION: &'static str = "2.0";
|
||||
}
|
||||
|
||||
impl fmt::Display for JsonRpcVersion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(Self::VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for JsonRpcVersion {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(Self::VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for JsonRpcVersion {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct V;
|
||||
impl de::Visitor<'_> for V {
|
||||
type Value = JsonRpcVersion;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "the literal string \"{}\"", JsonRpcVersion::VERSION)
|
||||
}
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
if v == JsonRpcVersion::VERSION {
|
||||
Ok(JsonRpcVersion)
|
||||
} else {
|
||||
Err(E::custom(format!(
|
||||
"expected jsonrpc \"{}\", got {v:?}",
|
||||
JsonRpcVersion::VERSION
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_str(V)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 envelope `id` field.
|
||||
///
|
||||
/// Per the spec the `id` MAY be a string, a number, or null. We accept the
|
||||
/// first two on deserialize and emit a string ourselves. Null ids are not
|
||||
/// produced and not modelled on the receive path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum JsonRpcId {
|
||||
String(String),
|
||||
Number(i64),
|
||||
}
|
||||
|
||||
impl JsonRpcId {
|
||||
pub fn new_string(s: impl Into<String>) -> Self {
|
||||
Self::String(s.into())
|
||||
}
|
||||
|
||||
/// Build a fresh UUID v7-backed id.
|
||||
pub fn new_uuid_v7() -> Self {
|
||||
Self::String(uuid::Uuid::now_v7().to_string())
|
||||
}
|
||||
|
||||
pub fn from_request_id(id: &RequestId) -> Self {
|
||||
Self::String(id.as_str().to_owned())
|
||||
}
|
||||
|
||||
/// Project to a [`RequestId`]. Numeric ids are stringified. Returns
|
||||
/// an error if the resulting string would be empty.
|
||||
pub fn as_request_id(&self) -> Result<RequestId, IdError> {
|
||||
match self {
|
||||
Self::String(s) => RequestId::new(s.as_str()),
|
||||
Self::Number(n) => RequestId::new(n.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for JsonRpcId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::String(s) => f.write_str(s),
|
||||
Self::Number(n) => write!(f, "{n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 request envelope.
|
||||
///
|
||||
/// Generic over `params` so callers can pin a concrete schema (e.g.
|
||||
/// [`crate::frames::ToolCallParams`]) without losing the envelope's
|
||||
/// invariants.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest<P = serde_json::Value> {
|
||||
pub jsonrpc: JsonRpcVersion,
|
||||
pub id: JsonRpcId,
|
||||
/// Grok extension: routing/sanity-check session id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<SessionId>,
|
||||
pub method: String,
|
||||
pub params: P,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 notification envelope.
|
||||
///
|
||||
/// No `id` (notifications do not produce a response). `seq` is an
|
||||
/// optional per-connection monotonic counter so receivers can dedup and
|
||||
/// detect drops.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JsonRpcNotification<P = serde_json::Value> {
|
||||
pub jsonrpc: JsonRpcVersion,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<SessionId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<FrameSeq>,
|
||||
pub method: String,
|
||||
pub params: P,
|
||||
}
|
||||
|
||||
/// JSON-RPC error object.
|
||||
///
|
||||
/// `code` is the numeric envelope code; `data` typically carries a
|
||||
/// serialized [`crate::error_wire::ToolErrorWire`] so receivers can switch
|
||||
/// on the stable string code rather than the numeric.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 response envelope.
|
||||
///
|
||||
/// Per the spec exactly one of `result` / `error` is present. The custom
|
||||
/// `Serialize` / `Deserialize` impls enforce that invariant: a payload
|
||||
/// containing both keys, or neither, fails to deserialize.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct JsonRpcResponse<R = serde_json::Value> {
|
||||
pub jsonrpc: JsonRpcVersion,
|
||||
pub id: JsonRpcId,
|
||||
pub session_id: Option<SessionId>,
|
||||
pub outcome: ResponseOutcome<R>,
|
||||
}
|
||||
|
||||
/// Either a `result` payload (success) or a [`JsonRpcError`] (failure).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ResponseOutcome<R> {
|
||||
Result(R),
|
||||
Error(JsonRpcError),
|
||||
}
|
||||
|
||||
impl<R: Serialize> Serialize for JsonRpcResponse<R> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut len = 3;
|
||||
if self.session_id.is_some() {
|
||||
len += 1;
|
||||
}
|
||||
let mut map = serializer.serialize_map(Some(len))?;
|
||||
map.serialize_entry("jsonrpc", &self.jsonrpc)?;
|
||||
map.serialize_entry("id", &self.id)?;
|
||||
if let Some(sid) = &self.session_id {
|
||||
map.serialize_entry("session_id", sid)?;
|
||||
}
|
||||
match &self.outcome {
|
||||
ResponseOutcome::Result(r) => map.serialize_entry("result", r)?,
|
||||
ResponseOutcome::Error(e) => map.serialize_entry("error", e)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, R: Deserialize<'de>> Deserialize<'de> for JsonRpcResponse<R> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// `Option<...>` deserialises to `None` when missing without
|
||||
// `#[serde(default)]`, avoiding a `R: Default` bound on the
|
||||
// result type parameter.
|
||||
#[derive(Deserialize)]
|
||||
struct Flat<R> {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId,
|
||||
session_id: Option<SessionId>,
|
||||
result: Option<R>,
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
let flat = Flat::<R>::deserialize(deserializer)?;
|
||||
let outcome = match (flat.result, flat.error) {
|
||||
(Some(r), None) => ResponseOutcome::Result(r),
|
||||
(None, Some(e)) => ResponseOutcome::Error(e),
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(de::Error::custom(
|
||||
"JSON-RPC response must contain `result` XOR `error`, got both",
|
||||
));
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(de::Error::custom(
|
||||
"JSON-RPC response must contain `result` or `error`",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
jsonrpc: flat.jsonrpc,
|
||||
id: flat.id,
|
||||
session_id: flat.session_id,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<R> JsonRpcResponse<R> {
|
||||
pub fn ok(id: JsonRpcId, result: R) -> Self {
|
||||
Self {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id,
|
||||
session_id: None,
|
||||
outcome: ResponseOutcome::Result(result),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(id: JsonRpcId, error: JsonRpcError) -> Self {
|
||||
Self {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id,
|
||||
session_id: None,
|
||||
outcome: ResponseOutcome::Error(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_session(mut self, sid: SessionId) -> Self {
|
||||
self.session_id = Some(sid);
|
||||
self
|
||||
}
|
||||
}
|
||||
300
crates/common/xai-tool-protocol/src/error_codes.rs
Normal file
300
crates/common/xai-tool-protocol/src/error_codes.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! Numeric ↔ string error-code mapping.
|
||||
//!
|
||||
//! Receivers SHOULD switch on `data.code` (the snake_case string) rather
|
||||
//! than the numeric JSON-RPC `error.code`. The numeric is the JSON-RPC
|
||||
//! envelope code; the string is the Grok stable identifier.
|
||||
//!
|
||||
//! Implemented as a `&'static [(i32, &'static str)]` table; the table is
|
||||
//! a small fixed set so a linear scan is faster than any
|
||||
//! `HashMap`/`OnceLock`-shaped alternative.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error_wire::ToolErrorWire;
|
||||
|
||||
/// `(numeric_code, string_code)` pairs. Both columns are unique.
|
||||
pub const ERROR_CODES: &[(i32, &str)] = &[
|
||||
(-32700, "parse_error"),
|
||||
(-32600, "invalid_request"),
|
||||
(-32601, "method_not_found"),
|
||||
(-32602, "invalid_params"),
|
||||
(-32603, "internal_error"),
|
||||
(-32605, "unsupported_protocol_version"),
|
||||
(-32001, "timeout"),
|
||||
(-32002, "unauthorized"),
|
||||
(-32003, "forbidden"),
|
||||
(-32004, "connection_lost"),
|
||||
(-32005, "tool_server_gone"),
|
||||
(-32006, "session_not_found"),
|
||||
(-32008, "session_draining"),
|
||||
(-32011, "tool_not_found"),
|
||||
(-32012, "tool_already_registered"),
|
||||
(-32013, "tool_unavailable"),
|
||||
(-32014, "stale_generation"),
|
||||
(-32015, "duplicate_client_name"),
|
||||
(-32016, "tool_busy"),
|
||||
(-32017, "notification_schema_violation"),
|
||||
(-32018, "frame_too_large"),
|
||||
(-32019, "schema_unknown_kind"),
|
||||
(-32020, "behavior_version_unsupported"),
|
||||
(-32021, "server_id_in_use"),
|
||||
(-32022, "invalid_description"),
|
||||
(-32023, "render_limited"),
|
||||
(-32024, "terminal_error"),
|
||||
(-32099, "rate_limited"),
|
||||
];
|
||||
|
||||
/// Returns `None` for strings not in the table. Receivers should fall
|
||||
/// back to `-32603 internal_error` for unknown strings.
|
||||
pub fn numeric_for(code_str: &str) -> Option<i32> {
|
||||
ERROR_CODES
|
||||
.iter()
|
||||
.find_map(|(n, s)| (*s == code_str).then_some(*n))
|
||||
}
|
||||
|
||||
/// Returns `None` for codes not in the table.
|
||||
pub fn string_for(code: i32) -> Option<&'static str> {
|
||||
ERROR_CODES
|
||||
.iter()
|
||||
.find_map(|(n, s)| (*n == code).then_some(*s))
|
||||
}
|
||||
|
||||
/// Numeric code most-appropriate for a [`ToolErrorWire`] variant.
|
||||
/// `Custom` always maps to `-32603 internal_error` since its `code`
|
||||
/// string is not in the table by definition.
|
||||
pub fn from_tool_error_wire(err: &ToolErrorWire) -> i32 {
|
||||
match err {
|
||||
ToolErrorWire::ToolNotFound { .. } => -32011,
|
||||
ToolErrorWire::SessionMismatch => -32600,
|
||||
ToolErrorWire::PermissionDenied { .. } => -32003,
|
||||
ToolErrorWire::TransportClosed { .. } => -32004,
|
||||
ToolErrorWire::Timeout { .. } => -32001,
|
||||
ToolErrorWire::Cancelled { .. } => -32603,
|
||||
ToolErrorWire::InvalidArguments { .. } => -32602,
|
||||
ToolErrorWire::Execution { .. } => -32603,
|
||||
ToolErrorWire::UnsupportedProtocolVersion { .. } => -32605,
|
||||
ToolErrorWire::PayloadTooLarge { .. } => -32018,
|
||||
ToolErrorWire::BehaviorVersionUnsupported { .. } => -32020,
|
||||
ToolErrorWire::Internal { .. } => -32603,
|
||||
ToolErrorWire::RenderLimited { .. } => -32023,
|
||||
ToolErrorWire::TerminalError { .. } => -32024,
|
||||
ToolErrorWire::Custom { .. } => -32603,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identifier for "this session's workspace (tool) server is gone;
|
||||
/// re-provision and retry", used as both the [`ToolErrorWire::Custom`] subcode
|
||||
/// and the `details["code"]` value. Reusing `Custom` (not a new variant) keeps
|
||||
/// the frame deserializable on older peers.
|
||||
pub const WORKSPACE_UNAVAILABLE_SUBCODE: &str = "workspace_unavailable";
|
||||
|
||||
/// Generic, tenant-data-free message paired with the workspace-gone error.
|
||||
pub const WORKSPACE_UNAVAILABLE_MESSAGE: &str = "workspace server gone; re-provision and retry";
|
||||
|
||||
/// JSON-RPC envelope code paired with the workspace-unavailable error. Shares
|
||||
/// the canonical `tool_server_gone` numeric; recognizers key on `data.subcode`,
|
||||
/// not this companion.
|
||||
pub const WORKSPACE_UNAVAILABLE_JSONRPC_CODE: i32 = -32005;
|
||||
|
||||
/// Why the workspace (tool) server went away. `Unknown` absorbs values a newer
|
||||
/// peer may add, so the typed parse never fails across independently-deployed
|
||||
/// hub/SDK versions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceGoneReason {
|
||||
IdleTimeout,
|
||||
Disconnect,
|
||||
Shutdown,
|
||||
/// No owner has bound a tool-server for the session yet (an attach-time
|
||||
/// miss), as opposed to a workspace that was bound and then lost.
|
||||
NotBound,
|
||||
/// Target hub liveness key absent (origin reaper or forward-time check).
|
||||
InstanceGone,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// When, relative to the failing tool call, the loss was observed. `Unknown`
|
||||
/// absorbs values a newer peer may add.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceGonePhase {
|
||||
InFlightCancelled,
|
||||
RouteMissing,
|
||||
/// Observed while resolving a `session_attach_server` request.
|
||||
Attach,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Structured payload placed in the wire `details` object. `code` mirrors the
|
||||
/// `Custom` subcode (the `ToolError::custom` convention), so it survives a
|
||||
/// `Wire → ToolError → Wire` round-trip and is the field recognizers read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceUnavailableDetails {
|
||||
pub code: String,
|
||||
pub reason: WorkspaceGoneReason,
|
||||
pub phase: WorkspaceGonePhase,
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
/// Build the recognizable "workspace gone" error as a [`ToolErrorWire::Custom`].
|
||||
pub fn workspace_unavailable_wire(
|
||||
reason: WorkspaceGoneReason,
|
||||
phase: WorkspaceGonePhase,
|
||||
) -> ToolErrorWire {
|
||||
let details = serde_json::to_value(WorkspaceUnavailableDetails {
|
||||
code: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
|
||||
reason,
|
||||
phase,
|
||||
retryable: true,
|
||||
});
|
||||
// This plain struct serializes infallibly; a missing `details` would make
|
||||
// the error unrecognizable, so guard the invariant in debug builds.
|
||||
debug_assert!(details.is_ok(), "workspace details must serialize");
|
||||
ToolErrorWire::Custom {
|
||||
subcode: WORKSPACE_UNAVAILABLE_SUBCODE.to_owned(),
|
||||
message: WORKSPACE_UNAVAILABLE_MESSAGE.to_owned(),
|
||||
details: details.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const REASONS: [WorkspaceGoneReason; 5] = [
|
||||
WorkspaceGoneReason::IdleTimeout,
|
||||
WorkspaceGoneReason::Disconnect,
|
||||
WorkspaceGoneReason::Shutdown,
|
||||
WorkspaceGoneReason::NotBound,
|
||||
WorkspaceGoneReason::InstanceGone,
|
||||
];
|
||||
const PHASES: [WorkspaceGonePhase; 3] = [
|
||||
WorkspaceGonePhase::InFlightCancelled,
|
||||
WorkspaceGonePhase::RouteMissing,
|
||||
WorkspaceGonePhase::Attach,
|
||||
];
|
||||
|
||||
// Exhaustive-match helpers pin the exact snake_case wire strings; adding a
|
||||
// variant forces an update here.
|
||||
fn reason_wire(r: WorkspaceGoneReason) -> &'static str {
|
||||
match r {
|
||||
WorkspaceGoneReason::IdleTimeout => "idle_timeout",
|
||||
WorkspaceGoneReason::Disconnect => "disconnect",
|
||||
WorkspaceGoneReason::Shutdown => "shutdown",
|
||||
WorkspaceGoneReason::NotBound => "not_bound",
|
||||
WorkspaceGoneReason::InstanceGone => "instance_gone",
|
||||
WorkspaceGoneReason::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
fn phase_wire(p: WorkspaceGonePhase) -> &'static str {
|
||||
match p {
|
||||
WorkspaceGonePhase::InFlightCancelled => "in_flight_cancelled",
|
||||
WorkspaceGonePhase::RouteMissing => "route_missing",
|
||||
WorkspaceGonePhase::Attach => "attach",
|
||||
WorkspaceGonePhase::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_emits_custom_with_code_in_details_for_every_reason_and_phase() {
|
||||
for reason in REASONS {
|
||||
for phase in PHASES {
|
||||
let v = serde_json::to_value(workspace_unavailable_wire(reason, phase)).unwrap();
|
||||
assert_eq!(v["code"], json!("custom"), "outer discriminator");
|
||||
assert_eq!(v["subcode"], json!(WORKSPACE_UNAVAILABLE_SUBCODE));
|
||||
// details.code mirrors the subcode (round-trip identity).
|
||||
assert_eq!(v["details"]["code"], json!(WORKSPACE_UNAVAILABLE_SUBCODE));
|
||||
assert_eq!(v["details"]["reason"], json!(reason_wire(reason)));
|
||||
assert_eq!(v["details"]["phase"], json!(phase_wire(phase)));
|
||||
assert_eq!(v["details"]["retryable"], json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_uses_the_pinned_generic_message() {
|
||||
let ToolErrorWire::Custom { message, .. } = workspace_unavailable_wire(
|
||||
WorkspaceGoneReason::IdleTimeout,
|
||||
WorkspaceGonePhase::RouteMissing,
|
||||
) else {
|
||||
panic!("expected Custom variant");
|
||||
};
|
||||
// Exact, tenant-data-free contract.
|
||||
assert_eq!(message, WORKSPACE_UNAVAILABLE_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reason_and_phase_deserialize_to_unknown() {
|
||||
// Independently-deployed peers may emit reason/phase values this build
|
||||
// does not know; the typed parse must absorb them, not fail.
|
||||
let details: WorkspaceUnavailableDetails = serde_json::from_value(json!({
|
||||
"code": WORKSPACE_UNAVAILABLE_SUBCODE,
|
||||
"reason": "reason_from_a_newer_hub",
|
||||
"phase": "phase_from_a_newer_hub",
|
||||
"retryable": true,
|
||||
}))
|
||||
.expect("typed parse tolerates unknown enum values");
|
||||
assert_eq!(details.reason, WorkspaceGoneReason::Unknown);
|
||||
assert_eq!(details.phase, WorkspaceGonePhase::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reason_serializes_and_round_trips() {
|
||||
// The route-missing classifier emits `Unknown` ("cause not observed"),
|
||||
// so — despite `Unknown` being the `#[serde(other)]` deserialize
|
||||
// catch-all — it must serialize to a stable `"unknown"` label and parse
|
||||
// back, both in the wire payload and as the bare enum.
|
||||
assert_eq!(
|
||||
serde_json::to_value(WorkspaceGoneReason::Unknown).unwrap(),
|
||||
json!("unknown"),
|
||||
);
|
||||
let wire = workspace_unavailable_wire(
|
||||
WorkspaceGoneReason::Unknown,
|
||||
WorkspaceGonePhase::RouteMissing,
|
||||
);
|
||||
let v = serde_json::to_value(&wire).unwrap();
|
||||
assert_eq!(v["details"]["reason"], json!("unknown"));
|
||||
let parsed: WorkspaceUnavailableDetails =
|
||||
serde_json::from_value(v["details"].clone()).expect("details round-trip");
|
||||
assert_eq!(parsed.reason, WorkspaceGoneReason::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_variant_tolerates_unknown_future_details_shape() {
|
||||
// An unknown subcode + richer future details must still deserialize rather than failing the frame.
|
||||
let future = json!({
|
||||
"code": "custom",
|
||||
"subcode": "some_future_subcode",
|
||||
"message": "from a newer peer",
|
||||
"details": {
|
||||
"code": "some_future_subcode",
|
||||
"extra_new_field": {"nested": [1, 2, 3]},
|
||||
},
|
||||
});
|
||||
let wire: ToolErrorWire =
|
||||
serde_json::from_value(future).expect("custom variant deserializes");
|
||||
let ToolErrorWire::Custom {
|
||||
subcode, details, ..
|
||||
} = &wire
|
||||
else {
|
||||
panic!("expected Custom variant");
|
||||
};
|
||||
assert_eq!(subcode, "some_future_subcode");
|
||||
assert!(
|
||||
details
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("extra_new_field"))
|
||||
.is_some(),
|
||||
"unknown details fields are preserved",
|
||||
);
|
||||
// Re-serialization preserves the unknown fields.
|
||||
let reser = serde_json::to_value(&wire).unwrap();
|
||||
assert_eq!(
|
||||
reser["details"]["extra_new_field"]["nested"],
|
||||
json!([1, 2, 3])
|
||||
);
|
||||
}
|
||||
}
|
||||
96
crates/common/xai-tool-protocol/src/error_wire.rs
Normal file
96
crates/common/xai-tool-protocol/src/error_wire.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Wire-friendly error type carried inside the JSON-RPC `error.data` field.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RequestId, ToolId};
|
||||
|
||||
/// Stable wire representation of a tool-call failure.
|
||||
///
|
||||
/// Receivers SHOULD switch on the `code` discriminator (e.g.
|
||||
/// `tool_not_found`) rather than the numeric JSON-RPC `error.code`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
|
||||
#[serde(tag = "code", rename_all = "snake_case")]
|
||||
pub enum ToolErrorWire {
|
||||
#[error("tool not found: {tool_id}")]
|
||||
ToolNotFound { tool_id: ToolId },
|
||||
|
||||
#[error("session mismatch")]
|
||||
SessionMismatch,
|
||||
|
||||
#[serde(rename = "forbidden")]
|
||||
#[error("permission denied: {reason}")]
|
||||
PermissionDenied { reason: String },
|
||||
|
||||
#[serde(rename = "connection_lost")]
|
||||
#[error("transport closed for {tool_id}")]
|
||||
TransportClosed { tool_id: ToolId },
|
||||
|
||||
#[error("timeout after {elapsed_ms}ms for {tool_id}")]
|
||||
Timeout { tool_id: ToolId, elapsed_ms: u64 },
|
||||
|
||||
#[error("cancelled")]
|
||||
Cancelled { tool_id: ToolId },
|
||||
|
||||
#[serde(rename = "invalid_params")]
|
||||
#[error("invalid arguments: {message}")]
|
||||
InvalidArguments {
|
||||
message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
details: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
#[error("execution error in {tool_id}: {message}")]
|
||||
Execution { tool_id: ToolId, message: String },
|
||||
|
||||
#[error("unsupported protocol version")]
|
||||
UnsupportedProtocolVersion { supported: Vec<String> },
|
||||
|
||||
#[serde(rename = "frame_too_large")]
|
||||
#[error("payload too large: {bytes} bytes (limit {limit})")]
|
||||
PayloadTooLarge { bytes: u64, limit: u64 },
|
||||
|
||||
#[error("behavior_version unsupported")]
|
||||
BehaviorVersionUnsupported { tool_id: ToolId, requested: String },
|
||||
|
||||
/// Render-card budget exceeded for the current session. `card_id`
|
||||
/// carries the offending render-card identifier when known; `reason`
|
||||
/// is a free-form human-readable explanation.
|
||||
#[error("render limited for {tool_id}: {reason}")]
|
||||
RenderLimited {
|
||||
tool_id: ToolId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
card_id: Option<String>,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Terminal-tool subprocess sub-call failed. Distinct from
|
||||
/// `Execution` because terminal sub-call failures have a known,
|
||||
/// retry-eligible shape.
|
||||
#[error("terminal subprocess error in {tool_id}: {message}")]
|
||||
TerminalError { tool_id: ToolId, message: String },
|
||||
|
||||
#[serde(rename = "internal_error")]
|
||||
#[error("internal error{}", .detail.as_deref().map(|d| format!(": {d}")).unwrap_or_default())]
|
||||
Internal {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<RequestId>,
|
||||
/// Bounded, human-readable cause of the internal error. Optional for
|
||||
/// wire compatibility with older peers; producers SHOULD populate it
|
||||
/// (truncated at the producer) so receivers can distinguish failure
|
||||
/// modes without correlating server logs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
|
||||
/// Free-form forward-compat error. The outer `code` discriminator is
|
||||
/// always the literal `"custom"`; the producer-supplied subcode lives
|
||||
/// in `subcode` (the field can't be named `code` because it would
|
||||
/// collide with the serde discriminator).
|
||||
#[error("custom: {subcode} — {message}")]
|
||||
Custom {
|
||||
subcode: String,
|
||||
message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
details: Option<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
1549
crates/common/xai-tool-protocol/src/frames.rs
Normal file
1549
crates/common/xai-tool-protocol/src/frames.rs
Normal file
File diff suppressed because it is too large
Load diff
52
crates/common/xai-tool-protocol/src/handshake.rs
Normal file
52
crates/common/xai-tool-protocol/src/handshake.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! Handshake messages exchanged immediately after the WebSocket upgrade.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ConnectionId, ConnectionKind, ServerId, UserId};
|
||||
|
||||
/// Wire-protocol version both ends speak. Bumped when an incompatible
|
||||
/// schema change lands; minor additions go through capability
|
||||
/// negotiation rather than a version bump.
|
||||
pub const PROTOCOL_VERSION: &str = "1.0.0";
|
||||
|
||||
/// First frame sent by the client after the WebSocket upgrade succeeds.
|
||||
///
|
||||
/// No session ids are carried at handshake time. The connection starts with
|
||||
/// an empty bound-session set and binds sessions dynamically over its
|
||||
/// lifetime via `register_session` / `unregister_session` JSON-RPC calls.
|
||||
///
|
||||
/// Tool-server connections carry `server_id` so the hub can
|
||||
/// identify the server without a separate `register_server` call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HelloMsg {
|
||||
pub protocol_version: String,
|
||||
pub kind: ConnectionKind,
|
||||
/// Stable server identity. Only set for
|
||||
/// [`ConnectionKind::ToolServer`] connections.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_id: Option<ServerId>,
|
||||
/// One-line server description for `servers.list`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Opaque metadata surfaced in `ServerInfo.metadata`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Computer hub's reply to [`HelloMsg`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HelloAckMsg {
|
||||
pub connection_id: ConnectionId,
|
||||
/// Hub-derived user identity. The hub resolves this from the
|
||||
/// upgrade credential (JWT `sub`, local-dev hash, etc.) so the
|
||||
/// client never needs to announce it.
|
||||
pub user_id: UserId,
|
||||
pub computer_hub_version: String,
|
||||
pub supported_protocol_versions: Vec<String>,
|
||||
/// Optional JSON-RPC methods this hub supports beyond the base
|
||||
/// protocol (wire method strings, e.g. `"session_attach_server"`).
|
||||
/// Absent on hubs predating the field; additive, so clients gate
|
||||
/// per-call fallbacks on membership instead of probing.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
22
crates/common/xai-tool-protocol/src/hook.rs
Normal file
22
crates/common/xai-tool-protocol/src/hook.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Hook events delivered from the harness to tools.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Internally-tagged hook payload. New variants land alongside `Custom`,
|
||||
/// which keeps unknown future kinds round-trippable.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HookEvent {
|
||||
/// Cancel an in-flight call. The owning `tool_call_id` travels in the
|
||||
/// enclosing `hook` frame.
|
||||
Cancel,
|
||||
Pause,
|
||||
Resume,
|
||||
/// Broadcast to every tool server bound to the session.
|
||||
SessionEnded,
|
||||
/// Forward-compatible escape hatch.
|
||||
Custom {
|
||||
kind: String,
|
||||
payload: serde_json::Value,
|
||||
},
|
||||
}
|
||||
241
crates/common/xai-tool-protocol/src/ids.rs
Normal file
241
crates/common/xai-tool-protocol/src/ids.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! Identifier newtypes.
|
||||
//!
|
||||
//! Every wire-traveling id has a dedicated newtype to prevent accidental
|
||||
//! mixing (e.g. passing a `SessionId` where a `ToolId` is expected).
|
||||
//! Constructors validate; `Deserialize` re-uses the constructor, so values
|
||||
//! that round-trip from the wire share the same invariants as values built
|
||||
//! locally.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Errors produced by id constructors and validators.
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IdError {
|
||||
#[error("identifier must not be empty")]
|
||||
Empty,
|
||||
#[error("identifier {value:?} has invalid format")]
|
||||
InvalidFormat { value: String },
|
||||
#[error("identifier {value:?} uses a reserved prefix")]
|
||||
ReservedPrefix { value: String },
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_id_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_' || c == '-'
|
||||
}
|
||||
|
||||
fn is_valid_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s.chars().all(is_id_char)
|
||||
}
|
||||
|
||||
fn ensure_non_empty(s: &str) -> Result<(), IdError> {
|
||||
if s.is_empty() {
|
||||
Err(IdError::Empty)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a string-backed opaque id newtype.
|
||||
///
|
||||
/// Emits `new`, `as_str`, `into_inner`, `AsRef<str>`, `Display`, `FromStr`,
|
||||
/// `TryFrom<String>`, and a validating `Deserialize` (which routes through
|
||||
/// `Self::new`). `Serialize` is derived transparently.
|
||||
///
|
||||
/// An optional `extra_validator = $path` clause accepts a
|
||||
/// `fn(&str) -> Result<(), IdError>` that runs after the empty-string
|
||||
/// check.
|
||||
macro_rules! opaque_id {
|
||||
($(#[$meta:meta])* $name:ident $(, extra_validator = $validator:path)?) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
/// Construct, validating the id's invariants.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, IdError> {
|
||||
let value = value.into();
|
||||
ensure_non_empty(&value)?;
|
||||
$($validator(&value)?;)?
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for $name {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for $name {
|
||||
type Err = IdError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for $name {
|
||||
type Error = IdError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::new(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
opaque_id!(
|
||||
/// Session identifier. Service-issued or carried from a JWT claim.
|
||||
SessionId
|
||||
);
|
||||
opaque_id!(
|
||||
/// User identifier (the JWT `sub` claim).
|
||||
UserId
|
||||
);
|
||||
opaque_id!(
|
||||
/// Per-connection identifier issued by the computer hub.
|
||||
ConnectionId
|
||||
);
|
||||
opaque_id!(
|
||||
/// JSON-RPC request id as it appears on the wire.
|
||||
RequestId
|
||||
);
|
||||
opaque_id!(
|
||||
/// End-to-end identifier for a single tool invocation.
|
||||
///
|
||||
/// SDKs SHOULD use UUID v7 (see [`ToolCallId::new_v7`]).
|
||||
ToolCallId
|
||||
);
|
||||
|
||||
impl ToolCallId {
|
||||
/// Generate a fresh UUID v7-backed `ToolCallId`.
|
||||
pub fn new_v7() -> Self {
|
||||
Self(uuid::Uuid::now_v7().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_ID_RESERVED_PREFIX: &str = "auto:";
|
||||
|
||||
fn validate_server_id(s: &str) -> Result<(), IdError> {
|
||||
if s.starts_with(SERVER_ID_RESERVED_PREFIX) {
|
||||
return Err(IdError::ReservedPrefix {
|
||||
value: s.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
opaque_id!(
|
||||
/// Server identifier.
|
||||
///
|
||||
/// Opaque non-empty string; the lexical prefix `auto:` is reserved for
|
||||
/// computer-hub-synthesised ids and rejected from client-supplied values.
|
||||
ServerId,
|
||||
extra_validator = validate_server_id
|
||||
);
|
||||
|
||||
impl ServerId {
|
||||
/// Synthesise the deterministic computer-hub-side id for a single-tool
|
||||
/// `register_tool` that omits `server_id`.
|
||||
///
|
||||
/// Bypasses [`ServerId::new`]'s reserved-prefix check.
|
||||
/// `connection_id` is part of the signature so callers can't omit
|
||||
/// the connection scope they are implicitly relying on, even though
|
||||
/// the current encoding does not mix it in. Two connections that
|
||||
/// register the same `tool_id` without an explicit `server_id`
|
||||
/// share the synthesised id but stay distinct in the registry's
|
||||
/// primary `(connection_id, tool_id)` table.
|
||||
pub fn synthesize_for_tool(
|
||||
#[allow(unused_variables)] connection_id: &ConnectionId,
|
||||
tool_id: &ToolId,
|
||||
) -> Self {
|
||||
Self(format!("{SERVER_ID_RESERVED_PREFIX}tool:{tool_id}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_tool_id(s: &str) -> Result<(), IdError> {
|
||||
if !is_well_formed_tool_id(s) {
|
||||
return Err(IdError::InvalidFormat {
|
||||
value: s.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_well_formed_tool_id(s: &str) -> bool {
|
||||
let mut parts = s.splitn(3, ':');
|
||||
let Some(first) = parts.next() else {
|
||||
return false;
|
||||
};
|
||||
match (parts.next(), parts.next()) {
|
||||
(None, _) => is_valid_segment(first),
|
||||
(Some(second), None) => is_valid_segment(first) && is_valid_segment(second),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
opaque_id!(
|
||||
/// Tool identifier.
|
||||
///
|
||||
/// Format: `{namespace}:{name}` or `{name}`. Each segment must match
|
||||
/// `[a-zA-Z0-9_-]+`.
|
||||
ToolId,
|
||||
extra_validator = validate_tool_id
|
||||
);
|
||||
|
||||
/// Per-connection monotonic notification sequence (starts at 0 on every new
|
||||
/// connection).
|
||||
///
|
||||
/// The inner `u64` is private so `new`, `From<u64>`, and `Default` are the
|
||||
/// only construction paths.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct FrameSeq(u64);
|
||||
|
||||
impl FrameSeq {
|
||||
pub const fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FrameSeq {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for FrameSeq {
|
||||
fn from(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
73
crates/common/xai-tool-protocol/src/lib.rs
Normal file
73
crates/common/xai-tool-protocol/src/lib.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! xAI Computer Hub — wire-protocol types.
|
||||
//!
|
||||
//! Identifier newtypes, registration payloads, capabilities, hook events,
|
||||
//! handshake messages, the JSON-RPC 2.0 envelope and method catalog, the
|
||||
//! `ToolErrorWire` / `ToolOutputWire` / `WireToolNotification` wire enums,
|
||||
//! every method's `params` / `result` payload struct, and the numeric ↔
|
||||
//! string error-code mapping.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod capabilities;
|
||||
mod connection;
|
||||
pub mod envelope;
|
||||
pub mod error_codes;
|
||||
pub mod error_wire;
|
||||
pub mod frames;
|
||||
mod handshake;
|
||||
mod hook;
|
||||
mod ids;
|
||||
pub mod methods;
|
||||
pub mod notification_wire;
|
||||
pub mod output_wire;
|
||||
mod registration;
|
||||
mod registry_error;
|
||||
pub mod session_event;
|
||||
pub mod turn_hook;
|
||||
|
||||
pub use capabilities::{HookKind, NotificationSchemas, StreamingSpec, ToolCapabilities, ToolScope};
|
||||
pub use connection::{ConnectionKind, ToolDefinitionMode};
|
||||
pub use envelope::{
|
||||
JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcVersion,
|
||||
ResponseOutcome,
|
||||
};
|
||||
pub use error_codes::{
|
||||
ERROR_CODES, WORKSPACE_UNAVAILABLE_JSONRPC_CODE, WORKSPACE_UNAVAILABLE_MESSAGE,
|
||||
WORKSPACE_UNAVAILABLE_SUBCODE, WorkspaceGonePhase, WorkspaceGoneReason,
|
||||
WorkspaceUnavailableDetails, workspace_unavailable_wire,
|
||||
};
|
||||
pub use error_wire::ToolErrorWire;
|
||||
pub use frames::{
|
||||
AttachRoute, HookFrame, HookReplyFrame, LastSeq, LogsDonateParams, MAX_DONATION_BYTES,
|
||||
MAX_LOG_RECORDS_PER_DONATION, MAX_METRICS_PER_DONATION, MAX_SPANS_PER_DONATION,
|
||||
MAX_SYSTEM_NOTIFY_PAYLOAD_BYTES, MetricsDonateParams, NotificationFilter, PingFrame, PongFrame,
|
||||
ServeParams, ServeResult, ServerBindAck, ServerBindOutcome, ServerBindParams, ServerInfo,
|
||||
ServerUnbindAck, ServerUnbindOutcome, ServerUnbindParams, ServersListParams, ServersListResult,
|
||||
SessionAttachServerParams, SessionAttachServerResult, SessionBindParams, SessionBindResult,
|
||||
SessionBindServerParams, SessionBindServerResult, SessionCloseParams, SessionOpenParams,
|
||||
SessionOpenResult, SessionUnbindParams, SessionUnbindServerParams, SubscribeAck,
|
||||
SubscribeNotificationsParams, SubscribeOutcome, SystemNotifyParams, ToolCallParams,
|
||||
ToolCallProgressFrame, ToolCallResult, ToolNotificationFrame, ToolSearchResult,
|
||||
ToolServerConnectionStatus, ToolServerDisconnectReason, ToolServerEvictParams,
|
||||
ToolServerGetStatusParams, ToolServerGetStatusResult, ToolServerLifecycleStatus,
|
||||
ToolServerStatusPayload, ToolsChanged, ToolsListParams, ToolsListResult, ToolsSearchParams,
|
||||
ToolsSearchResultBody, TracesDonateParams, UnsubscribeAck, UnsubscribeNotificationsParams,
|
||||
UnsubscribeOutcome,
|
||||
};
|
||||
pub use handshake::{HelloAckMsg, HelloMsg, PROTOCOL_VERSION};
|
||||
pub use hook::HookEvent;
|
||||
pub use ids::{
|
||||
ConnectionId, FrameSeq, IdError, RequestId, ServerId, SessionId, ToolCallId, ToolId, UserId,
|
||||
};
|
||||
pub use methods::{Method, UNKNOWN_METHOD_MSG_PREFIX};
|
||||
pub use notification_wire::{
|
||||
KNOWN_NOTIFICATION_KINDS, KnownVariantCollision, WireCustomNotification, WireToolNotification,
|
||||
check_custom_kind, known_notification_kinds,
|
||||
};
|
||||
pub use output_wire::{McpBlock, ToolOutputWire};
|
||||
pub use registration::{
|
||||
RegistrationOutcome, ToolDescriptionWithSchema, ToolRegistration, ToolServerRegistration,
|
||||
TransportKind,
|
||||
};
|
||||
pub use registry_error::RegistryError;
|
||||
pub use session_event::{SessionEvent, SessionPhase, ToolCallOutcome};
|
||||
208
crates/common/xai-tool-protocol/src/methods.rs
Normal file
208
crates/common/xai-tool-protocol/src/methods.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
//! Closed enumeration of every JSON-RPC method on the wire.
|
||||
//!
|
||||
//! Each variant is defined once in the [`define_methods!`] macro invocation
|
||||
//! together with its wire string. The macro generates the enum, serde
|
||||
//! renames, [`Method::as_wire_str`], and [`Method::from_wire_str`] from
|
||||
//! that single source of truth.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! define_methods {
|
||||
(
|
||||
$(
|
||||
$(#[$var_attr:meta])*
|
||||
$variant:ident => $wire:literal
|
||||
),* $(,)?
|
||||
) => {
|
||||
/// Every JSON-RPC method understood by the computer hub.
|
||||
///
|
||||
/// The variants are grouped by direction in source order; the enum is
|
||||
/// flat — direction enforcement is the computer hub's job, not the
|
||||
/// protocol crate's.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Method {
|
||||
$(
|
||||
$(#[$var_attr])*
|
||||
#[serde(rename = $wire)]
|
||||
$variant,
|
||||
)*
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Every `Method` variant, for exhaustive iteration in tests.
|
||||
pub const ALL: &'static [Method] = &[$(Self::$variant,)*];
|
||||
|
||||
/// Wire string for this method. Equivalent to the serde
|
||||
/// serialization but without a round-trip through `serde_json`.
|
||||
pub const fn as_wire_str(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$variant => $wire,)*
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::as_wire_str`]. Returns `None` for
|
||||
/// strings that don't match any known method.
|
||||
pub fn from_wire_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
$($wire => Some(Self::$variant),)*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Message prefix the hub uses when rejecting a request whose `method`
|
||||
/// string does not parse into [`Method`] — the shape an OLD hub produces
|
||||
/// for verbs it predates. Current clients answer hub skew from the
|
||||
/// `hello_ack` `capabilities` advertisement instead of sniffing this
|
||||
/// message, but the shape stays pinned here: terminal binaries built
|
||||
/// while the SDK still keyed old-hub detection on this exact prefix
|
||||
/// remain in the fleet. Do not change casually.
|
||||
pub const UNKNOWN_METHOD_MSG_PREFIX: &str = "unknown method `";
|
||||
|
||||
define_methods! {
|
||||
// harness → service
|
||||
SessionOpen => "session_open",
|
||||
SessionClose => "session_close",
|
||||
SessionBindServer => "session_bind_server",
|
||||
SessionUnbindServer => "session_unbind_server",
|
||||
/// Attach this harness connection to an EXISTING session as an
|
||||
/// observer. Answered hub-locally from the session→tool-server
|
||||
/// routing established by the owner's `session_bind_server` (or the
|
||||
/// server's re-`serve`); never forwarded to the tool server.
|
||||
SessionAttachServer => "session_attach_server",
|
||||
ToolsList => "tools.list",
|
||||
ToolsSearch => "tools.search",
|
||||
ToolCall => "tool.call",
|
||||
/// Sugar for [`Method::Hook`] with [`crate::HookEvent::Cancel`].
|
||||
/// SDKs translate this method to a hook frame before sending; there
|
||||
/// is no separate `tool.cancel` wire frame and no `ToolCancelParams`
|
||||
/// struct in [`crate::frames`].
|
||||
ToolCancel => "tool.cancel",
|
||||
ToolNotify => "tool.notify",
|
||||
SystemNotify => "system.notify",
|
||||
SubscribeNotifications => "subscribe_notifications",
|
||||
UnsubscribeNotifications => "unsubscribe_notifications",
|
||||
Hook => "hook",
|
||||
Hello => "hello",
|
||||
HelloAck => "hello_ack",
|
||||
Ping => "ping",
|
||||
Pong => "pong",
|
||||
|
||||
// tool_server → service
|
||||
ToolCallProgress => "tool_call_progress",
|
||||
ToolNotification => "tool.notification",
|
||||
/// Reply to a request/response hook, correlated back to the harness by `hook_id`.
|
||||
HookReply => "hook_reply",
|
||||
/// Notification (no `id`, no response); rejects surface only in
|
||||
/// hub metrics. Only hub-minted trace-ids are accepted.
|
||||
TracesDonate => "traces.donate",
|
||||
/// Notification (no `id`, no response); rejects surface only in hub
|
||||
/// metrics. Donor service.name must be hub-allowlisted.
|
||||
LogsDonate => "logs.donate",
|
||||
/// Notification (no `id`, no response); rejects surface only in hub
|
||||
/// metrics. Donor service.name must be hub-allowlisted. No envelope
|
||||
/// `session_id` — metrics are process-aggregate.
|
||||
MetricsDonate => "metrics.donate",
|
||||
|
||||
// service → tool_server
|
||||
ToolCallRequest => "tool_call_request",
|
||||
|
||||
// service → harness
|
||||
ToolsChanged => "tools_changed",
|
||||
SubscribeAck => "subscribe_ack",
|
||||
UnsubscribeAck => "unsubscribe_ack",
|
||||
|
||||
// harness → service (server discovery)
|
||||
/// List available tool servers for the authenticated user.
|
||||
ServersList => "servers.list",
|
||||
|
||||
// tool_server status lifecycle
|
||||
ToolServerStatus => "tool_server.status",
|
||||
ToolServerGetStatus => "tool_server.get_status",
|
||||
ToolServerEvict => "tool_server.evict",
|
||||
|
||||
// ── Session lifecycle ───────────────────────────────────────────
|
||||
|
||||
/// Full tool snapshot for a session (server → hub). Idempotent:
|
||||
/// re-sending replaces the tool set; the hub diffs and emits
|
||||
/// `tools_changed`.
|
||||
Serve => "serve",
|
||||
/// Hub requests the server to start serving a session
|
||||
/// (hub → server). The server responds with its tool snapshot.
|
||||
SessionBind => "session.bind",
|
||||
/// Hub tells the server to stop serving a session
|
||||
/// (hub → server). Notification — no response expected.
|
||||
SessionUnbind => "session.unbind",
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Method {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_wire_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_as_wire_str_from_wire_str() {
|
||||
let all = [
|
||||
Method::SessionOpen,
|
||||
Method::SessionClose,
|
||||
Method::SessionBindServer,
|
||||
Method::SessionUnbindServer,
|
||||
Method::SessionAttachServer,
|
||||
Method::ToolsList,
|
||||
Method::ToolsSearch,
|
||||
Method::ToolCall,
|
||||
Method::ToolCancel,
|
||||
Method::ToolNotify,
|
||||
Method::SystemNotify,
|
||||
Method::SubscribeNotifications,
|
||||
Method::UnsubscribeNotifications,
|
||||
Method::Hook,
|
||||
Method::Hello,
|
||||
Method::HelloAck,
|
||||
Method::Ping,
|
||||
Method::Pong,
|
||||
Method::ToolCallProgress,
|
||||
Method::ToolNotification,
|
||||
Method::HookReply,
|
||||
Method::TracesDonate,
|
||||
Method::LogsDonate,
|
||||
Method::MetricsDonate,
|
||||
Method::ToolCallRequest,
|
||||
Method::ToolsChanged,
|
||||
Method::SubscribeAck,
|
||||
Method::UnsubscribeAck,
|
||||
Method::ServersList,
|
||||
Method::ToolServerStatus,
|
||||
Method::ToolServerGetStatus,
|
||||
Method::ToolServerEvict,
|
||||
Method::Serve,
|
||||
Method::SessionBind,
|
||||
Method::SessionUnbind,
|
||||
];
|
||||
for m in all {
|
||||
assert_eq!(Method::from_wire_str(m.as_wire_str()), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_wire_str_returns_none_for_unknown() {
|
||||
assert_eq!(Method::from_wire_str("not_a_method"), None);
|
||||
assert_eq!(Method::from_wire_str(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_matches_wire_str() {
|
||||
let method = Method::ToolCall;
|
||||
let json = serde_json::to_value(method).expect("serialize");
|
||||
assert_eq!(json.as_str(), Some("tool.call"));
|
||||
let back: Method = serde_json::from_value(json).expect("deserialize");
|
||||
assert_eq!(back, method);
|
||||
}
|
||||
}
|
||||
86
crates/common/xai-tool-protocol/src/notification_wire.rs
Normal file
86
crates/common/xai-tool-protocol/src/notification_wire.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Adjacent-tagged notification wire wrapper with a forward-compat
|
||||
//! `Custom` shape.
|
||||
//!
|
||||
//! Adjacent tagging (`#[serde(tag = "shape", content = "value")]`) was
|
||||
//! chosen over `#[serde(untagged)]` to eliminate the spoofing risk where
|
||||
//! a `Custom` payload could silently match a known PascalCase variant.
|
||||
//! The collision check ([`check_custom_kind`]) runs at
|
||||
//! notification-emit time, not at registration time.
|
||||
//!
|
||||
//! Wire shape:
|
||||
//!
|
||||
//! ```jsonc
|
||||
//! { "shape": "known", "value": { "type": "BashOutputChunk", ... } }
|
||||
//! { "shape": "custom", "value": { "kind": "my_tool.progress", "payload": ... } }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Adjacent-tagged notification wire wrapper.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "shape", content = "value", rename_all = "snake_case")]
|
||||
pub enum WireToolNotification {
|
||||
Known(serde_json::Value),
|
||||
Custom(WireCustomNotification),
|
||||
}
|
||||
|
||||
/// Free-form notification payload for kinds the computer hub does not
|
||||
/// recognise. The `kind` MUST NOT collide with a known PascalCase variant
|
||||
/// (see [`check_custom_kind`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireCustomNotification {
|
||||
pub kind: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("custom notification kind {kind:?} collides with a known variant")]
|
||||
pub struct KnownVariantCollision {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// PascalCase variant names of known notification types.
|
||||
///
|
||||
/// Source of truth lives upstream; keep this list in sync. The audit
|
||||
/// test in `tests/notification_collision.rs` round-trips a representative
|
||||
/// of every variant and asserts its `type` discriminator appears here, so
|
||||
/// upstream additions cause a test failure rather than silent drift.
|
||||
pub const KNOWN_NOTIFICATION_KINDS: &[&str] = &[
|
||||
"BashOutputChunk",
|
||||
"BashExecutionComplete",
|
||||
"BashExecutionTimeout",
|
||||
"BashExecutionBackgrounded",
|
||||
"BashExecutionFailed",
|
||||
"FileWritten",
|
||||
"TaskCompleted",
|
||||
"PlanModeEntered",
|
||||
"PlanModeExited",
|
||||
"UserQuestionAsked",
|
||||
"LspServerStarting",
|
||||
"LspServerReady",
|
||||
"LspServerCrashed",
|
||||
"LspServerRetrying",
|
||||
"LspServerFailed",
|
||||
"ScheduledTaskFired",
|
||||
"ScheduledTaskRemoved",
|
||||
"ScheduledTaskCreated",
|
||||
"MonitorEvent",
|
||||
];
|
||||
|
||||
pub const fn known_notification_kinds() -> &'static [&'static str] {
|
||||
KNOWN_NOTIFICATION_KINDS
|
||||
}
|
||||
|
||||
/// Reject custom notification kinds whose name shadows a known PascalCase
|
||||
/// variant. Runs at notification-emit time; an empty `kind` is accepted
|
||||
/// here (the producer is responsible for validating that the field is
|
||||
/// non-empty).
|
||||
pub fn check_custom_kind(kind: &str) -> Result<(), KnownVariantCollision> {
|
||||
if KNOWN_NOTIFICATION_KINDS.contains(&kind) {
|
||||
Err(KnownVariantCollision {
|
||||
kind: kind.to_owned(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
40
crates/common/xai-tool-protocol/src/output_wire.rs
Normal file
40
crates/common/xai-tool-protocol/src/output_wire.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! Wire-friendly tool-call output.
|
||||
//!
|
||||
//! Tool servers may emit `Text`, `Json`, or `Mcp` directly depending
|
||||
//! on the shape needed.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable wire representation of a tool-call output.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum ToolOutputWire {
|
||||
/// Pre-formatted prompt text — the in-process default via
|
||||
/// `ToolOutput::to_prompt_format`.
|
||||
Text(String),
|
||||
/// Opaque JSON escape hatch — mirrors `ToolOutput::Dynamic`.
|
||||
Json(serde_json::Value),
|
||||
/// MCP-style structured blocks.
|
||||
Mcp { blocks: Vec<McpBlock> },
|
||||
}
|
||||
|
||||
/// One block in [`ToolOutputWire::Mcp`]'s `blocks` list.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
mime_type: String,
|
||||
/// Base64-encoded payload.
|
||||
data: String,
|
||||
},
|
||||
Resource {
|
||||
uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
},
|
||||
}
|
||||
164
crates/common/xai-tool-protocol/src/registration.rs
Normal file
164
crates/common/xai-tool-protocol/src/registration.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! Registration payloads, descriptions, transports, and outcomes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
HookKind, IdError, NotificationSchemas, ServerId, SessionId, ToolCapabilities, ToolId, UserId,
|
||||
};
|
||||
|
||||
/// Whether a registered tool runs in-process or behind a remote connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportKind {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
/// A single tool's wire description plus optional schema and capability
|
||||
/// metadata. The `tool_id` is **not** stored explicitly — it is derived
|
||||
/// from `description.{namespace, name}` via [`Self::derive_tool_id`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolDescriptionWithSchema {
|
||||
pub description: xai_tool_types::ToolDescription,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capabilities: Option<ToolCapabilities>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notification_schemas: Option<NotificationSchemas>,
|
||||
}
|
||||
|
||||
impl ToolDescriptionWithSchema {
|
||||
/// Derive the canonical `ToolId`.
|
||||
///
|
||||
/// Namespaced descriptions render as `"{namespace}:{name}"`; otherwise
|
||||
/// the bare `name`. The result is run through [`ToolId::new`], so an
|
||||
/// invalid name or namespace surfaces as an [`IdError`].
|
||||
pub fn derive_tool_id(&self) -> Result<ToolId, IdError> {
|
||||
match &self.description.namespace {
|
||||
Some(ns) => ToolId::new(format!("{ns}:{}", self.description.name)),
|
||||
None => ToolId::new(self.description.name.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-tool registration. Wire-level sugar for a one-tool
|
||||
/// `register_server`.
|
||||
///
|
||||
/// `sessions` carries the per-tool session set with three-state
|
||||
/// semantics, modelled after `if_match_generation: Option<u64>`:
|
||||
///
|
||||
/// - `None` (field omitted on the wire) — "no change". For a
|
||||
/// first-time registration the computer hub treats this as the empty
|
||||
/// set; for a re-registration the existing session bindings are
|
||||
/// preserved untouched. Use this for heartbeat-style re-register
|
||||
/// flows that re-send the description without revisiting the
|
||||
/// session set.
|
||||
/// - `Some(vec![])` (explicit empty array) — "unbind every session".
|
||||
/// The tool stays registered against the connection but becomes
|
||||
/// unreachable from any session until [`crate::Method::BindToolSession`]
|
||||
/// adds a new binding.
|
||||
/// - `Some(vec![s1, ...])` — replace the per-tool session set with
|
||||
/// exactly the listed ids. Each id MUST already be in the
|
||||
/// connection's bound-session set (validated by the router);
|
||||
/// a missing id rejects the entire registration.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolRegistration {
|
||||
/// MUST equal `description.derive_tool_id()`. Carried explicitly so
|
||||
/// receivers can route without re-deriving. The IC service router
|
||||
/// enforces this at register-tool time and rejects mismatches with
|
||||
/// `InvalidRequest`.
|
||||
pub tool_id: ToolId,
|
||||
/// Per-tool session set. See struct doc-comment for the
|
||||
/// `None` / `Some(vec![])` / `Some(vec![...])` semantics.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sessions: Option<Vec<SessionId>>,
|
||||
pub user_id: UserId,
|
||||
/// `None` → computer hub synthesises `auto:tool:{tool_id}`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server_id: Option<ServerId>,
|
||||
pub description: xai_tool_types::ToolDescription,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub capabilities: Option<ToolCapabilities>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notification_schemas: Option<NotificationSchemas>,
|
||||
pub transport_kind: TransportKind,
|
||||
/// Optimistic-concurrency precondition. `None` → last-writer-wins.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub if_match_generation: Option<u64>,
|
||||
/// Opaque metadata supplied by the tool server at registration time.
|
||||
/// Propagated to `ServerInfo.metadata` in `servers.list` responses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ToolRegistration {
|
||||
/// Derive the canonical `ToolId` from `description.{namespace, name}`.
|
||||
/// The `tool_id` payload field MUST equal this value; the IC service
|
||||
/// router enforces the invariant at register-tool time.
|
||||
pub fn derive_tool_id(&self) -> Result<ToolId, IdError> {
|
||||
match &self.description.namespace {
|
||||
Some(ns) => ToolId::new(format!("{ns}:{}", self.description.name)),
|
||||
None => ToolId::new(self.description.name.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-tool registration. The whole batch shares one `server_id` and one
|
||||
/// `sessions` value; per-tool outcomes are reported individually via
|
||||
/// [`RegistrationOutcome`].
|
||||
///
|
||||
/// `sessions` follows the same three-state semantics as
|
||||
/// [`ToolRegistration::sessions`]: `None` means "no change" (preserves
|
||||
/// existing per-tool session bindings on a re-register), `Some(vec![])`
|
||||
/// means "unbind every session for every tool in this batch", and
|
||||
/// `Some(vec![...])` means "replace each tool's session set with
|
||||
/// exactly these ids".
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolServerRegistration {
|
||||
pub server_id: ServerId,
|
||||
/// Per-batch session set. See struct doc-comment for `None` /
|
||||
/// `Some(vec![])` / `Some(vec![...])` semantics.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sessions: Option<Vec<SessionId>>,
|
||||
pub user_id: UserId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
pub tools: Vec<ToolDescriptionWithSchema>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub hooks: Vec<HookKind>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub if_match_generation: Option<u64>,
|
||||
/// Opaque metadata supplied by the tool server at registration time.
|
||||
/// Applied to every tool in the batch and propagated to
|
||||
/// `ServerInfo.metadata` in `servers.list` responses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Per-tool result from a `register_tool` or `register_server` call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum RegistrationOutcome {
|
||||
Registered {
|
||||
tool_id: ToolId,
|
||||
generation: u64,
|
||||
},
|
||||
Updated {
|
||||
tool_id: ToolId,
|
||||
generation: u64,
|
||||
},
|
||||
Shadowed {
|
||||
tool_id: ToolId,
|
||||
reason: String,
|
||||
},
|
||||
Rejected {
|
||||
tool_id: ToolId,
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
47
crates/common/xai-tool-protocol/src/registry_error.rs
Normal file
47
crates/common/xai-tool-protocol/src/registry_error.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//! Serializable registry-level errors.
|
||||
//!
|
||||
//! Variants here are for failures that occur **inside** the registry —
|
||||
//! mismatched session, server-id collisions, optimistic-concurrency stale
|
||||
//! generation. Wire-level transport errors (`tool_not_found`, etc.) live
|
||||
//! in [`crate::ToolErrorWire`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ServerId, SessionId, ToolId};
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "code", rename_all = "snake_case")]
|
||||
pub enum RegistryError {
|
||||
/// A different connection already owns this `(session, tool)`.
|
||||
#[serde(rename = "tool_already_registered")]
|
||||
#[error("tool already registered: {tool_id}")]
|
||||
AlreadyRegistered { tool_id: ToolId },
|
||||
|
||||
/// The registration's session does not match the connection's bound
|
||||
/// session.
|
||||
#[error("session mismatch: token session={token_session}, registration session={reg_session}")]
|
||||
SessionMismatch {
|
||||
token_session: SessionId,
|
||||
reg_session: SessionId,
|
||||
},
|
||||
|
||||
/// `server_id` collides with an active server in this session owned by
|
||||
/// a different connection. Fails the entire `register_*` batch with a
|
||||
/// top-level JSON-RPC error.
|
||||
#[error("server_id {server_id} collides with an active server in this session")]
|
||||
ServerIdCollision { server_id: ServerId },
|
||||
|
||||
/// `server_id` is already in use on this connection by an earlier
|
||||
/// registration with a different tool set.
|
||||
#[error("server_id {server_id} already owned by an earlier registration on this connection")]
|
||||
ServerIdInUse { server_id: ServerId },
|
||||
|
||||
/// Description failed structural validation (e.g. derived `tool_id`
|
||||
/// invalid, reserved prefix on a client-supplied `server_id`).
|
||||
#[error("invalid description: {message}")]
|
||||
InvalidDescription { message: String },
|
||||
|
||||
/// `if_match_generation` precondition failed.
|
||||
#[error("stale generation: expected={expected}, actual={actual}")]
|
||||
StaleGeneration { expected: u64, actual: u64 },
|
||||
}
|
||||
404
crates/common/xai-tool-protocol/src/session_event.rs
Normal file
404
crates/common/xai-tool-protocol/src/session_event.rs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
//! Session lifecycle events designed to ride inside
|
||||
//! `ToolNotificationFrame` as `Custom` notifications with
|
||||
//! `kind = "session_event"`. They will provide a unified view of
|
||||
//! turn/tool activity across both samplers once the emitting side
|
||||
//! is wired up.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::turn_hook::TurnHookOutcome;
|
||||
|
||||
/// Session lifecycle event.
|
||||
///
|
||||
/// Serialized with an internally-tagged `event_type` discriminator so
|
||||
/// consumers can match on the string tag before deserializing the rest.
|
||||
///
|
||||
/// The `Unknown` variant acts as a forward-compatibility catch-all:
|
||||
/// older consumers that encounter a new `event_type` value deserialize
|
||||
/// it as `Unknown` instead of failing. Consumers MUST silently ignore
|
||||
/// `Unknown` events.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "event_type", rename_all = "snake_case")]
|
||||
pub enum SessionEvent {
|
||||
/// Fields mirror [`crate::turn_hook::BeforeTurnPayload`] but are
|
||||
/// structurally independent — this is a notification event, not a
|
||||
/// hook payload.
|
||||
TurnStarted {
|
||||
turn_number: u64,
|
||||
model_id: String,
|
||||
#[serde(default)]
|
||||
yolo_mode: bool,
|
||||
},
|
||||
/// Fields mirror [`crate::turn_hook::AfterTurnPayload`] but are
|
||||
/// structurally independent — this is a notification event, not a
|
||||
/// hook payload.
|
||||
TurnEnded {
|
||||
turn_number: u64,
|
||||
outcome: TurnHookOutcome,
|
||||
duration_ms: u64,
|
||||
tool_call_count: u32,
|
||||
model_id: String,
|
||||
},
|
||||
ToolCallStarted {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
turn_number: u64,
|
||||
},
|
||||
ToolCallCompleted {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
duration_ms: u64,
|
||||
outcome: ToolCallOutcome,
|
||||
},
|
||||
PhaseChanged {
|
||||
phase: SessionPhase,
|
||||
},
|
||||
/// Forward-compatibility catch-all. Older consumers that encounter
|
||||
/// a new `event_type` value deserialize it as `Unknown` instead of
|
||||
/// failing. Consumers MUST silently ignore `Unknown` events.
|
||||
///
|
||||
/// The original `event_type` value is not preserved; consumers that
|
||||
/// need to log unrecognized types should inspect the raw JSON before
|
||||
/// deserializing into `SessionEvent`.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Outcome of a completed tool call within a session event.
|
||||
///
|
||||
/// The `Unknown` variant is a forward-compatibility catch-all for
|
||||
/// variants added in newer protocol versions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallOutcome {
|
||||
Success,
|
||||
Error,
|
||||
Cancelled,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Current phase of the session lifecycle.
|
||||
///
|
||||
/// The `Unknown` variant is a forward-compatibility catch-all for
|
||||
/// phases added in newer protocol versions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPhase {
|
||||
Idle,
|
||||
Sampling,
|
||||
ToolExecution,
|
||||
PermissionPrompt,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── SessionEvent round-trip tests ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn turn_started_round_trip() {
|
||||
let event = SessionEvent::TurnStarted {
|
||||
turn_number: 1,
|
||||
model_id: "grok-3".into(),
|
||||
yolo_mode: true,
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["event_type"], "turn_started");
|
||||
assert_eq!(v["turn_number"], 1);
|
||||
assert_eq!(v["model_id"], "grok-3");
|
||||
assert_eq!(v["yolo_mode"], true);
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_started_yolo_mode_defaults_false() {
|
||||
let v = json!({
|
||||
"event_type": "turn_started",
|
||||
"turn_number": 5,
|
||||
"model_id": "grok-3",
|
||||
});
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SessionEvent::TurnStarted {
|
||||
turn_number: 5,
|
||||
model_id: "grok-3".into(),
|
||||
yolo_mode: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_ended_round_trip() {
|
||||
let event = SessionEvent::TurnEnded {
|
||||
turn_number: 3,
|
||||
outcome: TurnHookOutcome::Completed,
|
||||
duration_ms: 2500,
|
||||
tool_call_count: 7,
|
||||
model_id: "grok-3".into(),
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["event_type"], "turn_ended");
|
||||
assert_eq!(v["outcome"], "completed");
|
||||
assert_eq!(v["duration_ms"], 2500);
|
||||
assert_eq!(v["tool_call_count"], 7);
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_ended_uses_turn_hook_outcome_variants() {
|
||||
for (outcome, expected_str) in [
|
||||
(TurnHookOutcome::Completed, "completed"),
|
||||
(TurnHookOutcome::Cancelled, "cancelled"),
|
||||
(TurnHookOutcome::Error, "error"),
|
||||
] {
|
||||
let event = SessionEvent::TurnEnded {
|
||||
turn_number: 1,
|
||||
outcome,
|
||||
duration_ms: 100,
|
||||
tool_call_count: 0,
|
||||
model_id: "m".into(),
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["outcome"], expected_str, "TurnHookOutcome::{outcome:?}");
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_started_round_trip() {
|
||||
let event = SessionEvent::ToolCallStarted {
|
||||
tool_call_id: "call-42".into(),
|
||||
tool_name: "read_file".into(),
|
||||
turn_number: 2,
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["event_type"], "tool_call_started");
|
||||
assert_eq!(v["tool_call_id"], "call-42");
|
||||
assert_eq!(v["tool_name"], "read_file");
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_completed_round_trip_all_outcomes() {
|
||||
for (outcome, expected_str) in [
|
||||
(ToolCallOutcome::Success, "success"),
|
||||
(ToolCallOutcome::Error, "error"),
|
||||
(ToolCallOutcome::Cancelled, "cancelled"),
|
||||
] {
|
||||
let event = SessionEvent::ToolCallCompleted {
|
||||
tool_call_id: "call-42".into(),
|
||||
tool_name: "read_file".into(),
|
||||
duration_ms: 350,
|
||||
outcome,
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["event_type"], "tool_call_completed");
|
||||
assert_eq!(v["outcome"], expected_str, "ToolCallOutcome::{outcome:?}");
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_changed_round_trip_all_phases() {
|
||||
for (phase, expected_str) in [
|
||||
(SessionPhase::Idle, "idle"),
|
||||
(SessionPhase::Sampling, "sampling"),
|
||||
(SessionPhase::ToolExecution, "tool_execution"),
|
||||
(SessionPhase::PermissionPrompt, "permission_prompt"),
|
||||
] {
|
||||
let event = SessionEvent::PhaseChanged { phase };
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["event_type"], "phase_changed");
|
||||
assert_eq!(v["phase"], expected_str, "SessionPhase::{phase:?}");
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
|
||||
// ── #[serde(other)] backward-compat ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unknown_event_type_deserializes_as_unknown() {
|
||||
let v = json!({ "event_type": "some_future_event", "extra": 123 });
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(event, SessionEvent::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_unknown_event_type_deserializes_as_unknown() {
|
||||
let v = json!({ "event_type": "metrics_snapshot", "ts": 0 });
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(event, SessionEvent::Unknown);
|
||||
}
|
||||
|
||||
// ── ToolCallOutcome serialization ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_call_outcome_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(ToolCallOutcome::Success, "success"),
|
||||
(ToolCallOutcome::Error, "error"),
|
||||
(ToolCallOutcome::Cancelled, "cancelled"),
|
||||
(ToolCallOutcome::Unknown, "unknown"),
|
||||
] {
|
||||
let v = serde_json::to_value(variant).unwrap();
|
||||
assert_eq!(v.as_str(), Some(expected), "ToolCallOutcome::{variant:?}");
|
||||
let back: ToolCallOutcome = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, variant);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SessionPhase serialization ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn session_phase_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(SessionPhase::Idle, "idle"),
|
||||
(SessionPhase::Sampling, "sampling"),
|
||||
(SessionPhase::ToolExecution, "tool_execution"),
|
||||
(SessionPhase::PermissionPrompt, "permission_prompt"),
|
||||
(SessionPhase::Unknown, "unknown"),
|
||||
] {
|
||||
let v = serde_json::to_value(variant).unwrap();
|
||||
assert_eq!(v.as_str(), Some(expected), "SessionPhase::{variant:?}");
|
||||
let back: SessionPhase = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, variant);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forward-compat: inner enum Unknown ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_call_outcome_unknown_variant_on_future_value() {
|
||||
let back: ToolCallOutcome = serde_json::from_value(json!("timeout")).unwrap();
|
||||
assert_eq!(back, ToolCallOutcome::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_phase_unknown_variant_on_future_value() {
|
||||
let back: SessionPhase = serde_json::from_value(json!("cleanup")).unwrap();
|
||||
assert_eq!(back, SessionPhase::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_completed_with_unknown_outcome_deserializes() {
|
||||
let v = json!({
|
||||
"event_type": "tool_call_completed",
|
||||
"tool_call_id": "call-99",
|
||||
"tool_name": "future_tool",
|
||||
"duration_ms": 42,
|
||||
"outcome": "timeout",
|
||||
});
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SessionEvent::ToolCallCompleted {
|
||||
tool_call_id: "call-99".into(),
|
||||
tool_name: "future_tool".into(),
|
||||
duration_ms: 42,
|
||||
outcome: ToolCallOutcome::Unknown,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_changed_with_unknown_phase_deserializes() {
|
||||
let v = json!({
|
||||
"event_type": "phase_changed",
|
||||
"phase": "cleanup",
|
||||
});
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SessionEvent::PhaseChanged {
|
||||
phase: SessionPhase::Unknown,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Unknown variant serialization ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn unknown_variant_serializes_as_expected() {
|
||||
let v = serde_json::to_value(SessionEvent::Unknown).unwrap();
|
||||
assert_eq!(v, json!({"event_type": "unknown"}));
|
||||
}
|
||||
|
||||
// ── Extra/unknown fields on known variants ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn extra_fields_ignored_on_known_variant() {
|
||||
let v = json!({
|
||||
"event_type": "turn_started",
|
||||
"turn_number": 1,
|
||||
"model_id": "grok-3",
|
||||
"future_field": "should be ignored",
|
||||
});
|
||||
let event: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SessionEvent::TurnStarted {
|
||||
turn_number: 1,
|
||||
model_id: "grok-3".into(),
|
||||
yolo_mode: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Negative: missing required fields ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn turn_ended_missing_required_field_rejected() {
|
||||
let v = json!({
|
||||
"event_type": "turn_ended",
|
||||
"turn_number": 1,
|
||||
"duration_ms": 100,
|
||||
"tool_call_count": 0,
|
||||
"model_id": "grok-3",
|
||||
// missing "outcome"
|
||||
});
|
||||
assert!(serde_json::from_value::<SessionEvent>(v).is_err());
|
||||
}
|
||||
|
||||
// ── Boundary values ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn turn_number_zero_and_max() {
|
||||
for turn_number in [0, u64::MAX] {
|
||||
let event = SessionEvent::TurnStarted {
|
||||
turn_number,
|
||||
model_id: "m".into(),
|
||||
yolo_mode: false,
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_ms_zero() {
|
||||
let event = SessionEvent::TurnEnded {
|
||||
turn_number: 0,
|
||||
outcome: TurnHookOutcome::Completed,
|
||||
duration_ms: 0,
|
||||
tool_call_count: 0,
|
||||
model_id: "m".into(),
|
||||
};
|
||||
let v = serde_json::to_value(&event).unwrap();
|
||||
assert_eq!(v["duration_ms"], 0);
|
||||
let back: SessionEvent = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, event);
|
||||
}
|
||||
}
|
||||
700
crates/common/xai-tool-protocol/src/turn_hook.rs
Normal file
700
crates/common/xai-tool-protocol/src/turn_hook.rs
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
//! Turn lifecycle hook payload types for `HookEvent::Custom`.
|
||||
//!
|
||||
//! These types ride inside `HookEvent::Custom { kind, payload }` and
|
||||
//! provide typed serialization for `before_turn` and `after_turn`
|
||||
//! custom hook payloads. They are NOT new `HookEvent` variants.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Well-known `HookEvent::Custom` kind string for before-turn hooks.
|
||||
pub const BEFORE_TURN_KIND: &str = "before_turn";
|
||||
|
||||
/// Well-known `HookEvent::Custom` kind string for after-turn hooks.
|
||||
pub const AFTER_TURN_KIND: &str = "after_turn";
|
||||
|
||||
/// Default `session_relationship` wire value (mirrors
|
||||
/// `xai_file_utils::events::SessionRelationship::Primary`).
|
||||
pub const DEFAULT_SESSION_RELATIONSHIP: &str = "primary";
|
||||
|
||||
/// Default `schema_version` wire value. Bare literal (not the
|
||||
/// `xai-file-utils` constant) to avoid a dependency cycle.
|
||||
pub const DEFAULT_SCHEMA_VERSION: &str = "1.0";
|
||||
|
||||
fn default_session_relationship() -> String {
|
||||
DEFAULT_SESSION_RELATIONSHIP.to_owned()
|
||||
}
|
||||
|
||||
fn default_schema_version() -> String {
|
||||
DEFAULT_SCHEMA_VERSION.to_owned()
|
||||
}
|
||||
|
||||
/// Payload for `before_turn` custom hooks.
|
||||
///
|
||||
/// Sent by the harness before the agent loop begins a new turn.
|
||||
/// Recipients can use this to prepare state (clear caches, initialize
|
||||
/// tracking, etc.) but MUST NOT block — hooks are fire-and-forget.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BeforeTurnPayload {
|
||||
/// Monotonically increasing turn counter within the session.
|
||||
pub turn_number: u64,
|
||||
/// Model being used for this turn (e.g. "grok-3").
|
||||
pub model_id: String,
|
||||
/// Whether the session is in YOLO / auto-approve mode.
|
||||
#[serde(default)]
|
||||
pub yolo_mode: bool,
|
||||
// ── Extended fields (workspace mirrors these into `events.jsonl`);
|
||||
// all `#[serde(default)]` for old-shell / old-workspace interop. ──
|
||||
/// Mirrors `Event::TurnStarted::conversation_message_count`.
|
||||
#[serde(default)]
|
||||
pub conversation_message_count: usize,
|
||||
/// Snake-case mirror of `Event::TurnStarted::session_relationship`
|
||||
/// (`"primary"` | `"subagent"`). A `String`, not the `xai-file-utils`
|
||||
/// enum, to avoid a dependency cycle; decoded by the workspace at emit time.
|
||||
#[serde(default = "default_session_relationship")]
|
||||
pub session_relationship: String,
|
||||
/// Mirrors `Event::TurnStarted::schema_version`.
|
||||
#[serde(default = "default_schema_version")]
|
||||
pub schema_version: String,
|
||||
}
|
||||
|
||||
impl Default for BeforeTurnPayload {
|
||||
/// Mirrors the per-field serde defaults so producers that don't yet track a
|
||||
/// field (e.g. the server-side sampler for `conversation_message_count`) can
|
||||
/// use `..Default::default()` instead of repeating literal stub values.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
turn_number: 0,
|
||||
model_id: String::new(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: default_session_relationship(),
|
||||
schema_version: default_schema_version(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for `after_turn` custom hooks.
|
||||
///
|
||||
/// Sent by the harness after the agent loop completes a turn.
|
||||
///
|
||||
/// **Design note:** This payload carries `tool_call_count` but intentionally
|
||||
/// omits per-tool names. The workspace can correlate tool names from its own
|
||||
/// `ActivityTracker` per-session state if needed. Keeping the payload small
|
||||
/// avoids unbounded growth on tool-heavy turns. `written_repo_paths` is the
|
||||
/// exception: bounded by distinct files edited, not tool-call volume.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AfterTurnPayload {
|
||||
/// Same turn counter as the preceding `before_turn`.
|
||||
pub turn_number: u64,
|
||||
/// High-level outcome of the turn.
|
||||
pub outcome: TurnHookOutcome,
|
||||
/// Wall-clock duration of the turn in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
/// Number of tool calls made during the turn.
|
||||
/// Tool names are intentionally excluded — the workspace can correlate
|
||||
/// from its own `ActivityTracker` if richer data is needed.
|
||||
pub tool_call_count: u32,
|
||||
/// Model used (may differ from `before_turn` if model was switched mid-turn).
|
||||
pub model_id: String,
|
||||
/// Repo-relative agent writes, so proxy-mode workspaces can force-include
|
||||
/// gitignored edits. Empty in local mode.
|
||||
#[serde(default)]
|
||||
pub written_repo_paths: Vec<String>,
|
||||
/// Snake-case mirror of `Event::TurnEnded::cancellation_category` (e.g.
|
||||
/// `"doom_loop_repetition"`). Carried as a `String` for the same
|
||||
/// dep-cycle-avoidance reason as `BeforeTurnPayload::session_relationship`;
|
||||
/// the workspace decodes it into the `xai-file-utils`
|
||||
/// `CancellationCategory` enum at emit time. `None` for non-cancelled turns.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cancellation_category: Option<String>,
|
||||
/// Opaque JSON mirror of `Event::TurnEnded::cancellation_context` (e.g.
|
||||
/// `{ "reason": "max_turns_reached", "limit": 50 }`). Passed through
|
||||
/// verbatim by the workspace. `None` when there is no context.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cancellation_context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Turn outcome as observed by the sampler.
|
||||
///
|
||||
/// Named `TurnHookOutcome` (not `TurnOutcome`) to avoid collision with the
|
||||
/// shell's existing `TurnOutcome` and the telemetry crate's
|
||||
/// `TurnOutcomeLabel`. Module-qualified usage (`turn_hook::TurnHookOutcome`)
|
||||
/// is still recommended in shell code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum TurnHookOutcome {
|
||||
/// Turn completed normally (model finished generating).
|
||||
Completed,
|
||||
/// Turn was cancelled by the user (Ctrl+C / abort).
|
||||
Cancelled,
|
||||
/// Turn ended due to an error.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// `HookEvent::Custom` kind for the request/response turn hook.
|
||||
pub const TURN_HOOK_KIND: &str = "turn_hook";
|
||||
|
||||
/// Request/response turn hook (sampler → bound workspace), internally tagged on `phase`.
|
||||
/// `phase` is a reserved key — `BeforeTurnPayload`/`AfterTurnPayload` must not define a field of that name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "phase", rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum TurnHookRequest {
|
||||
/// Fired just before the sampler begins a new turn (before inference).
|
||||
Before(BeforeTurnPayload),
|
||||
/// Fired just after the sampler completes a turn (tool results are in).
|
||||
After(AfterTurnPayload),
|
||||
}
|
||||
|
||||
/// Conversation role for a turn the workspace asks the sampler to append.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum InjectionRole {
|
||||
/// Append as a system turn.
|
||||
System,
|
||||
/// Append as a developer turn.
|
||||
Developer,
|
||||
/// Append as a user turn (e.g. a `<system-reminder>`-wrapped message).
|
||||
User,
|
||||
}
|
||||
|
||||
/// A single turn the workspace asks the sampler to append before the next sampling step.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HookInjection {
|
||||
/// Role to append the content as.
|
||||
pub role: InjectionRole,
|
||||
/// Verbatim turn content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Override of the sampler's loop decision at a turn boundary.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum TurnControl {
|
||||
/// No override — the sampler proceeds with its own completion logic.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force another turn even if the model ended without a tool call.
|
||||
ForceContinue,
|
||||
/// Force the loop to stop after this turn.
|
||||
ForceStop,
|
||||
}
|
||||
|
||||
/// Reply to a [`TurnHookRequest`]: turns to inject plus a loop-control decision; default (`{}`) is a no-op.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HookReply {
|
||||
/// Turns to append before the next sampling step, in order.
|
||||
#[serde(default)]
|
||||
pub injections: Vec<HookInjection>,
|
||||
/// Optional loop-control override.
|
||||
#[serde(default)]
|
||||
pub control: TurnControl,
|
||||
/// Artifact-handling ack for a [`TurnHookRequest::After`] request; `None`
|
||||
/// on `Before` replies and from workspaces that predate the ack.
|
||||
/// Informational only — the requester never gates its loop on it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after_turn_ack: Option<AfterTurnAckPayload>,
|
||||
}
|
||||
|
||||
/// Terminal status of the workspace's per-turn artifact handling, carried in
|
||||
/// the [`AfterTurnAckPayload`] the workspace sends back to the shell.
|
||||
///
|
||||
/// The variants are wire-stable snake_case strings; the shell routes on them
|
||||
/// to decide how to record the turn's data-collection outcome. The ack
|
||||
/// is informational — the shell never blocks its agent loop on it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AfterTurnAckStatus {
|
||||
/// Every archive the workspace attempted was durably handed off to its
|
||||
/// upload queue (written to the on-disk spill, or an inline-fallback
|
||||
/// upload is in flight). The cloud upload then proceeds independently with
|
||||
/// the queue's own retry policy. The caller MAY advance.
|
||||
Enqueued,
|
||||
/// At least one archive could not be handed off (temp file unwritable,
|
||||
/// queue worker shut down, or the archive build failed). The workspace has
|
||||
/// done what it can — the caller MUST NOT retry.
|
||||
Failed,
|
||||
/// The workspace skipped uploads before touching disk (no upload queue
|
||||
/// configured / not in proxy mode). `error_message` carries the reason.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Artifact-handling ack the workspace returns for a
|
||||
/// [`TurnHookRequest::After`] request on [`HookReply::after_turn_ack`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AfterTurnAckPayload {
|
||||
/// The turn this ack corresponds to (matches `AfterTurnPayload::turn_number`).
|
||||
pub turn_number: u64,
|
||||
/// Terminal artifact-handling status for the turn.
|
||||
pub status: AfterTurnAckStatus,
|
||||
/// Failure / skip reason. `Some` only for [`AfterTurnAckStatus::Failed`] or
|
||||
/// [`AfterTurnAckStatus::Skipped`]; omitted from the wire when `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
/// Count of archives this turn that landed durably on the queue's on-disk
|
||||
/// spill — `0`, `1`, or `2` (before/after repository snapshot archives).
|
||||
/// Informational; defaults to `0` for back-compat.
|
||||
#[serde(default)]
|
||||
pub artifact_count: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn before_turn_round_trip() {
|
||||
let payload = BeforeTurnPayload {
|
||||
turn_number: 42,
|
||||
model_id: "grok-3".to_string(),
|
||||
yolo_mode: true,
|
||||
conversation_message_count: 9,
|
||||
session_relationship: "subagent".to_string(),
|
||||
schema_version: "1.0".to_string(),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"turn_number": 42,
|
||||
"model_id": "grok-3",
|
||||
"yolo_mode": true,
|
||||
"conversation_message_count": 9,
|
||||
"session_relationship": "subagent",
|
||||
"schema_version": "1.0",
|
||||
})
|
||||
);
|
||||
|
||||
let deserialized: BeforeTurnPayload = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_turn_yolo_mode_defaults_false() {
|
||||
let json = json!({
|
||||
"turn_number": 1,
|
||||
"model_id": "grok-3",
|
||||
});
|
||||
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
||||
assert!(!payload.yolo_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_round_trip() {
|
||||
// Completed turn: both cancellation fields are `None` and therefore
|
||||
// skip serialization — the wire shape is byte-identical to the legacy shape.
|
||||
let payload = AfterTurnPayload {
|
||||
turn_number: 42,
|
||||
outcome: TurnHookOutcome::Completed,
|
||||
duration_ms: 1500,
|
||||
tool_call_count: 3,
|
||||
model_id: "grok-3".to_string(),
|
||||
written_repo_paths: vec!["outputs/result.md".to_string()],
|
||||
cancellation_category: None,
|
||||
cancellation_context: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"turn_number": 42,
|
||||
"outcome": "completed",
|
||||
"duration_ms": 1500,
|
||||
"tool_call_count": 3,
|
||||
"model_id": "grok-3",
|
||||
"written_repo_paths": ["outputs/result.md"],
|
||||
})
|
||||
);
|
||||
|
||||
let deserialized: AfterTurnPayload = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_written_repo_paths_defaults_empty() {
|
||||
let json = json!({
|
||||
"turn_number": 1,
|
||||
"outcome": "completed",
|
||||
"duration_ms": 10,
|
||||
"tool_call_count": 0,
|
||||
"model_id": "grok-3",
|
||||
});
|
||||
let payload: AfterTurnPayload = serde_json::from_value(json).unwrap();
|
||||
assert!(payload.written_repo_paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_round_trip_with_cancellation_fields() {
|
||||
let payload = AfterTurnPayload {
|
||||
turn_number: 7,
|
||||
outcome: TurnHookOutcome::Cancelled,
|
||||
duration_ms: 200,
|
||||
tool_call_count: 1,
|
||||
model_id: "grok-4".to_string(),
|
||||
written_repo_paths: vec![],
|
||||
cancellation_category: Some("doom_loop_repetition".to_string()),
|
||||
cancellation_context: Some(json!({ "reason": "repetition" })),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(
|
||||
serialized["cancellation_category"],
|
||||
json!("doom_loop_repetition")
|
||||
);
|
||||
assert_eq!(
|
||||
serialized["cancellation_context"],
|
||||
json!({ "reason": "repetition" })
|
||||
);
|
||||
|
||||
let deserialized: AfterTurnPayload = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_variants_serialize_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnHookOutcome::Completed).unwrap(),
|
||||
json!("completed"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnHookOutcome::Cancelled).unwrap(),
|
||||
json!("cancelled"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnHookOutcome::Error).unwrap(),
|
||||
json!("error"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_deserializes_from_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TurnHookOutcome>(json!("completed")).unwrap(),
|
||||
TurnHookOutcome::Completed,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TurnHookOutcome>(json!("cancelled")).unwrap(),
|
||||
TurnHookOutcome::Cancelled,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<TurnHookOutcome>(json!("error")).unwrap(),
|
||||
TurnHookOutcome::Error,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_constants() {
|
||||
assert_eq!(BEFORE_TURN_KIND, "before_turn");
|
||||
assert_eq!(AFTER_TURN_KIND, "after_turn");
|
||||
assert_eq!(DEFAULT_SESSION_RELATIONSHIP, "primary");
|
||||
assert_eq!(DEFAULT_SCHEMA_VERSION, "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_outcome_variant_rejected() {
|
||||
let result = serde_json::from_value::<TurnHookOutcome>(json!("timeout"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_missing_required_field_rejected() {
|
||||
let json = json!({
|
||||
"turn_number": 1,
|
||||
"duration_ms": 100,
|
||||
"tool_call_count": 0,
|
||||
"model_id": "grok-3",
|
||||
});
|
||||
assert!(serde_json::from_value::<AfterTurnPayload>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_fields_ignored() {
|
||||
let json = json!({
|
||||
"turn_number": 1,
|
||||
"model_id": "grok-3",
|
||||
"future_field": "should be ignored",
|
||||
});
|
||||
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(payload.turn_number, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_turn_yolo_false_serialized() {
|
||||
let payload = BeforeTurnPayload {
|
||||
turn_number: 1,
|
||||
model_id: "grok-3".to_string(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: "primary".to_string(),
|
||||
schema_version: "1.0".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(serialized["yolo_mode"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_hook_kind_constant() {
|
||||
assert_eq!(TURN_HOOK_KIND, "turn_hook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_hook_request_before_round_trip() {
|
||||
let req = TurnHookRequest::Before(BeforeTurnPayload {
|
||||
turn_number: 7,
|
||||
model_id: "grok-3".to_string(),
|
||||
yolo_mode: true,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: "primary".to_string(),
|
||||
schema_version: "1.0".to_string(),
|
||||
});
|
||||
let serialized = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"phase": "before",
|
||||
"turn_number": 7,
|
||||
"model_id": "grok-3",
|
||||
"yolo_mode": true,
|
||||
"conversation_message_count": 0,
|
||||
"session_relationship": "primary",
|
||||
"schema_version": "1.0",
|
||||
})
|
||||
);
|
||||
let deserialized: TurnHookRequest = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, req);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_hook_request_after_round_trip() {
|
||||
let req = TurnHookRequest::After(AfterTurnPayload {
|
||||
turn_number: 7,
|
||||
outcome: TurnHookOutcome::Completed,
|
||||
duration_ms: 10,
|
||||
tool_call_count: 2,
|
||||
model_id: "grok-3".to_string(),
|
||||
written_repo_paths: Vec::new(),
|
||||
cancellation_category: None,
|
||||
cancellation_context: None,
|
||||
});
|
||||
let serialized = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(serialized["phase"], json!("after"));
|
||||
assert_eq!(serialized["tool_call_count"], json!(2));
|
||||
let deserialized: TurnHookRequest = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, req);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_reply_default_is_empty_auto() {
|
||||
let reply = HookReply::default();
|
||||
assert!(reply.injections.is_empty());
|
||||
assert_eq!(reply.control, TurnControl::Auto);
|
||||
assert_eq!(reply.after_turn_ack, None);
|
||||
// `None` must skip serialization so the default reply stays the legacy
|
||||
// `{}`-compatible shape (old decoders use `deny_unknown_fields`).
|
||||
let serialized = serde_json::to_value(&reply).unwrap();
|
||||
assert!(serialized.get("after_turn_ack").is_none());
|
||||
}
|
||||
|
||||
/// An `After` reply carrying the ack round-trips, and a legacy reply
|
||||
/// without the field decodes with `after_turn_ack == None`.
|
||||
#[test]
|
||||
fn hook_reply_after_turn_ack_round_trip_and_legacy_decode() {
|
||||
let reply = HookReply {
|
||||
injections: vec![],
|
||||
control: TurnControl::Auto,
|
||||
after_turn_ack: Some(AfterTurnAckPayload {
|
||||
turn_number: 7,
|
||||
status: AfterTurnAckStatus::Enqueued,
|
||||
error_message: None,
|
||||
artifact_count: 2,
|
||||
}),
|
||||
};
|
||||
let serialized = serde_json::to_value(&reply).unwrap();
|
||||
assert_eq!(serialized["after_turn_ack"]["turn_number"], json!(7));
|
||||
assert_eq!(serialized["after_turn_ack"]["status"], json!("enqueued"));
|
||||
let deserialized: HookReply = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, reply);
|
||||
|
||||
let legacy: HookReply =
|
||||
serde_json::from_value(json!({"injections": [], "control": "auto"})).unwrap();
|
||||
assert_eq!(legacy.after_turn_ack, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_reply_deserializes_from_empty_object() {
|
||||
let reply: HookReply = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(reply, HookReply::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_reply_rejects_unknown_field() {
|
||||
let result: Result<HookReply, _> =
|
||||
serde_json::from_value(json!({"injection": [], "control": "auto"}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_reply_round_trip() {
|
||||
let reply = HookReply {
|
||||
injections: vec![
|
||||
HookInjection {
|
||||
role: InjectionRole::System,
|
||||
content: "Available channels: response".to_string(),
|
||||
},
|
||||
HookInjection {
|
||||
role: InjectionRole::User,
|
||||
content: "<system-reminder>\nkeep going\n</system-reminder>".to_string(),
|
||||
},
|
||||
],
|
||||
control: TurnControl::ForceContinue,
|
||||
after_turn_ack: None,
|
||||
};
|
||||
let serialized = serde_json::to_value(&reply).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"injections": [
|
||||
{ "role": "system", "content": "Available channels: response" },
|
||||
{
|
||||
"role": "user",
|
||||
"content": "<system-reminder>\nkeep going\n</system-reminder>",
|
||||
},
|
||||
],
|
||||
"control": "force_continue",
|
||||
})
|
||||
);
|
||||
let deserialized: HookReply = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, reply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_control_variants_serialize_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnControl::Auto).unwrap(),
|
||||
json!("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnControl::ForceContinue).unwrap(),
|
||||
json!("force_continue"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(TurnControl::ForceStop).unwrap(),
|
||||
json!("force_stop"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injection_role_serializes_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(InjectionRole::Developer).unwrap(),
|
||||
json!("developer"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Back-compat: a `before_turn` payload from an OLD shell (without the extended fields)
|
||||
/// must still deserialize, with the new fields taking their serde defaults.
|
||||
#[test]
|
||||
fn before_turn_legacy_payload_defaults_new_fields() {
|
||||
let json = json!({
|
||||
"turn_number": 3,
|
||||
"model_id": "grok-3",
|
||||
"yolo_mode": true,
|
||||
});
|
||||
let payload: BeforeTurnPayload = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(payload.conversation_message_count, 0);
|
||||
assert_eq!(payload.session_relationship, DEFAULT_SESSION_RELATIONSHIP);
|
||||
assert_eq!(payload.schema_version, DEFAULT_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
/// Back-compat: an `after_turn` payload from an OLD shell (without the
|
||||
/// cancellation fields) must still deserialize, defaulting both to `None`.
|
||||
#[test]
|
||||
fn after_turn_legacy_payload_defaults_new_fields() {
|
||||
let json = json!({
|
||||
"turn_number": 3,
|
||||
"outcome": "completed",
|
||||
"duration_ms": 10,
|
||||
"tool_call_count": 0,
|
||||
"model_id": "grok-3",
|
||||
});
|
||||
let payload: AfterTurnPayload = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(payload.cancellation_category, None);
|
||||
assert_eq!(payload.cancellation_context, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_ack_status_serializes_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(AfterTurnAckStatus::Enqueued).unwrap(),
|
||||
json!("enqueued"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AfterTurnAckStatus::Failed).unwrap(),
|
||||
json!("failed"),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AfterTurnAckStatus::Skipped).unwrap(),
|
||||
json!("skipped"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_ack_payload_round_trip_enqueued() {
|
||||
// `Enqueued` ack with no error message: `error_message` skips the wire.
|
||||
let payload = AfterTurnAckPayload {
|
||||
turn_number: 42,
|
||||
status: AfterTurnAckStatus::Enqueued,
|
||||
error_message: None,
|
||||
artifact_count: 2,
|
||||
};
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
json!({
|
||||
"turn_number": 42,
|
||||
"status": "enqueued",
|
||||
"artifact_count": 2,
|
||||
})
|
||||
);
|
||||
let deserialized: AfterTurnAckPayload = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_turn_ack_payload_round_trip_failed_carries_message() {
|
||||
let payload = AfterTurnAckPayload {
|
||||
turn_number: 1,
|
||||
status: AfterTurnAckStatus::Failed,
|
||||
error_message: Some("disk budget exhausted".to_string()),
|
||||
artifact_count: 1,
|
||||
};
|
||||
let serialized = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(serialized["status"], json!("failed"));
|
||||
assert_eq!(serialized["error_message"], json!("disk budget exhausted"));
|
||||
assert_eq!(serialized["artifact_count"], json!(1));
|
||||
let deserialized: AfterTurnAckPayload = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized, payload);
|
||||
}
|
||||
|
||||
/// Back-compat: an ack with only the required fields (old sender) defaults
|
||||
/// `artifact_count` to 0 and `error_message` to `None`.
|
||||
#[test]
|
||||
fn after_turn_ack_payload_minimal_defaults() {
|
||||
let json = json!({
|
||||
"turn_number": 5,
|
||||
"status": "skipped",
|
||||
});
|
||||
let payload: AfterTurnAckPayload = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(payload.status, AfterTurnAckStatus::Skipped);
|
||||
assert_eq!(payload.artifact_count, 0);
|
||||
assert_eq!(payload.error_message, None);
|
||||
}
|
||||
}
|
||||
192
crates/common/xai-tool-protocol/tests/identifier_validation.rs
Normal file
192
crates/common/xai-tool-protocol/tests/identifier_validation.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Validation rules for every identifier newtype, plus the synthetic
|
||||
//! `ServerId` helper invariants.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use xai_tool_protocol::{
|
||||
ConnectionId, IdError, RequestId, ServerId, SessionId, ToolCallId, ToolId, UserId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tool_id_accepts_bare_and_namespaced_names() {
|
||||
let bare = ToolId::new("read_file").unwrap();
|
||||
assert_eq!(bare.as_str(), "read_file");
|
||||
|
||||
let namespaced = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
assert_eq!(namespaced.as_str(), "GrokBuild:read_file");
|
||||
|
||||
assert_eq!(
|
||||
ToolId::from_str("github:list_repos").unwrap().as_str(),
|
||||
"github:list_repos"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_empty() {
|
||||
assert_eq!(ToolId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_more_than_one_separator() {
|
||||
let err = ToolId::new("foo:bar:baz").unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
IdError::InvalidFormat {
|
||||
value: "foo:bar:baz".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_disallowed_characters() {
|
||||
for bad in [
|
||||
"foo bar",
|
||||
"foo!bar",
|
||||
"foo/bar",
|
||||
"foo.bar",
|
||||
"foo:bar baz",
|
||||
" foo",
|
||||
"foo ",
|
||||
"foo\tbar",
|
||||
"foo\u{00e9}bar",
|
||||
"f\u{00f6}\u{00f6}bar",
|
||||
] {
|
||||
let err = ToolId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { ref value } if value == bad),
|
||||
"expected InvalidFormat for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_accepts_digits_only_and_other_boundary_inputs() {
|
||||
assert_eq!(ToolId::new("123").unwrap().as_str(), "123");
|
||||
assert_eq!(ToolId::new("v2:42").unwrap().as_str(), "v2:42");
|
||||
assert_eq!(ToolId::new("a").unwrap().as_str(), "a");
|
||||
assert_eq!(ToolId::new("a:b").unwrap().as_str(), "a:b");
|
||||
assert_eq!(ToolId::new("-_-").unwrap().as_str(), "-_-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_empty_segments_around_separator() {
|
||||
for bad in [":foo", "foo:", ":"] {
|
||||
let err = ToolId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { ref value } if value == bad),
|
||||
"expected InvalidFormat for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_try_from_string_works() {
|
||||
let id: ToolId = "GrokBuild:read_file".to_owned().try_into().unwrap();
|
||||
assert_eq!(id.as_str(), "GrokBuild:read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_accepts_arbitrary_non_empty_strings() {
|
||||
for ok in ["my-uuid-v7", "srv_42", "x", "abc.def"] {
|
||||
let s = ServerId::new(ok).unwrap();
|
||||
assert_eq!(s.as_str(), ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_rejects_empty() {
|
||||
assert_eq!(ServerId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_rejects_reserved_auto_prefix() {
|
||||
for bad in ["auto:my-server", "auto:", "auto:tool:read_file"] {
|
||||
let err = ServerId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::ReservedPrefix { ref value } if value == bad),
|
||||
"expected ReservedPrefix for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_starts_with_auto_prefix() {
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let bare = ToolId::new("read_file").unwrap();
|
||||
let synth = ServerId::synthesize_for_tool(&conn, &bare);
|
||||
assert_eq!(synth.as_str(), "auto:tool:read_file");
|
||||
|
||||
let namespaced = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
let synth_ns = ServerId::synthesize_for_tool(&conn, &namespaced);
|
||||
assert_eq!(synth_ns.as_str(), "auto:tool:GrokBuild:read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_is_deterministic() {
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let tool = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
let a = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
let b = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"synthesis must be a pure function of (connection, tool)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_bypasses_reserved_prefix_check() {
|
||||
// The synthesised id starts with `auto:`; the reserved-prefix rule
|
||||
// only applies to client-supplied values via `ServerId::new`.
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let tool = ToolId::new("read_file").unwrap();
|
||||
let synth = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
assert!(synth.as_str().starts_with("auto:"));
|
||||
|
||||
let err = ServerId::new(synth.as_str()).unwrap_err();
|
||||
assert!(matches!(err, IdError::ReservedPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_ids_reject_empty() {
|
||||
assert_eq!(SessionId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(UserId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(ConnectionId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(RequestId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(ToolCallId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_ids_accept_arbitrary_non_empty_strings() {
|
||||
assert_eq!(
|
||||
SessionId::new("anything goes").unwrap().as_str(),
|
||||
"anything goes"
|
||||
);
|
||||
assert_eq!(
|
||||
UserId::new("alice@example.com").unwrap().as_str(),
|
||||
"alice@example.com"
|
||||
);
|
||||
assert_eq!(RequestId::new("req-9c4f").unwrap().as_str(), "req-9c4f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_uuid_v7_helper_is_unique_and_valid_uuid() {
|
||||
let a = ToolCallId::new_v7();
|
||||
let b = ToolCallId::new_v7();
|
||||
assert_ne!(a, b, "two consecutive v7 ids must differ");
|
||||
for id in [&a, &b] {
|
||||
let parsed = uuid::Uuid::parse_str(id.as_str()).expect("parse uuid");
|
||||
assert_eq!(
|
||||
parsed.get_version_num(),
|
||||
7,
|
||||
"expected UUID v7, got {parsed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_id_display_matches_inner_string() {
|
||||
let s = SessionId::new("sess_abc").unwrap();
|
||||
assert_eq!(format!("{s}"), "sess_abc");
|
||||
let t = ToolId::new("github:list_repos").unwrap();
|
||||
assert_eq!(format!("{t}"), "github:list_repos");
|
||||
}
|
||||
265
crates/common/xai-tool-protocol/tests/jsonrpc_envelope.rs
Normal file
265
crates/common/xai-tool-protocol/tests/jsonrpc_envelope.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! Envelope-shape tests for the JSON-RPC 2.0 wrappers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use xai_tool_protocol::{
|
||||
FrameSeq, JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
|
||||
JsonRpcVersion, RequestId, ResponseOutcome, SessionId,
|
||||
};
|
||||
|
||||
fn session() -> SessionId {
|
||||
SessionId::new("sess_abc").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_no_session_id_omits_envelope_field() {
|
||||
let req: JsonRpcRequest<Value> = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-1"),
|
||||
session_id: None,
|
||||
method: "tool.call".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert!(
|
||||
!obj.contains_key("session_id"),
|
||||
"session_id=None must be omitted: {v}"
|
||||
);
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("req-1"));
|
||||
assert_eq!(v["method"], json!("tool.call"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_session_id_includes_envelope_field() {
|
||||
let req: JsonRpcRequest<Value> = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-2"),
|
||||
session_id: Some(session()),
|
||||
method: "tool.call".to_owned(),
|
||||
params: json!({"x": 1}),
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["params"]["x"], json!(1));
|
||||
let parsed: JsonRpcRequest<Value> = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(parsed.id, JsonRpcId::new_string("req-2"));
|
||||
assert_eq!(
|
||||
parsed.session_id.as_ref().map(|s| s.as_str()),
|
||||
Some("sess_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_with_no_seq_omits_envelope_field() {
|
||||
let n: JsonRpcNotification<Value> = JsonRpcNotification {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
session_id: None,
|
||||
seq: None,
|
||||
method: "tool.notification".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert!(!obj.contains_key("seq"));
|
||||
assert!(!obj.contains_key("session_id"));
|
||||
assert!(!obj.contains_key("id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_with_seq_includes_envelope_field() {
|
||||
let n: JsonRpcNotification<Value> = JsonRpcNotification {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
session_id: Some(session()),
|
||||
seq: Some(FrameSeq::new(42)),
|
||||
method: "tool.notification".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(v["seq"], json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_ok_serialises_with_result_only() {
|
||||
let resp: JsonRpcResponse<Value> =
|
||||
JsonRpcResponse::ok(JsonRpcId::new_string("r"), json!({"y": 2}));
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("r"));
|
||||
assert_eq!(v["result"], json!({"y": 2}));
|
||||
assert!(!obj.contains_key("error"), "ok must omit `error`: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_err_serialises_with_error_only() {
|
||||
let resp: JsonRpcResponse<Value> = JsonRpcResponse::err(
|
||||
JsonRpcId::new_string("r"),
|
||||
JsonRpcError {
|
||||
code: -32011,
|
||||
message: "tool not found".to_owned(),
|
||||
data: Some(json!({"code": "tool_not_found"})),
|
||||
},
|
||||
);
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert_eq!(v["error"]["code"], json!(-32011));
|
||||
assert_eq!(v["error"]["data"]["code"], json!("tool_not_found"));
|
||||
assert!(!obj.contains_key("result"), "err must omit `result`: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_round_trips_with_session_envelope() {
|
||||
let resp: JsonRpcResponse<Value> =
|
||||
JsonRpcResponse::ok(JsonRpcId::Number(7), json!({})).with_session(session());
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["id"], json!(7));
|
||||
let parsed: JsonRpcResponse<Value> = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(parsed.id, JsonRpcId::Number(7));
|
||||
match parsed.outcome {
|
||||
ResponseOutcome::Result(_) => {}
|
||||
ResponseOutcome::Error(e) => panic!("expected Result, got Error({e:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_both_result_and_error_fails_to_deserialize() {
|
||||
let bad = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "r",
|
||||
"result": {"x": 1},
|
||||
"error": {"code": -32000, "message": "no"},
|
||||
});
|
||||
let err = serde_json::from_value::<JsonRpcResponse<Value>>(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("XOR"),
|
||||
"expected XOR-violation message, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_neither_result_nor_error_fails_to_deserialize() {
|
||||
let bad = json!({"jsonrpc": "2.0", "id": "r"});
|
||||
let err = serde_json::from_value::<JsonRpcResponse<Value>>(bad).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("`result` or `error`"),
|
||||
"expected exactly-one message, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_error_round_trips_with_and_without_data() {
|
||||
let e_no_data = JsonRpcError {
|
||||
code: -32603,
|
||||
message: "internal".to_owned(),
|
||||
data: None,
|
||||
};
|
||||
let v = serde_json::to_value(&e_no_data).unwrap();
|
||||
assert!(
|
||||
!v.as_object().unwrap().contains_key("data"),
|
||||
"data=None must be omitted: {v}"
|
||||
);
|
||||
let back: JsonRpcError = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, e_no_data);
|
||||
|
||||
let e_with_data = JsonRpcError {
|
||||
code: -32011,
|
||||
message: "tool not found".to_owned(),
|
||||
data: Some(json!({"code": "tool_not_found", "tool_id": "echo"})),
|
||||
};
|
||||
let v = serde_json::to_value(&e_with_data).unwrap();
|
||||
assert_eq!(v["data"]["tool_id"], json!("echo"));
|
||||
let back: JsonRpcError = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, e_with_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_id_accepts_string_and_number_on_request() {
|
||||
let v_str = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "req-9c4f",
|
||||
"method": "tool.call",
|
||||
"params": {},
|
||||
});
|
||||
let req: JsonRpcRequest<Value> = serde_json::from_value(v_str).unwrap();
|
||||
assert_eq!(req.id, JsonRpcId::new_string("req-9c4f"));
|
||||
|
||||
let v_num = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "tool.call",
|
||||
"params": {},
|
||||
});
|
||||
let req: JsonRpcRequest<Value> = serde_json::from_value(v_num).unwrap();
|
||||
assert_eq!(req.id, JsonRpcId::Number(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_id_round_trips_to_request_id_correlator() {
|
||||
let original = RequestId::new("req-42").unwrap();
|
||||
let envelope_id = JsonRpcId::from_request_id(&original);
|
||||
assert_eq!(envelope_id.as_request_id().unwrap(), original);
|
||||
|
||||
// Numeric ids are stringified.
|
||||
let nid = JsonRpcId::Number(7);
|
||||
assert_eq!(nid.as_request_id().unwrap().as_str(), "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_call_envelope_serialises_to_expected_shape() {
|
||||
use xai_tool_protocol::{ToolCallId, ToolCallParams, ToolId};
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-9c4f"),
|
||||
session_id: Some(session()),
|
||||
method: "tool.call".to_owned(),
|
||||
params: ToolCallParams {
|
||||
tool_call_id: ToolCallId::new("call_xyz").unwrap(),
|
||||
tool_id: ToolId::new("GrokBuild:read_file").unwrap(),
|
||||
arguments: json!({"path": "/etc/hosts"}),
|
||||
deadline_ms: None,
|
||||
behavior_version: None,
|
||||
cwd: None,
|
||||
trace_context: None,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("req-9c4f"));
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["method"], json!("tool.call"));
|
||||
assert_eq!(v["params"]["tool_id"], json!("GrokBuild:read_file"));
|
||||
assert_eq!(v["params"]["tool_call_id"], json!("call_xyz"));
|
||||
}
|
||||
|
||||
/// The envelope-level `session_id` and an inner `params.session_id` (e.g.
|
||||
/// on `ToolsListParams`) are independent keys in the wire JSON tree.
|
||||
/// This test pins that invariant so a refactor that accidentally
|
||||
/// collapses the two layers (e.g. via `#[serde(flatten)]`) fails loudly.
|
||||
#[test]
|
||||
fn envelope_session_id_and_inner_params_session_id_are_distinct_layers() {
|
||||
use xai_tool_protocol::{ToolDefinitionMode, ToolsListParams};
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-mix"),
|
||||
session_id: Some(session()),
|
||||
method: "tools.list".to_owned(),
|
||||
params: ToolsListParams {
|
||||
session_id: session(),
|
||||
mode: ToolDefinitionMode::Full,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["params"]["session_id"], json!("sess_abc"));
|
||||
let top_keys: std::collections::BTreeSet<&str> =
|
||||
v.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
top_keys,
|
||||
["id", "jsonrpc", "method", "params", "session_id"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
);
|
||||
}
|
||||
1680
crates/common/xai-tool-protocol/tests/serde_roundtrip.rs
Normal file
1680
crates/common/xai-tool-protocol/tests/serde_roundtrip.rs
Normal file
File diff suppressed because it is too large
Load diff
93
crates/common/xai-tool-protocol/tests/tool_id_derivation.rs
Normal file
93
crates/common/xai-tool-protocol/tests/tool_id_derivation.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! `ToolDescriptionWithSchema::derive_tool_id`: namespaced descriptions
|
||||
//! render as `"{ns}:{name}"`; bare names pass through; descriptions whose
|
||||
//! derived id fails [`ToolId`] validation return `Err`.
|
||||
|
||||
use xai_tool_protocol::{IdError, ToolDescriptionWithSchema, ToolId};
|
||||
use xai_tool_types::ToolDescription;
|
||||
|
||||
fn entry(name: &str, namespace: Option<&str>) -> ToolDescriptionWithSchema {
|
||||
let mut description = ToolDescription::new(name, "test description");
|
||||
if let Some(ns) = namespace {
|
||||
description = description.with_namespace(ns);
|
||||
}
|
||||
ToolDescriptionWithSchema {
|
||||
description,
|
||||
input_schema: None,
|
||||
capabilities: None,
|
||||
notification_schemas: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_name_derives_to_tool_id_without_namespace() {
|
||||
let derived = entry("read_file", None).derive_tool_id().unwrap();
|
||||
assert_eq!(derived, ToolId::new("read_file").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaced_name_derives_to_namespaced_tool_id() {
|
||||
let derived = entry("read_file", Some("GrokBuild"))
|
||||
.derive_tool_id()
|
||||
.unwrap();
|
||||
assert_eq!(derived, ToolId::new("GrokBuild:read_file").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_name_propagates_id_validation_error() {
|
||||
let err = entry("foo bar", None).derive_tool_id().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { .. }),
|
||||
"expected InvalidFormat, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_namespace_propagates_id_validation_error() {
|
||||
let err = entry("read_file", Some("bad ns"))
|
||||
.derive_tool_id()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, IdError::InvalidFormat { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_yields_empty_id_error() {
|
||||
let err = entry("", None).derive_tool_id().unwrap_err();
|
||||
assert_eq!(err, IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_derivations_in_a_batch_are_detectable() {
|
||||
let batch = [
|
||||
entry("read_file", Some("GrokBuild")),
|
||||
entry("write_file", Some("GrokBuild")),
|
||||
entry("read_file", Some("GrokBuild")),
|
||||
];
|
||||
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
let mut duplicates: Vec<(ToolId, Vec<usize>)> = Vec::new();
|
||||
for (i, e) in batch.iter().enumerate() {
|
||||
let id = e.derive_tool_id().unwrap();
|
||||
seen.entry(id).or_insert_with(Vec::new).push(i);
|
||||
}
|
||||
for (id, indices) in seen {
|
||||
if indices.len() > 1 {
|
||||
duplicates.push((id, indices));
|
||||
}
|
||||
}
|
||||
assert_eq!(duplicates.len(), 1, "exactly one duplicate id expected");
|
||||
let (id, indices) = &duplicates[0];
|
||||
assert_eq!(id, &ToolId::new("GrokBuild:read_file").unwrap());
|
||||
assert_eq!(indices, &vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivation_does_not_collide_across_namespaces() {
|
||||
let a = entry("read_file", Some("GrokBuild"))
|
||||
.derive_tool_id()
|
||||
.unwrap();
|
||||
let b = entry("read_file", Some("github")).derive_tool_id().unwrap();
|
||||
let c = entry("read_file", None).derive_tool_id().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_ne!(b, c);
|
||||
}
|
||||
Loading…
Reference in a new issue