kanade_shared/ipc/envelope.rs
1//! JSON-RPC 2.0 envelope types for KLP (SPEC §2.12.3).
2//!
3//! Three message shapes flow over the framed transport (Named Pipe
4//! on Windows, Unix Domain Socket on Linux/macOS):
5//!
6//! - [`RpcRequest`] — `{jsonrpc, id, method, params}`. Carries an
7//! `id` so the recipient can correlate the matching response.
8//! - [`RpcNotification`] — `{jsonrpc, method, params}`. No `id`,
9//! no response. Used for server push (`notifications.new`,
10//! `jobs.progress`, `state.changed`).
11//! - [`RpcResponse`] — `{jsonrpc, id, result|error}`. Exactly one
12//! of `result` or `error` is present.
13//!
14//! [`RpcMessage`] is an untagged enum over the three for the read
15//! side of the connection (one decoder, three possible shapes). The
16//! write side picks the concrete type directly.
17//!
18//! `id` is modelled as a [`String`] to match SPEC §2.12.3's "UUID v7
19//! 推奨" guidance — JSON-RPC 2.0 allows numbers and null too, but
20//! KLP is a closed two-party protocol where both ends are ours, so
21//! we narrow to the form we actually use. Inbound non-string ids
22//! fail decode and the agent returns `InvalidRequest`.
23//!
24//! `params` and `result` are typed as [`serde_json::Value`] at the
25//! envelope layer so the dispatcher can route on `method` BEFORE
26//! committing to a payload schema. Each per-method module
27//! (`handshake`, `system`, `jobs`, …) then `serde_json::from_value`s
28//! into its strongly-typed params/result struct. This is a
29//! deliberate trade — one extra (de)serialise hop in exchange for
30//! the envelope staying method-agnostic, which is what makes the
31//! dispatcher implementable as a `match method.as_str()` block.
32
33use serde::de::DeserializeOwned;
34use serde::{Deserialize, Serialize};
35
36use super::error::RpcError;
37
38/// The version string every KLP message carries in the `jsonrpc`
39/// field. Pinned to `"2.0"` per the JSON-RPC spec; KLP doesn't
40/// negotiate a different RPC version — protocol evolution happens
41/// through the handshake's `protocol` field (SPEC §2.12.6).
42pub const JSONRPC_VERSION: &str = "2.0";
43
44/// Client → Agent request that expects a response (correlated by `id`).
45///
46/// SPEC shape:
47/// ```jsonc
48/// {"jsonrpc":"2.0","id":"01931a8e-...","method":"system.handshake",
49/// "params":{...}}
50/// ```
51#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
52pub struct RpcRequest {
53 pub jsonrpc: String,
54 pub id: String,
55 pub method: String,
56 /// `params` is wire-optional — methods like `system.ping` take
57 /// no arguments and SHOULD omit the field rather than send
58 /// `null`. Decoders see `serde_json::Value::Null` for either
59 /// form, so callers must not rely on absent-vs-null to carry
60 /// meaning.
61 #[serde(default, skip_serializing_if = "is_null")]
62 pub params: serde_json::Value,
63}
64
65/// Server-push or fire-and-forget message with no response (no `id`).
66///
67/// Used for `notifications.new`, `jobs.progress`, `state.changed`
68/// (Agent → Client) and, when needed, request-shaped Client → Agent
69/// messages that don't want a response (none currently — kept here
70/// for symmetry with JSON-RPC 2.0).
71#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
72pub struct RpcNotification {
73 pub jsonrpc: String,
74 pub method: String,
75 #[serde(default, skip_serializing_if = "is_null")]
76 pub params: serde_json::Value,
77}
78
79/// Response to a [`RpcRequest`]. Exactly one of `result` or `error`
80/// is populated — see [`RpcResponsePayload`].
81///
82/// Modelled as a struct with a flattened payload enum (rather than
83/// two field options) so the type system enforces the spec's
84/// "exactly one of" requirement: it's impossible to construct a
85/// response that has both, or neither.
86///
87/// `id` is [`Option<String>`] because JSON-RPC 2.0 mandates `null`
88/// for errors that fire BEFORE the request id can be parsed —
89/// [`super::error::ErrorKind::ParseError`] (the body wasn't valid
90/// JSON at all) and [`super::error::ErrorKind::InvalidRequest`]
91/// (envelope rejected). [`RpcResponse::err_anonymous`] is the
92/// dedicated constructor for that case; the happy-path [`Self::ok`]
93/// / [`Self::err`] keep the `String` ergonomic.
94#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
95pub struct RpcResponse {
96 pub jsonrpc: String,
97 pub id: Option<String>,
98 #[serde(flatten)]
99 pub payload: RpcResponsePayload,
100}
101
102/// Either-or payload for [`RpcResponse`]. `serde(untagged)` means
103/// each variant is recognised purely by which key (`result` or
104/// `error`) is present on the wire.
105#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
106#[serde(untagged)]
107pub enum RpcResponsePayload {
108 /// Success path. `result` may be any JSON value — including
109 /// `null` for void methods like `notifications.unsubscribe`.
110 Ok { result: serde_json::Value },
111 /// Failure path. See [`RpcError`] for the error model.
112 Err { error: RpcError },
113}
114
115/// Top-level decoded message for the agent's read loop. Inbound
116/// bytes are parsed into this enum once; the dispatcher then
117/// matches on the variant to route.
118///
119/// Untagged enum, decoded by trying variants in declaration order:
120/// `Response` first (it owns `result`/`error`, neither of which
121/// appear on requests), then `Request` (has both `id` and
122/// `method`), then `Notification` (has `method` but no `id`). The
123/// ordering matters — putting `Request` first would let it greedily
124/// match `{id, method, error}` because `params` is optional and the
125/// extra `error` field is silently ignored by serde-derived structs.
126#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
127#[serde(untagged)]
128pub enum RpcMessage {
129 Response(RpcResponse),
130 Request(RpcRequest),
131 Notification(RpcNotification),
132}
133
134impl RpcRequest {
135 /// Build a typed request. Serialises `params` to JSON eagerly so
136 /// later dispatch is cheap and the failure surface is just this
137 /// call — no surprises mid-send.
138 pub fn new<P: Serialize>(
139 id: impl Into<String>,
140 method: impl Into<String>,
141 params: &P,
142 ) -> Result<Self, serde_json::Error> {
143 Ok(Self {
144 jsonrpc: JSONRPC_VERSION.to_string(),
145 id: id.into(),
146 method: method.into(),
147 params: serde_json::to_value(params)?,
148 })
149 }
150}
151
152impl RpcNotification {
153 /// Build a typed notification (no id, no response).
154 pub fn new<P: Serialize>(
155 method: impl Into<String>,
156 params: &P,
157 ) -> Result<Self, serde_json::Error> {
158 Ok(Self {
159 jsonrpc: JSONRPC_VERSION.to_string(),
160 method: method.into(),
161 params: serde_json::to_value(params)?,
162 })
163 }
164}
165
166impl RpcResponse {
167 /// Build a success response for a given request `id` from a
168 /// typed result. `R = ()` is encoded as JSON `null`, matching
169 /// SPEC §2.12.7's `{"result":null}` for void method returns.
170 pub fn ok<R: Serialize>(id: impl Into<String>, result: &R) -> Result<Self, serde_json::Error> {
171 Ok(Self {
172 jsonrpc: JSONRPC_VERSION.to_string(),
173 id: Some(id.into()),
174 payload: RpcResponsePayload::Ok {
175 result: serde_json::to_value(result)?,
176 },
177 })
178 }
179
180 /// Build an error response correlated to a known request `id`.
181 pub fn err(id: impl Into<String>, error: RpcError) -> Self {
182 Self {
183 jsonrpc: JSONRPC_VERSION.to_string(),
184 id: Some(id.into()),
185 payload: RpcResponsePayload::Err { error },
186 }
187 }
188
189 /// Build an error response with `id: null` — the JSON-RPC 2.0
190 /// shape for errors that fire before the request id can be
191 /// parsed (`ParseError` on un-decodable JSON; `InvalidRequest`
192 /// on an envelope missing required fields). Distinct from
193 /// [`Self::err`] so the type system makes "I don't have an id
194 /// to correlate" an explicit choice.
195 pub fn err_anonymous(error: RpcError) -> Self {
196 Self {
197 jsonrpc: JSONRPC_VERSION.to_string(),
198 id: None,
199 payload: RpcResponsePayload::Err { error },
200 }
201 }
202}
203
204/// Decode a method's `params` payload from the envelope's
205/// [`serde_json::Value`] slot, treating `Value::Null` (the wire
206/// shape for an omitted `params` field, per SPEC §2.12.3) as
207/// equivalent to `P::default()`.
208///
209/// Solves the "empty params struct can't deserialize from null"
210/// hole: methods like `system.ping` SHOULD omit `params`
211/// (envelope.rs:55 doc), which arrives as `Value::Null`, but
212/// `serde_json::from_value::<PingParams>(Null)` would fail because
213/// PingParams expects an object. Routing every params decode
214/// through this helper lets the dispatcher accept both the
215/// canonical absent form and an explicit `params: {}` without
216/// per-method branching.
217///
218/// Wrong-shape non-null inputs (e.g. an array where an object is
219/// expected) still fail loudly through normal serde decoding — the
220/// helper only widens the null case.
221pub fn decode_params<P: DeserializeOwned + Default>(
222 value: serde_json::Value,
223) -> Result<P, serde_json::Error> {
224 if value.is_null() {
225 Ok(P::default())
226 } else {
227 serde_json::from_value(value)
228 }
229}
230
231fn is_null(v: &serde_json::Value) -> bool {
232 v.is_null()
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::ipc::error::ErrorKind;
239
240 #[derive(Serialize, Deserialize, Debug, PartialEq)]
241 struct DummyParams {
242 foo: String,
243 bar: u32,
244 }
245
246 #[test]
247 fn request_round_trips_through_json() {
248 let req = RpcRequest::new(
249 "u1",
250 "system.handshake",
251 &DummyParams {
252 foo: "hello".into(),
253 bar: 7,
254 },
255 )
256 .expect("encode");
257 let json = serde_json::to_string(&req).unwrap();
258 // Spot-check wire shape — `params` is nested, not flattened.
259 assert!(json.contains("\"jsonrpc\":\"2.0\""), "wire: {json}");
260 assert!(json.contains("\"method\":\"system.handshake\""));
261 assert!(json.contains("\"id\":\"u1\""));
262 let back: RpcRequest = serde_json::from_str(&json).unwrap();
263 assert_eq!(back.id, "u1");
264 assert_eq!(back.method, "system.handshake");
265 let p: DummyParams = serde_json::from_value(back.params).unwrap();
266 assert_eq!(p.foo, "hello");
267 assert_eq!(p.bar, 7);
268 }
269
270 #[test]
271 fn request_without_params_omits_field_on_wire() {
272 // SPEC §2.12.6's `system.ping` has no params — the
273 // serializer SHOULD drop the field rather than emit
274 // `"params":null`, since strict JSON-RPC parsers reject the
275 // latter for some methods.
276 let req = RpcRequest {
277 jsonrpc: JSONRPC_VERSION.into(),
278 id: "ping-1".into(),
279 method: "system.ping".into(),
280 params: serde_json::Value::Null,
281 };
282 let v = serde_json::to_value(&req).unwrap();
283 assert!(v.get("params").is_none(), "wire: {v:?}");
284 }
285
286 #[test]
287 fn notification_decodes_without_id() {
288 // SPEC §2.12.7 push: `notifications.new` arrives with no id.
289 let wire = r#"{"jsonrpc":"2.0","method":"notifications.new",
290 "params":{"id":"notif-9f3a"}}"#;
291 let m: RpcMessage = serde_json::from_str(wire).unwrap();
292 match m {
293 RpcMessage::Notification(n) => {
294 assert_eq!(n.method, "notifications.new");
295 assert_eq!(n.params["id"], "notif-9f3a");
296 }
297 other => panic!("expected Notification, got {other:?}"),
298 }
299 }
300
301 #[test]
302 fn success_response_decodes_and_round_trips() {
303 let r =
304 RpcResponse::ok("u3", &serde_json::json!({"subscription":"sub-n-1"})).expect("encode");
305 let json = serde_json::to_string(&r).unwrap();
306 // Critical: `result` must appear on the wire, not nested in
307 // a `payload` field — the flatten attribute does the work.
308 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
309 assert!(v.get("result").is_some(), "wire: {v:?}");
310 assert!(v.get("error").is_none());
311 // And the message-level decoder must classify it as Response.
312 let m: RpcMessage = serde_json::from_str(&json).unwrap();
313 assert!(matches!(m, RpcMessage::Response(_)));
314 }
315
316 #[test]
317 fn error_response_decodes_and_round_trips() {
318 let r = RpcResponse::err(
319 "u5",
320 RpcError::new(
321 ErrorKind::Unauthorized,
322 "manifest 'reboot' has user_invokable=false",
323 ),
324 );
325 let json = serde_json::to_string(&r).unwrap();
326 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
327 assert!(v.get("error").is_some(), "wire: {v:?}");
328 assert!(v.get("result").is_none());
329 assert_eq!(v["error"]["code"], -32000);
330
331 // Round-trip preserves the discriminant.
332 let back: RpcResponse = serde_json::from_str(&json).unwrap();
333 match back.payload {
334 RpcResponsePayload::Err { error } => assert_eq!(error.code, -32000),
335 other => panic!("expected Err payload, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn message_decoder_distinguishes_request_from_response() {
341 // The tricky case: a Request and a Response both carry `id`.
342 // The decoder MUST recognise Response by the presence of
343 // `result` (or `error`), not by id-vs-method, because there
344 // are no required-method requests we send today that lack
345 // params.
346 let request_wire = r#"{"jsonrpc":"2.0","id":"u1","method":"system.ping"}"#;
347 let response_wire = r#"{"jsonrpc":"2.0","id":"u1","result":null}"#;
348
349 match serde_json::from_str::<RpcMessage>(request_wire).unwrap() {
350 RpcMessage::Request(r) => assert_eq!(r.method, "system.ping"),
351 other => panic!("expected Request, got {other:?}"),
352 }
353 match serde_json::from_str::<RpcMessage>(response_wire).unwrap() {
354 RpcMessage::Response(r) => assert_eq!(r.id.as_deref(), Some("u1")),
355 other => panic!("expected Response, got {other:?}"),
356 }
357 }
358
359 #[test]
360 fn void_result_serialises_as_null() {
361 // SPEC §2.12.7's unsubscribe response is `{"result":null}`.
362 let r = RpcResponse::ok("u4", &()).expect("encode");
363 let v = serde_json::to_value(&r).unwrap();
364 assert!(v["result"].is_null(), "wire: {v}");
365 }
366
367 #[test]
368 fn err_anonymous_serialises_id_as_null() {
369 // JSON-RPC 2.0 mandates `id: null` for errors that fire
370 // before the request id can be parsed (ParseError /
371 // InvalidRequest). Wire MUST carry `"id": null` literally,
372 // not omit the field.
373 let r = RpcResponse::err_anonymous(RpcError::bare(ErrorKind::ParseError));
374 let v = serde_json::to_value(&r).unwrap();
375 assert!(v["id"].is_null(), "wire: {v}");
376 assert_eq!(v["error"]["code"], -32700);
377 }
378
379 #[test]
380 fn anonymous_error_response_round_trips() {
381 let wire = r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}"#;
382 let back: RpcResponse = serde_json::from_str(wire).expect("decode");
383 assert!(back.id.is_none(), "decoded id should be None for null wire");
384 match back.payload {
385 RpcResponsePayload::Err { error } => assert_eq!(error.code, -32700),
386 other => panic!("expected Err payload, got {other:?}"),
387 }
388 }
389
390 // --- decode_params helper (Gemini #1 fix) ---
391
392 #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
393 struct EmptyParams {}
394
395 #[derive(Serialize, Deserialize, Default, Debug, PartialEq)]
396 struct WithDefaults {
397 #[serde(default)]
398 lines: u32,
399 }
400
401 #[test]
402 fn decode_params_treats_null_as_default() {
403 // The reason this helper exists: methods like system.ping
404 // SHOULD omit `params` (envelope wire-form), which decodes
405 // as Value::Null. Direct from_value::<EmptyParams>(Null)
406 // would fail; the helper routes it to P::default().
407 let p: EmptyParams = decode_params(serde_json::Value::Null).expect("null → default");
408 assert_eq!(p, EmptyParams {});
409
410 let p: WithDefaults = decode_params(serde_json::Value::Null).expect("null → default");
411 assert_eq!(p.lines, 0);
412 }
413
414 #[test]
415 fn decode_params_passes_through_explicit_object() {
416 // Non-null inputs go through normal serde — wrong shape
417 // still fails loudly so InvalidParams detection isn't
418 // weakened.
419 let p: WithDefaults =
420 decode_params(serde_json::json!({"lines": 42})).expect("explicit object");
421 assert_eq!(p.lines, 42);
422
423 let err: Result<WithDefaults, _> = decode_params(serde_json::json!(["wrong", "shape"]));
424 assert!(err.is_err(), "non-object input must still fail");
425 }
426}