//! 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub behavior_version: Option, /// OS-native path; informational for cross-FS tools. #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, /// W3C `traceparent` for distributed tracing. #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_context: Option, } /// 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(default, skip_serializing_if = "Vec::is_empty")] pub reminders: Vec, /// 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, } // 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, } /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_id: Option, 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, 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( tool_id: ToolId, notification: N, ) -> Result { 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, /// 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, } // 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, #[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, } /// `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, } #[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, pub input_schema: serde_json::Value, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ToolsSearchResultBody { pub results: Vec, 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, } /// 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, } /// 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, /// 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, } /// 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub binary_version: Option, /// [`SessionBindResult::unserved_tool_ids`], forwarded verbatim. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unserved_tool_ids: Vec, /// [`SessionBindResult::resolve_error`], forwarded verbatim. #[serde(default, skip_serializing_if = "Option::is_none")] pub resolve_error: Option, } /// `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, /// Free-form caller label for metrics/logs ("fs_read", "deploy_app", …). #[serde(default, skip_serializing_if = "Option::is_none")] pub caller: Option, } /// 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, /// Where the session's tool-server was found. #[serde(default, skip_serializing_if = "Option::is_none")] pub route: Option, } /// 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, } /// 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, /// Tool IDs removed relative to the previous snapshot. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub removed: Vec, } /// `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, /// 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, /// 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, /// 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, } /// `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, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct NotificationFilter { #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_id: Option, /// 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>, } /// 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, /// Required for call-scoped hooks (`Cancel`); optional otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub call_id: Option, /// 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, pub event: HookEvent, /// W3C `traceparent` (mirrors [`ToolCallParams::trace_context`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub trace_context: Option, } 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) -> 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, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub removed: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub updated: Vec, } // 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, /// 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, pub active_tool_calls: u32, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub active_tool_names: Vec, pub background_tasks: u32, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub background_task_ids: Vec, 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, /// 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, /// 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, } /// 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":}`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PingFrame { pub ts_ms: u64, } /// Application-level heartbeat pong. /// /// Serializes as `{"method":"pong","ts_ms":}`. #[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(&self, s: S) -> Result { 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(&self, s: S) -> Result { 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: D) -> Result { #[derive(serde::Deserialize)] struct Raw { method: Option, 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: D) -> Result { #[derive(serde::Deserialize)] struct Raw { method: Option, 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())); } }