Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1501 lines
56 KiB
Rust
1501 lines
56 KiB
Rust
//! Per-method `params` and `result` payload structs.
|
|
//!
|
|
//! These types ride inside a [`crate::JsonRpcRequest`] /
|
|
//! [`crate::JsonRpcResponse`] / [`crate::JsonRpcNotification`].
|
|
//!
|
|
//! `session_id` belongs in the JSON-RPC envelope field — always.
|
|
//! Request/notification params structs do NOT carry a `session_id`;
|
|
//! the hub reads it from `request.session_id` on the envelope.
|
|
//! Types that are NOT request params (e.g. `ToolsChanged` notification
|
|
//! body, `ServerInfo` display struct) keep their own `session_id`
|
|
//! because it is payload data, not routing.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
ConnectionId, FrameSeq, HookEvent, ServerId, SessionId, ToolCallId, ToolDefinitionMode, ToolId,
|
|
ToolRegistration, ToolServerRegistration, notification_wire::WireToolNotification,
|
|
output_wire::ToolOutputWire,
|
|
};
|
|
|
|
// Tool call params / result / progress
|
|
|
|
/// `tool.call` (harness → service) and `tool_call_request` (service →
|
|
/// tool_server) share the same params shape; `tool_call_id` is preserved
|
|
/// end-to-end.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolCallParams {
|
|
pub tool_call_id: ToolCallId,
|
|
pub tool_id: ToolId,
|
|
pub arguments: serde_json::Value,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub deadline_ms: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub behavior_version: Option<String>,
|
|
/// OS-native path; informational for cross-FS tools.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cwd: Option<String>,
|
|
/// W3C `traceparent` for distributed tracing.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub trace_context: Option<String>,
|
|
}
|
|
|
|
/// Body of a successful `tool_call_result` response.
|
|
///
|
|
/// `follow_ups` and `reminders` only fire for **local** tools and are
|
|
/// empty (and field-skipped) for remote calls.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolCallResult {
|
|
pub tool_call_id: ToolCallId,
|
|
pub output: ToolOutputWire,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub follow_ups: Vec<serde_json::Value>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub reminders: Vec<serde_json::Value>,
|
|
/// Carried as opaque `Value` (not the runtime's typed frame) because
|
|
/// this crate must not depend on `kigi-tool-runtime`. Sampler-side wire
|
|
/// decoders reconstruct it into the typed frame.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub chat_completion_output: Option<serde_json::Value>,
|
|
}
|
|
|
|
// Trace donation
|
|
|
|
/// Hub rejects oversized batches wholesale; donors chunk before encoding.
|
|
pub const MAX_SPANS_PER_DONATION: usize = 512;
|
|
|
|
/// Maximum decoded `ExportTraceServiceRequest` size the hub accepts.
|
|
pub const MAX_DONATION_BYTES: usize = 1024 * 1024;
|
|
|
|
/// `traces.donate` params (tool_server → service notification).
|
|
/// Envelope `session_id` required. `hub.*` span attributes are
|
|
/// reserved — the hub strips them and stamps its own attribution.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct TracesDonateParams {
|
|
/// Base64 (standard alphabet, padded) protobuf-encoded
|
|
/// `opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest`.
|
|
pub otlp_request: String,
|
|
}
|
|
|
|
// Log donation
|
|
|
|
/// Hub rejects oversized batches wholesale; donors chunk before encoding.
|
|
/// The 1 MiB [`MAX_DONATION_BYTES`] decoded-size cap is the real bound; this
|
|
/// record cap is a secondary guard symmetric with [`MAX_SPANS_PER_DONATION`].
|
|
pub const MAX_LOG_RECORDS_PER_DONATION: usize = 512;
|
|
|
|
/// `logs.donate` params (tool_server → service notification).
|
|
/// Envelope `session_id` required. `hub.*` log attributes are reserved —
|
|
/// the hub strips them and stamps its own attribution.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct LogsDonateParams {
|
|
/// Base64 (standard alphabet, padded) protobuf-encoded
|
|
/// `opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest`.
|
|
pub otlp_request: String,
|
|
}
|
|
|
|
// Metric donation
|
|
|
|
/// Hub rejects oversized batches wholesale; donors chunk before encoding.
|
|
/// Secondary guard alongside the 1 MiB [`MAX_DONATION_BYTES`] decoded-size cap.
|
|
pub const MAX_METRICS_PER_DONATION: usize = 512;
|
|
|
|
/// `metrics.donate` params (tool_server → service notification).
|
|
/// **No envelope `session_id`** — metrics are process-aggregate, not
|
|
/// per-session (unlike [`LogsDonateParams`]). `hub.*` resource attributes
|
|
/// are reserved — the hub strips them and stamps its own attribution.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct MetricsDonateParams {
|
|
/// Base64 (standard alphabet, padded) protobuf-encoded
|
|
/// `opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest`.
|
|
pub otlp_request: String,
|
|
}
|
|
|
|
/// Body of a `tool_call_progress` notification.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolCallProgressFrame {
|
|
pub tool_call_id: ToolCallId,
|
|
/// Producer-defined kind, e.g. `"log_chunk"` or `"chunk"`.
|
|
pub kind: String,
|
|
pub body: serde_json::Value,
|
|
/// Drop-bookkeeping counter — non-zero when prior progress frames for
|
|
/// this `tool_call_id` were dropped under rate pressure.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub dropped_count: Option<u32>,
|
|
}
|
|
|
|
/// Body of a `tool.notification` frame.
|
|
///
|
|
/// Both the harness and the tool server may emit notifications; `tool_id`
|
|
/// is omitted when the producer is the harness.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolNotificationFrame {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tool_call_id: Option<ToolCallId>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tool_id: Option<ToolId>,
|
|
pub notification: WireToolNotification,
|
|
}
|
|
|
|
impl ToolNotificationFrame {
|
|
/// Build a custom notification with an application-defined `kind` and free-form payload.
|
|
pub fn custom(tool_id: ToolId, kind: impl Into<String>, payload: serde_json::Value) -> Self {
|
|
Self {
|
|
tool_call_id: None,
|
|
tool_id: Some(tool_id),
|
|
notification: WireToolNotification::Custom(
|
|
crate::notification_wire::WireCustomNotification {
|
|
kind: kind.into(),
|
|
payload,
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Build a notification wrapping a typed ("known") notification value.
|
|
///
|
|
/// The `notification` is serialized into the `WireToolNotification::Known`
|
|
/// variant. Returns `Err` only if serialization fails, which should not
|
|
/// happen for well-formed `Serialize` types.
|
|
pub fn known<N: Serialize>(
|
|
tool_id: ToolId,
|
|
notification: N,
|
|
) -> Result<Self, serde_json::Error> {
|
|
let value = serde_json::to_value(notification)?;
|
|
Ok(Self {
|
|
tool_call_id: None,
|
|
tool_id: Some(tool_id),
|
|
notification: WireToolNotification::Known(value),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Maximum serialized size of a `system.notify` opaque payload.
|
|
pub const MAX_SYSTEM_NOTIFY_PAYLOAD_BYTES: usize = 256 * 1024;
|
|
|
|
/// Body of a `system.notify` frame. `payload` is an opaque `SystemNotification`
|
|
/// JSON value forwarded verbatim without decoding.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SystemNotifyParams {
|
|
pub payload: serde_json::Value,
|
|
/// Target conversation override; defaults to the envelope `session_id`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id_override: Option<String>,
|
|
/// Ask the gateway to echo the payload to WS subscribers.
|
|
#[serde(default)]
|
|
pub echo_to_subscribers: bool,
|
|
/// Correlation id echoed back in the ack/response; not an idempotency key.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub request_id: Option<String>,
|
|
}
|
|
|
|
// Registration frames
|
|
|
|
/// `register_tool` params — single-tool sugar over `register_server`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct RegisterToolParams {
|
|
pub tool: ToolRegistration,
|
|
}
|
|
|
|
/// `register_server` params — multi-tool batch.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct RegisterServerParams {
|
|
pub server: ToolServerRegistration,
|
|
}
|
|
|
|
/// Drop a tool entirely from the connection (across every session it was
|
|
/// bound to). For per-session removal use
|
|
/// [`crate::Method::UnbindToolSession`].
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct UnregisterToolParams {
|
|
pub tool_id: ToolId,
|
|
}
|
|
|
|
/// Drop every tool registered under `server_id` by this connection.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct UnregisterServerParams {
|
|
pub server_id: ServerId,
|
|
}
|
|
|
|
// Per-tool session binding
|
|
|
|
/// `bind_tool_session` params — add `session_id` to a registered tool's
|
|
/// per-tool session set.
|
|
///
|
|
/// Both fields are SUBJECTS of the operation: `tool_id` names the
|
|
/// tool whose session set is being mutated, and `session_id` names
|
|
/// the session being added. Neither matches the envelope-level
|
|
/// `session_id`, which is the calling-frame routing scope and is
|
|
/// typically omitted on connection-control frames.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BindToolSessionParams {
|
|
pub tool_id: ToolId,
|
|
/// The session id to add to the tool's session set. Must already
|
|
/// be in the connection's bound-session set.
|
|
pub session_id: SessionId,
|
|
}
|
|
|
|
/// Outcome reported by [`BindToolSessionAck`].
|
|
///
|
|
/// This is intentionally a **strict subset** of the registry-side
|
|
/// `kigi_computer_hub_core::registry::ToolSessionBindOutcome`, with one
|
|
/// extra wire-only variant. The asymmetry exists because the wire and
|
|
/// registry layers have different failure vocabularies:
|
|
///
|
|
/// - The registry's `Conflict` variant (cross-connection race on the
|
|
/// `(session_id, tool_id)` reverse-index slot) is NOT mirrored here.
|
|
/// The router lifts `Conflict` into a top-level
|
|
/// `ServerError::ToolBindingConflict` (-32600) so the contended caller
|
|
/// sees a wire-level error frame with a dedicated code instead of a
|
|
/// quietly-buried ack outcome — mirroring it would re-introduce the
|
|
/// `UnknownTool`-overload ambiguity the dedicated code exists to fix.
|
|
/// - `SessionNotBound` is router-injected by the per-frame envelope
|
|
/// pre-check (the connection's bound-session set is router state, not
|
|
/// registry state) and never originates from the registry call.
|
|
///
|
|
/// Adding a new variant here means adding a corresponding registry-side
|
|
/// outcome OR documenting why the wire-only variant is router-injected.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolSessionBindOutcome {
|
|
/// Added to the tool's session set.
|
|
Bound,
|
|
/// Already in the session set; no-op.
|
|
AlreadyBound,
|
|
/// No tool with this id is registered against the calling connection.
|
|
UnknownTool,
|
|
/// `session_id` is not in the connection's bound-session set; the caller
|
|
/// must `register_session` first.
|
|
SessionNotBound,
|
|
}
|
|
|
|
/// Reply to [`BindToolSessionParams`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct BindToolSessionAck {
|
|
pub outcome: ToolSessionBindOutcome,
|
|
}
|
|
|
|
/// `unbind_tool_session` params — drop `session_id` from a registered
|
|
/// tool's per-tool session set.
|
|
///
|
|
/// Same envelope-vs-payload distinction as
|
|
/// [`BindToolSessionParams`]: `tool_id` and `session_id` are subjects
|
|
/// of the unbind operation; the envelope `session_id` (if present) is
|
|
/// the calling-frame routing scope and serves a different concept.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UnbindToolSessionParams {
|
|
pub tool_id: ToolId,
|
|
/// The session id to remove from the tool's session set.
|
|
pub session_id: SessionId,
|
|
}
|
|
|
|
/// Outcome reported by [`UnbindToolSessionAck`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolSessionUnbindOutcome {
|
|
/// Removed from the tool's session set.
|
|
Unbound,
|
|
/// Was not in the tool's session set; no-op.
|
|
NotBound,
|
|
/// No tool with this id is registered against the calling connection.
|
|
UnknownTool,
|
|
}
|
|
|
|
/// Reply to [`UnbindToolSessionParams`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UnbindToolSessionAck {
|
|
pub outcome: ToolSessionUnbindOutcome,
|
|
}
|
|
|
|
// Server discovery + binding
|
|
|
|
/// `servers.list` params — discover available tool servers for the
|
|
/// authenticated user.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ServersListParams {}
|
|
|
|
/// Metadata about a connected tool server.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ServerInfo {
|
|
pub server_id: ServerId,
|
|
/// The tool server's own session ID (used internally for routing).
|
|
/// May be absent when the hub omits it from `servers.list` responses.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub session_id: Option<SessionId>,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub metadata: serde_json::Value,
|
|
#[serde(default)]
|
|
pub connected_since: String,
|
|
/// Lifecycle status (Ready, Busy, Draining, etc.).
|
|
pub status: ToolServerLifecycleStatus,
|
|
}
|
|
|
|
/// Reply to [`ServersListParams`].
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ServersListResult {
|
|
pub servers: Vec<ServerInfo>,
|
|
}
|
|
|
|
/// `server.bind` params — bind a tool server's tools to a harness session.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ServerBindParams {
|
|
/// Which tool server to bind (its server_id from `servers.list`).
|
|
pub server_id: ServerId,
|
|
pub session_id: SessionId,
|
|
}
|
|
|
|
/// Outcome of a `server.bind` request.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ServerBindOutcome {
|
|
/// Tools successfully bound to the session.
|
|
Bound,
|
|
/// Tools already bound to this session.
|
|
AlreadyBound,
|
|
/// No server with this server_id found.
|
|
ServerNotFound,
|
|
/// Server found and bind forwarded, but bind did not complete:
|
|
/// ack timed out, transport send/delivery failed, or ack was
|
|
/// malformed or an explicit error. Distinct from `ServerNotFound`, which
|
|
/// means no such server is registered.
|
|
Unavailable,
|
|
}
|
|
|
|
/// Reply to [`ServerBindParams`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ServerBindAck {
|
|
pub outcome: ServerBindOutcome,
|
|
}
|
|
|
|
/// `server.unbind` params — unbind a tool server's tools from a session.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ServerUnbindParams {
|
|
pub server_id: ServerId,
|
|
pub session_id: SessionId,
|
|
}
|
|
|
|
/// Outcome of a `server.unbind` request.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ServerUnbindOutcome {
|
|
Unbound,
|
|
ServerNotFound,
|
|
}
|
|
|
|
/// Reply to [`ServerUnbindParams`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ServerUnbindAck {
|
|
pub outcome: ServerUnbindOutcome,
|
|
}
|
|
|
|
// List & search
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolsListParams {
|
|
pub session_id: SessionId,
|
|
pub mode: ToolDefinitionMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolsListResult {
|
|
pub tools: Vec<kigi_tool_types::ToolDescription>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolsSearchParams {
|
|
pub session_id: SessionId,
|
|
pub query: String,
|
|
pub limit: usize,
|
|
}
|
|
|
|
/// One match in a `tools.search_result` body. Wire-local definition keeps
|
|
/// the protocol crate free of the codegen crate's transitive deps.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolSearchResult {
|
|
pub tool_name: String,
|
|
pub server_name: String,
|
|
pub description: String,
|
|
pub score: f32,
|
|
pub parameters: Vec<String>,
|
|
pub input_schema: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolsSearchResultBody {
|
|
pub results: Vec<ToolSearchResult>,
|
|
pub total_hidden_tools: usize,
|
|
pub is_ready: bool,
|
|
}
|
|
|
|
// Session lifecycle
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionOpenParams {
|
|
/// `true` when reconnecting an existing session within the grace
|
|
/// window; requires `last_seq` to dedup notifications.
|
|
#[serde(default)]
|
|
pub resume: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_seq: Option<LastSeq>,
|
|
}
|
|
|
|
/// Reconnect helper: the last `(connection_id, seq)` the client saw
|
|
/// before disconnecting.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct LastSeq {
|
|
pub connection_id: ConnectionId,
|
|
pub seq: FrameSeq,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionCloseParams {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Reply to a `session_open` request.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionOpenResult {}
|
|
|
|
/// `session_bind_server` params (harness → hub). Bind a tool server's
|
|
/// tools to the current session.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionBindServerParams {
|
|
pub server_id: ServerId,
|
|
/// Working directory for the session. When absent, the tool server's
|
|
/// default CWD is used.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cwd: Option<String>,
|
|
/// Opaque metadata passed through to the tool server (sandbox_id,
|
|
/// agent config, user preferences). The hub does not interpret it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Reply to [`SessionBindServerParams`]. Returns the tools available
|
|
/// after the server is bound.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionBindServerResult {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools: Vec<kigi_tool_types::ToolDescription>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub binary_version: Option<String>,
|
|
/// [`SessionBindResult::unserved_tool_ids`], forwarded verbatim.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub unserved_tool_ids: Vec<String>,
|
|
/// [`SessionBindResult::resolve_error`], forwarded verbatim.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub resolve_error: Option<String>,
|
|
}
|
|
|
|
/// `session_unbind_server` params (harness → hub). Unbind a tool
|
|
/// server from the current session.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionUnbindServerParams {
|
|
pub server_id: ServerId,
|
|
}
|
|
|
|
/// `session_attach_server` params (harness → hub). Attach this harness
|
|
/// connection to an EXISTING session as an observer: verify a tool-server
|
|
/// is bound for the envelope session and return the tool snapshot.
|
|
/// Hub-local: never forwarded to the tool server; never creates a
|
|
/// workspace session; never mutates toolsets/handlers.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionAttachServerParams {
|
|
/// Optional expected server (diagnostics + directory cross-check);
|
|
/// the authoritative key is the envelope `session_id`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub server_id: Option<ServerId>,
|
|
/// Free-form caller label for metrics/logs ("fs_read", "deploy_app", …).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub caller: Option<String>,
|
|
}
|
|
|
|
/// Reply to [`SessionAttachServerParams`].
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionAttachServerResult {
|
|
/// Registry snapshot for the session (local + cross-instance), same
|
|
/// shape as [`SessionBindServerResult::tools`].
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tools: Vec<kigi_tool_types::ToolDescription>,
|
|
/// Where the session's tool-server was found.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub route: Option<AttachRoute>,
|
|
}
|
|
|
|
/// Where an attach found the session's tool-server. `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 AttachRoute {
|
|
/// Live tool-server connection on the answering hub instance.
|
|
Local,
|
|
/// Published cross-instance routes (tool-server on another replica).
|
|
Remote,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
// Simplified lifecycle
|
|
|
|
/// `serve` params (server → hub). Full tool snapshot for a session.
|
|
///
|
|
/// Idempotent: re-sending replaces the tool set for the envelope
|
|
/// `session_id`. The hub diffs against the previous snapshot and
|
|
/// emits `tools_changed` to subscribed harnesses.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ServeParams {
|
|
pub tools: Vec<crate::ToolDescriptionWithSchema>,
|
|
}
|
|
|
|
/// Reply to [`ServeParams`].
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct ServeResult {
|
|
#[serde(default)]
|
|
pub accepted: usize,
|
|
/// Tool IDs added relative to the previous snapshot.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub added: Vec<ToolId>,
|
|
/// Tool IDs removed relative to the previous snapshot.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub removed: Vec<ToolId>,
|
|
}
|
|
|
|
/// `session.bind` params (hub → server). Hub requests the server to
|
|
/// start serving a session.
|
|
///
|
|
/// The session id is carried on the JSON-RPC envelope, not in params.
|
|
/// The server responds with its tool snapshot (via the JSON-RPC
|
|
/// response). On reconnect the server replays `serve{tools}` for
|
|
/// every remembered session.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SessionBindParams {}
|
|
|
|
/// Reply to [`SessionBindParams`]. The server's tool snapshot for the
|
|
/// newly bound session.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct SessionBindResult {
|
|
pub tools: Vec<kigi_tool_types::ToolDescription>,
|
|
/// Version of the responding tool-server binary. `None` on servers
|
|
/// predating the field.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub binary_version: Option<String>,
|
|
/// Pinned tool ids this binary could not serve (unknown to its registry).
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub unserved_tool_ids: Vec<String>,
|
|
/// Reason the server failed the toolset resolution closed (the bind then
|
|
/// advertises no model-facing tools by design). `None` on normal
|
|
/// resolutions and on servers predating the field.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub resolve_error: Option<String>,
|
|
}
|
|
|
|
/// `session.unbind` params (hub → server). Hub tells the server to
|
|
/// stop serving a session. Sent as a JSON-RPC **notification** (no
|
|
/// `id` field) — the hub does not expect or wait for a response.
|
|
///
|
|
/// The session id is carried on the JSON-RPC envelope, not in params.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SessionUnbindParams {}
|
|
|
|
// Subscriptions
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SubscribeNotificationsParams {
|
|
pub session_id: SessionId,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub filter: Option<NotificationFilter>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct NotificationFilter {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tool_id: Option<ToolId>,
|
|
/// Whitelist of accepted notification `kinds`. `None` means accept
|
|
/// all kinds; an empty `Vec` means accept none.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub kinds: Option<Vec<String>>,
|
|
}
|
|
|
|
/// Reply outcome reported by [`SubscribeAck`].
|
|
///
|
|
/// `Subscribed` and `AlreadySubscribed` discriminate first-time binds
|
|
/// from idempotent retries; `NotAuthorized` is reserved for the case
|
|
/// where the subscriber's connection has not bound `session_id` via
|
|
/// `register_session` — the same precondition that gates dispatch and
|
|
/// hook frames.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SubscribeOutcome {
|
|
/// Subscription was newly added to the session's subscriber set.
|
|
Subscribed,
|
|
/// Subscription was already present; no-op.
|
|
AlreadySubscribed,
|
|
/// Subscriber's connection has not bound `session_id`; the request
|
|
/// is rejected without mutating any state.
|
|
NotAuthorized,
|
|
}
|
|
|
|
/// Reply to [`SubscribeNotificationsParams`].
|
|
///
|
|
/// `subscription_id` is the harness-facing handle that callers thread
|
|
/// through subsequent [`UnsubscribeNotificationsParams`] requests; the
|
|
/// service uses `(connection_id, session_id)` internally so the id is
|
|
/// informational and a single value (`"default"`) is reused per
|
|
/// `(connection, session)` pair.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SubscribeAck {
|
|
pub outcome: SubscribeOutcome,
|
|
pub subscription_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct UnsubscribeNotificationsParams {
|
|
pub session_id: SessionId,
|
|
pub subscription_id: String,
|
|
}
|
|
|
|
/// Reply outcome reported by [`UnsubscribeAck`].
|
|
///
|
|
/// `Evicted` is server-pushed: the service emits an unsubscribe ack
|
|
/// with this outcome to a subscriber whose mpsc was dropped during
|
|
/// fan-out (see slow-consumer eviction in the computer hub crate).
|
|
/// Clients reading that frame on a connection they did not initiate
|
|
/// an unsubscribe on should treat the subscription as gone.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum UnsubscribeOutcome {
|
|
/// Subscription was present and the service removed it.
|
|
Unsubscribed,
|
|
/// Subscription was not present; no-op.
|
|
NotSubscribed,
|
|
/// The service removed the subscription because the subscriber's
|
|
/// outbound mpsc was full or dropped during fan-out.
|
|
Evicted,
|
|
}
|
|
|
|
/// Reply to [`UnsubscribeNotificationsParams`] and the service-pushed
|
|
/// frame for slow-consumer eviction.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct UnsubscribeAck {
|
|
pub outcome: UnsubscribeOutcome,
|
|
pub subscription_id: String,
|
|
}
|
|
|
|
// Hooks
|
|
|
|
/// `hook` frame body, routed in both directions through the hub: harness →
|
|
/// tool-server for forward hooks (e.g. `Cancel`, `SessionEnded`), and
|
|
/// tool-server → harness for reverse request/response hooks (e.g. permission
|
|
/// requests).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct HookFrame {
|
|
pub session_id: SessionId,
|
|
/// Omit for session-wide hooks (broadcast); required for call-scoped
|
|
/// hooks like `Cancel`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tool_id: Option<ToolId>,
|
|
/// Required for call-scoped hooks (`Cancel`); optional otherwise.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub call_id: Option<ToolCallId>,
|
|
/// Correlation id for a request/response hook. The requester mints it and
|
|
/// the responder echoes it in the [`HookReplyFrame`], so the reply routes
|
|
/// back to the awaiting caller. Set in both directions (harness ↔
|
|
/// tool-server). `None` for fire-and-forget hooks such as `Cancel` and
|
|
/// `SessionEnded`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub hook_id: Option<String>,
|
|
pub event: HookEvent,
|
|
/// W3C `traceparent` (mirrors [`ToolCallParams::trace_context`]).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub trace_context: Option<String>,
|
|
}
|
|
|
|
impl HookFrame {
|
|
/// Build a `Cancel` hook targeting a specific in-flight call.
|
|
pub fn cancel(session_id: SessionId, tool_id: ToolId, call_id: ToolCallId) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: Some(tool_id),
|
|
call_id: Some(call_id),
|
|
hook_id: None,
|
|
event: HookEvent::Cancel,
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Build a `Pause` hook broadcast to every tool server bound to the session.
|
|
pub fn pause(session_id: SessionId) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: None,
|
|
call_id: None,
|
|
hook_id: None,
|
|
event: HookEvent::Pause,
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Build a `Resume` hook broadcast to every tool server bound to the session.
|
|
pub fn resume(session_id: SessionId) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: None,
|
|
call_id: None,
|
|
hook_id: None,
|
|
event: HookEvent::Resume,
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Build a `SessionEnded` hook broadcast to every tool server bound to the session.
|
|
pub fn session_ended(session_id: SessionId) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: None,
|
|
call_id: None,
|
|
hook_id: None,
|
|
event: HookEvent::SessionEnded,
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Build a `Custom` hook with an application-defined `kind` and free-form payload.
|
|
pub fn custom(session_id: SessionId, kind: String, payload: serde_json::Value) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: None,
|
|
call_id: None,
|
|
hook_id: None,
|
|
event: HookEvent::Custom { kind, payload },
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Session-scoped request/response `Custom` hook that sets `hook_id` so the hub wires a [`HookReplyFrame`] reply leg.
|
|
pub fn custom_request(
|
|
session_id: SessionId,
|
|
hook_id: String,
|
|
kind: String,
|
|
payload: serde_json::Value,
|
|
) -> Self {
|
|
Self {
|
|
session_id,
|
|
tool_id: None,
|
|
call_id: None,
|
|
hook_id: Some(hook_id),
|
|
event: HookEvent::Custom { kind, payload },
|
|
trace_context: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a W3C `traceparent` (builder-style).
|
|
pub fn with_trace_context(mut self, trace_context: Option<String>) -> Self {
|
|
self.trace_context = trace_context;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// `hook_reply` frame body: reply to a request/response [`HookFrame`], correlated by `hook_id`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct HookReplyFrame {
|
|
pub session_id: SessionId,
|
|
/// Echoed from the originating [`HookFrame::hook_id`].
|
|
pub hook_id: String,
|
|
pub result: serde_json::Value,
|
|
}
|
|
|
|
// Service → harness pushes
|
|
|
|
/// `tools_changed` body — the active tool set for `session_id` changed.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolsChanged {
|
|
pub session_id: SessionId,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub added: Vec<ToolId>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub removed: Vec<ToolId>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub updated: Vec<ToolId>,
|
|
}
|
|
|
|
// Tool server status lifecycle
|
|
|
|
/// Lifecycle status of a tool server connection.
|
|
///
|
|
/// `starting` → `ready` → `busy` ↔ `ready` → `draining` → `shutting_down`.
|
|
/// `disconnected` is hub-only: set during disconnect cleanup, never sent
|
|
/// by the tool server itself.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolServerLifecycleStatus {
|
|
Starting,
|
|
#[default]
|
|
Ready,
|
|
Busy,
|
|
Draining,
|
|
ShuttingDown,
|
|
Disconnected,
|
|
}
|
|
|
|
/// `tool_server.status` payload. `session_id = Some` scopes counters
|
|
/// to that session; `None` is an aggregate across all sessions. Newer
|
|
/// fields use `#[serde(default)]` for forward/backward wire compat.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
|
pub struct ToolServerStatusPayload {
|
|
pub status: ToolServerLifecycleStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub session_id: Option<SessionId>,
|
|
/// Dead connection on Disconnected (remote hubs scope in-flight cancel).
|
|
///
|
|
/// A raw `String`, not a typed `ConnectionId`: a malformed id degrades
|
|
/// leniently on the consumer (re-parsed, logged, ignored) instead of failing
|
|
/// deserialization of the whole status frame.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub connection_id: Option<String>,
|
|
pub active_tool_calls: u32,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub active_tool_names: Vec<String>,
|
|
pub background_tasks: u32,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub background_task_ids: Vec<String>,
|
|
pub pending_tool_calls: u32,
|
|
pub last_tool_call_started_ms: u64,
|
|
pub last_tool_call_completed_ms: u64,
|
|
pub uptime_ms: u64,
|
|
/// `None` while busy; epoch ms of the last busy→ready transition.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub idle_since_ms: Option<u64>,
|
|
/// Epoch ms when a graceful drain began (SIGTERM or hub evict); `None`
|
|
/// until draining starts.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub drain_started_ms: Option<u64>,
|
|
/// Whether a turn is currently active. Per-session for a session-scoped
|
|
/// snapshot; "any session has an active turn" for the aggregate.
|
|
#[serde(default)]
|
|
pub turn_active: bool,
|
|
/// `true` when the reported idle verdict was computed ignoring background
|
|
/// tasks.
|
|
#[serde(default)]
|
|
pub idle_ignores_background: bool,
|
|
}
|
|
|
|
impl ToolServerStatusPayload {
|
|
/// Zeroed-out payload for terminal states (Disconnected, Starting, etc.).
|
|
pub fn terminal(status: ToolServerLifecycleStatus) -> Self {
|
|
Self {
|
|
status,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `tool_server.evict` params — hub requests graceful shutdown.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolServerEvictParams {
|
|
pub session_id: SessionId,
|
|
pub reason: String,
|
|
/// Deadline (ms) before the hub force-closes the connection.
|
|
pub grace_period_ms: u64,
|
|
}
|
|
|
|
/// `tool_server.get_status` params.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ToolServerGetStatusParams {
|
|
pub session_id: SessionId,
|
|
}
|
|
|
|
/// One entry in [`ToolServerGetStatusResult`].
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolServerConnectionStatus {
|
|
pub connection_id: String,
|
|
pub status: ToolServerStatusPayload,
|
|
}
|
|
|
|
/// Reply to `tool_server.get_status`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ToolServerGetStatusResult {
|
|
pub tool_servers: Vec<ToolServerConnectionStatus>,
|
|
}
|
|
|
|
/// Why a tool server connection was dropped (carried in the hub's
|
|
/// `tool_server.status_changed` disconnect notification).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolServerDisconnectReason {
|
|
NormalClose,
|
|
IdleTimeout,
|
|
ForceEvicted,
|
|
ConnectionLost,
|
|
}
|
|
|
|
// Heartbeat
|
|
//
|
|
// PingFrame / PongFrame carry a `method` discriminator on the wire so
|
|
// any receiver (hub or SDK) can route them through a method-based demux.
|
|
// The `method` value is baked into the Serialize impl — callers just set
|
|
// `ts_ms` and the correct method string appears in the JSON output.
|
|
//
|
|
// Deserialization is lenient: the `method` field is accepted but ignored,
|
|
// so frames produced by older builds (without `method`) still parse.
|
|
|
|
/// Application-level heartbeat ping.
|
|
///
|
|
/// Serializes as `{"method":"ping","ts_ms":<u64>}`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PingFrame {
|
|
pub ts_ms: u64,
|
|
}
|
|
|
|
/// Application-level heartbeat pong.
|
|
///
|
|
/// Serializes as `{"method":"pong","ts_ms":<u64>}`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PongFrame {
|
|
pub ts_ms: u64,
|
|
}
|
|
|
|
impl PingFrame {
|
|
pub fn new(ts_ms: u64) -> Self {
|
|
Self { ts_ms }
|
|
}
|
|
}
|
|
|
|
impl PongFrame {
|
|
pub fn new(ts_ms: u64) -> Self {
|
|
Self { ts_ms }
|
|
}
|
|
}
|
|
|
|
// Custom Serialize: always includes `"method"` on the wire.
|
|
|
|
impl serde::Serialize for PingFrame {
|
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
use serde::ser::SerializeMap;
|
|
let mut map = s.serialize_map(Some(2))?;
|
|
map.serialize_entry("method", crate::methods::Method::Ping.as_wire_str())?;
|
|
map.serialize_entry("ts_ms", &self.ts_ms)?;
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
impl serde::Serialize for PongFrame {
|
|
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
use serde::ser::SerializeMap;
|
|
let mut map = s.serialize_map(Some(2))?;
|
|
map.serialize_entry("method", crate::methods::Method::Pong.as_wire_str())?;
|
|
map.serialize_entry("ts_ms", &self.ts_ms)?;
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
// Custom Deserialize: accepts with or without `method` for compat.
|
|
|
|
impl<'de> serde::Deserialize<'de> for PingFrame {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
#[derive(serde::Deserialize)]
|
|
struct Raw {
|
|
method: Option<String>,
|
|
ts_ms: u64,
|
|
}
|
|
let raw = Raw::deserialize(d)?;
|
|
if let Some(ref m) = raw.method
|
|
&& m != crate::methods::Method::Ping.as_wire_str()
|
|
{
|
|
return Err(serde::de::Error::custom(format!(
|
|
"expected method \"{}\" but got \"{m}\"",
|
|
crate::methods::Method::Ping.as_wire_str(),
|
|
)));
|
|
}
|
|
Ok(Self { ts_ms: raw.ts_ms })
|
|
}
|
|
}
|
|
|
|
impl<'de> serde::Deserialize<'de> for PongFrame {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
#[derive(serde::Deserialize)]
|
|
struct Raw {
|
|
method: Option<String>,
|
|
ts_ms: u64,
|
|
}
|
|
let raw = Raw::deserialize(d)?;
|
|
if let Some(ref m) = raw.method
|
|
&& m != crate::methods::Method::Pong.as_wire_str()
|
|
{
|
|
return Err(serde::de::Error::custom(format!(
|
|
"expected method \"{}\" but got \"{m}\"",
|
|
crate::methods::Method::Pong.as_wire_str(),
|
|
)));
|
|
}
|
|
Ok(Self { ts_ms: raw.ts_ms })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn sid() -> SessionId {
|
|
SessionId::new("test-session").expect("valid")
|
|
}
|
|
|
|
fn tid() -> ToolId {
|
|
ToolId::new("test-tool").expect("valid")
|
|
}
|
|
|
|
fn legacy_status_json() -> serde_json::Value {
|
|
json!({
|
|
"status": "ready",
|
|
"active_tool_calls": 0,
|
|
"background_tasks": 0,
|
|
"pending_tool_calls": 0,
|
|
"last_tool_call_started_ms": 0,
|
|
"last_tool_call_completed_ms": 0,
|
|
"uptime_ms": 0
|
|
})
|
|
}
|
|
|
|
fn cid() -> ToolCallId {
|
|
ToolCallId::new_v7()
|
|
}
|
|
|
|
#[test]
|
|
fn hook_cancel_sets_tool_and_call_ids() {
|
|
let hook = HookFrame::cancel(sid(), tid(), cid());
|
|
assert_eq!(hook.event, HookEvent::Cancel);
|
|
assert!(hook.tool_id.is_some());
|
|
assert!(hook.call_id.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_pause_omits_tool_and_call_ids() {
|
|
let hook = HookFrame::pause(sid());
|
|
assert_eq!(hook.event, HookEvent::Pause);
|
|
assert!(hook.tool_id.is_none());
|
|
assert!(hook.call_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_resume_omits_tool_and_call_ids() {
|
|
let hook = HookFrame::resume(sid());
|
|
assert_eq!(hook.event, HookEvent::Resume);
|
|
assert!(hook.tool_id.is_none());
|
|
assert!(hook.call_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_session_ended_omits_tool_and_call_ids() {
|
|
let hook = HookFrame::session_ended(sid());
|
|
assert_eq!(hook.event, HookEvent::SessionEnded);
|
|
assert!(hook.tool_id.is_none());
|
|
assert!(hook.call_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_custom_carries_kind_and_payload() {
|
|
let payload = json!({"key": "value"});
|
|
let hook = HookFrame::custom(sid(), "my.hook".to_owned(), payload.clone());
|
|
match &hook.event {
|
|
HookEvent::Custom { kind, payload: p } => {
|
|
assert_eq!(kind, "my.hook");
|
|
assert_eq!(p, &payload);
|
|
}
|
|
other => panic!("expected Custom, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hook_cancel_round_trips_through_serde() {
|
|
let hook = HookFrame::cancel(sid(), tid(), cid());
|
|
let json = serde_json::to_value(&hook).expect("serialize");
|
|
let back: HookFrame = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(hook, back);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_custom_sets_wire_shape() {
|
|
let frame = ToolNotificationFrame::custom(tid(), "echo.status", json!({"status": "idle"}));
|
|
assert_eq!(frame.tool_id.as_ref().unwrap(), &tid());
|
|
assert!(frame.tool_call_id.is_none());
|
|
match &frame.notification {
|
|
WireToolNotification::Custom(c) => {
|
|
assert_eq!(c.kind, "echo.status");
|
|
assert_eq!(c.payload, json!({"status": "idle"}));
|
|
}
|
|
other => panic!("expected Custom, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn notification_known_serializes_value() {
|
|
let inner = json!({"type": "BashOutputChunk", "data": "hello"});
|
|
let frame = ToolNotificationFrame::known(tid(), inner.clone()).expect("serialize");
|
|
match &frame.notification {
|
|
WireToolNotification::Known(v) => assert_eq!(v, &inner),
|
|
other => panic!("expected Known, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn system_notify_params_skips_optional_fields_when_none() {
|
|
let params = super::SystemNotifyParams {
|
|
payload: json!({"backgroundTaskCompleted": {"id": "t-1", "returnCode": 0}}),
|
|
conversation_id_override: None,
|
|
echo_to_subscribers: false,
|
|
request_id: None,
|
|
};
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
assert!(json.get("conversation_id_override").is_none());
|
|
assert!(json.get("request_id").is_none());
|
|
assert_eq!(json["echo_to_subscribers"], false);
|
|
let back: super::SystemNotifyParams = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn system_notify_params_echo_defaults_false_when_absent() {
|
|
let json = json!({ "payload": {"k": "v"} });
|
|
let back: super::SystemNotifyParams = serde_json::from_value(json).expect("deserialize");
|
|
assert!(!back.echo_to_subscribers);
|
|
assert!(back.conversation_id_override.is_none());
|
|
assert!(back.request_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn system_notify_params_round_trips_with_all_fields() {
|
|
let params = super::SystemNotifyParams {
|
|
payload: json!({"k": "v"}),
|
|
conversation_id_override: Some("conv-7".into()),
|
|
echo_to_subscribers: true,
|
|
request_id: Some("bgtask-t-1".into()),
|
|
};
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
let back: super::SystemNotifyParams = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn logs_donate_params_round_trips() {
|
|
let params = super::LogsDonateParams {
|
|
otlp_request: "b64payload".to_owned(),
|
|
};
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
assert_eq!(json["otlp_request"], "b64payload");
|
|
let back: super::LogsDonateParams = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_donate_params_round_trips() {
|
|
let params = super::MetricsDonateParams {
|
|
otlp_request: "b64payload".to_owned(),
|
|
};
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
assert_eq!(json["otlp_request"], "b64payload");
|
|
let back: super::MetricsDonateParams = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_lifecycle_status_serde_snake_case() {
|
|
let status = super::ToolServerLifecycleStatus::ShuttingDown;
|
|
let json = serde_json::to_value(status).expect("serialize");
|
|
assert_eq!(json.as_str(), Some("shutting_down"));
|
|
let back: super::ToolServerLifecycleStatus =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, status);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_lifecycle_status_all_variants_round_trip() {
|
|
use super::ToolServerLifecycleStatus::*;
|
|
for (variant, expected_str) in [
|
|
(Starting, "starting"),
|
|
(Ready, "ready"),
|
|
(Busy, "busy"),
|
|
(Draining, "draining"),
|
|
(ShuttingDown, "shutting_down"),
|
|
(Disconnected, "disconnected"),
|
|
] {
|
|
let json = serde_json::to_value(variant).expect("serialize");
|
|
assert_eq!(json.as_str(), Some(expected_str), "variant {variant:?}");
|
|
let back: super::ToolServerLifecycleStatus =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, variant);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_status_payload_round_trips() {
|
|
let payload = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Busy,
|
|
session_id: None,
|
|
active_tool_calls: 2,
|
|
active_tool_names: vec!["read_file".into(), "grep".into()],
|
|
background_tasks: 1,
|
|
background_task_ids: vec!["task-abc".into()],
|
|
pending_tool_calls: 0,
|
|
last_tool_call_started_ms: 1721234567890,
|
|
last_tool_call_completed_ms: 1721234565000,
|
|
uptime_ms: 342000,
|
|
idle_since_ms: None,
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&payload).expect("serialize");
|
|
assert_eq!(json["status"], "busy");
|
|
assert_eq!(json["active_tool_calls"], 2);
|
|
assert!(
|
|
json.get("idle_since_ms").is_none(),
|
|
"None should be skipped"
|
|
);
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_status_payload_idle_since_present() {
|
|
let payload = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Ready,
|
|
session_id: Some(sid()),
|
|
active_tool_calls: 0,
|
|
active_tool_names: vec![],
|
|
background_tasks: 0,
|
|
background_task_ids: vec![],
|
|
pending_tool_calls: 0,
|
|
last_tool_call_started_ms: 0,
|
|
last_tool_call_completed_ms: 0,
|
|
uptime_ms: 60000,
|
|
idle_since_ms: Some(1721234560000),
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&payload).expect("serialize");
|
|
assert_eq!(json["idle_since_ms"], 1721234560000u64);
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back.idle_since_ms, Some(1721234560000));
|
|
}
|
|
|
|
/// The drain fields round-trip with real values on the wire.
|
|
#[test]
|
|
fn tool_server_status_payload_carries_drain_fields() {
|
|
let payload = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Draining,
|
|
session_id: Some(sid()),
|
|
connection_id: None,
|
|
active_tool_calls: 1,
|
|
active_tool_names: vec!["read_file".into()],
|
|
background_tasks: 0,
|
|
background_task_ids: vec![],
|
|
pending_tool_calls: 0,
|
|
last_tool_call_started_ms: 0,
|
|
last_tool_call_completed_ms: 0,
|
|
uptime_ms: 5000,
|
|
idle_since_ms: None,
|
|
drain_started_ms: Some(1721234599999),
|
|
turn_active: true,
|
|
idle_ignores_background: false,
|
|
};
|
|
let json = serde_json::to_value(&payload).expect("serialize");
|
|
assert_eq!(json["drain_started_ms"], 1721234599999u64);
|
|
assert_eq!(json["turn_active"], true);
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, payload);
|
|
}
|
|
|
|
/// A legacy payload without the new fields deserializes with defaults.
|
|
#[test]
|
|
fn tool_server_status_payload_legacy_without_pr9_fields_defaults() {
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(legacy_status_json()).expect("legacy payload must deserialize");
|
|
assert_eq!(back.status, super::ToolServerLifecycleStatus::Ready);
|
|
assert_eq!(back.drain_started_ms, None);
|
|
assert!(!back.turn_active);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_status_payload_idle_ignores_background_round_trips() {
|
|
let payload = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Ready,
|
|
idle_ignores_background: true,
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&payload).expect("serialize");
|
|
assert_eq!(json["idle_ignores_background"], true);
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert!(back.idle_ignores_background);
|
|
|
|
let default_json =
|
|
serde_json::to_value(super::ToolServerStatusPayload::default()).expect("serialize");
|
|
assert_eq!(default_json["idle_ignores_background"], false);
|
|
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(legacy_status_json()).expect("deserialize");
|
|
assert!(!back.idle_ignores_background);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_status_payload_drain_started_skipped_when_none() {
|
|
let payload = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Ready,
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&payload).expect("serialize");
|
|
assert!(
|
|
json.get("drain_started_ms").is_none(),
|
|
"None drain_started_ms should be skipped on the wire"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_status_payload_connection_id_round_trips_and_skips_none() {
|
|
let with_conn = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Disconnected,
|
|
connection_id: Some("ts-dead".into()),
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&with_conn).expect("serialize");
|
|
assert_eq!(json["connection_id"], "ts-dead");
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back.connection_id.as_deref(), Some("ts-dead"));
|
|
|
|
let without = super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Disconnected,
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_value(&without).expect("serialize");
|
|
assert!(json.get("connection_id").is_none());
|
|
// Round-trip the None case through the payload's own serialized form.
|
|
let back: super::ToolServerStatusPayload =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back.connection_id, None);
|
|
|
|
// Separately, a legacy payload that omits the `connection_id` field
|
|
// entirely still deserializes (forward/backward wire compat).
|
|
let legacy: super::ToolServerStatusPayload =
|
|
serde_json::from_value(legacy_status_json()).expect("legacy payload must deserialize");
|
|
assert_eq!(legacy.connection_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_evict_params_round_trips() {
|
|
let params = super::ToolServerEvictParams {
|
|
session_id: sid(),
|
|
reason: "idle_timeout".into(),
|
|
grace_period_ms: 30000,
|
|
};
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
let back: super::ToolServerEvictParams = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_get_status_params_round_trips() {
|
|
let params = super::ToolServerGetStatusParams { session_id: sid() };
|
|
let json = serde_json::to_value(¶ms).expect("serialize");
|
|
let back: super::ToolServerGetStatusParams =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, params);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_get_status_result_round_trips() {
|
|
let result = super::ToolServerGetStatusResult {
|
|
tool_servers: vec![super::ToolServerConnectionStatus {
|
|
connection_id: "conn-1".into(),
|
|
status: super::ToolServerStatusPayload {
|
|
status: super::ToolServerLifecycleStatus::Ready,
|
|
session_id: None,
|
|
active_tool_calls: 0,
|
|
active_tool_names: vec![],
|
|
background_tasks: 0,
|
|
background_task_ids: vec![],
|
|
pending_tool_calls: 0,
|
|
last_tool_call_started_ms: 0,
|
|
last_tool_call_completed_ms: 0,
|
|
uptime_ms: 120000,
|
|
idle_since_ms: Some(1721234560000),
|
|
..Default::default()
|
|
},
|
|
}],
|
|
};
|
|
let json = serde_json::to_value(&result).expect("serialize");
|
|
let back: super::ToolServerGetStatusResult =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, result);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_server_disconnect_reason_serde() {
|
|
use super::ToolServerDisconnectReason::*;
|
|
for (variant, expected_str) in [
|
|
(NormalClose, "normal_close"),
|
|
(IdleTimeout, "idle_timeout"),
|
|
(ForceEvicted, "force_evicted"),
|
|
(ConnectionLost, "connection_lost"),
|
|
] {
|
|
let json = serde_json::to_value(variant).expect("serialize");
|
|
assert_eq!(json.as_str(), Some(expected_str));
|
|
let back: super::ToolServerDisconnectReason =
|
|
serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(back, variant);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn notification_custom_round_trips_through_serde() {
|
|
let frame = ToolNotificationFrame::custom(tid(), "test.kind", json!({"x": 42}));
|
|
let json = serde_json::to_value(&frame).expect("serialize");
|
|
let back: ToolNotificationFrame = serde_json::from_value(json).expect("deserialize");
|
|
assert_eq!(frame, back);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_frame_missing_hook_id_deserializes_as_none() {
|
|
let v = json!({
|
|
"session_id": "test-session",
|
|
"event": { "type": "Pause" },
|
|
});
|
|
let frame: HookFrame = serde_json::from_value(v).expect("deserialize");
|
|
assert!(frame.hook_id.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_frame_with_hook_id_round_trips() {
|
|
let mut hook = HookFrame::pause(sid());
|
|
hook.hook_id = Some("abc".into());
|
|
let v = serde_json::to_value(&hook).expect("serialize");
|
|
assert_eq!(v["hook_id"], "abc");
|
|
let back: HookFrame = serde_json::from_value(v).expect("deserialize");
|
|
assert_eq!(back.hook_id, Some("abc".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn hook_frame_none_hook_id_skipped_in_json() {
|
|
let hook = HookFrame::pause(sid());
|
|
assert!(hook.hook_id.is_none());
|
|
let v = serde_json::to_value(&hook).expect("serialize");
|
|
assert!(
|
|
v.as_object().unwrap().get("hook_id").is_none(),
|
|
"None hook_id must be absent from JSON"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_frame_empty_string_hook_id_round_trips() {
|
|
let mut hook = HookFrame::pause(sid());
|
|
hook.hook_id = Some(String::new());
|
|
let v = serde_json::to_value(&hook).expect("serialize");
|
|
assert_eq!(v["hook_id"], "");
|
|
let back: HookFrame = serde_json::from_value(v).expect("deserialize");
|
|
assert_eq!(back.hook_id, Some(String::new()));
|
|
}
|
|
}
|