Skip to main content

agent_client_protocol_schema/
rpc.rs

1//! JSON-RPC envelope types shared by ACP clients and agents.
2//!
3//! These types model the JSON-RPC 2.0 request, response, notification, and
4//! batch envelopes that wrap ACP method-specific payloads.
5
6use std::sync::Arc;
7
8use derive_more::{Display, From};
9use serde::{Deserialize, Serialize};
10use serde_with::skip_serializing_none;
11
12/// JSON RPC Request Id
13///
14/// An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null \[1\] and Numbers SHOULD NOT contain fractional parts \[2\]
15///
16/// The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.
17///
18/// \[1\] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.
19///
20/// \[2\] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22#[derive(
23    Debug, PartialEq, Clone, Hash, Eq, Deserialize, Serialize, PartialOrd, Ord, Display, From,
24)]
25#[serde(untagged)]
26#[allow(
27    clippy::exhaustive_enums,
28    reason = "This comes from the JSON-RPC specification itself"
29)]
30#[from(String, i64)]
31pub enum RequestId {
32    /// The JSON-RPC `null` request id.
33    #[display("null")]
34    Null,
35    /// A numeric JSON-RPC request id.
36    Number(i64),
37    /// A string JSON-RPC request id.
38    Str(String),
39}
40
41/// A JSON-RPC request object.
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
44#[allow(
45    clippy::exhaustive_structs,
46    reason = "This comes from the JSON-RPC specification itself"
47)]
48#[cfg_attr(feature = "schemars", schemars(rename = "{Params}", extend("x-docs-ignore" = true)))]
49#[skip_serializing_none]
50pub struct Request<Params> {
51    /// The request id used to correlate the matching response.
52    pub id: RequestId,
53    /// The method name to invoke.
54    pub method: Arc<str>,
55    /// Method-specific request parameters.
56    pub params: Option<Params>,
57}
58
59/// A JSON-RPC response object.
60#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
61#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
62#[allow(
63    clippy::exhaustive_enums,
64    reason = "This comes from the JSON-RPC specification itself"
65)]
66#[serde(untagged)]
67#[cfg_attr(feature = "schemars", schemars(rename = "{Result}", extend("x-docs-ignore" = true)))]
68pub enum Response<Result, Error> {
69    /// A successful JSON-RPC response.
70    Result {
71        /// The id of the request this response answers.
72        id: RequestId,
73        /// Method-specific response data.
74        // Require the key even when the payload's deserializer accepts null.
75        #[serde(
76            deserialize_with = "Deserialize::deserialize",
77            bound(deserialize = "Result: Deserialize<'de>")
78        )]
79        result: Result,
80    },
81    /// A failed JSON-RPC response.
82    Error {
83        /// The id of the request this response answers.
84        id: RequestId,
85        /// Method-specific error data.
86        error: Error,
87    },
88}
89
90impl<R, E> Response<R, E> {
91    /// Creates a JSON-RPC response from a Rust [`Result`].
92    #[must_use]
93    pub fn new(id: impl Into<RequestId>, result: std::result::Result<R, E>) -> Self {
94        match result {
95            Ok(result) => Self::Result {
96                id: id.into(),
97                result,
98            },
99            Err(error) => Self::Error {
100                id: id.into(),
101                error,
102            },
103        }
104    }
105}
106
107/// A JSON-RPC notification object.
108#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
110#[allow(
111    clippy::exhaustive_structs,
112    reason = "This comes from the JSON-RPC specification itself"
113)]
114#[cfg_attr(feature = "schemars", schemars(rename = "{Params}", extend("x-docs-ignore" = true)))]
115#[skip_serializing_none]
116pub struct Notification<Params> {
117    /// The notification method name.
118    pub method: Arc<str>,
119    /// Method-specific notification parameters.
120    pub params: Option<Params>,
121}
122
123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[cfg_attr(feature = "schemars", schemars(inline))]
126enum JsonRpcVersion {
127    #[serde(rename = "2.0")]
128    V2,
129}
130
131/// A message (request, response, or notification) with `"jsonrpc": "2.0"` specified as
132/// [required by JSON-RPC 2.0 Specification][1].
133///
134/// [1]: https://www.jsonrpc.org/specification#compatibility
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schemars", schemars(inline))]
138pub struct JsonRpcMessage<M> {
139    jsonrpc: JsonRpcVersion,
140    #[serde(flatten)]
141    message: M,
142}
143
144impl<M> JsonRpcMessage<M> {
145    /// Wraps the provided message into a versioned [`JsonRpcMessage`].
146    #[must_use]
147    pub fn wrap(message: M) -> Self {
148        Self {
149            jsonrpc: JsonRpcVersion::V2,
150            message,
151        }
152    }
153
154    /// Returns the contained message.
155    #[must_use]
156    pub fn inner(&self) -> &M {
157        &self.message
158    }
159
160    /// Unwraps the contained message.
161    #[must_use]
162    pub fn into_inner(self) -> M {
163        self.message
164    }
165}
166
167/// Error returned when constructing an empty JSON-RPC batch.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
169#[display("JSON-RPC batch must contain at least one message")]
170#[non_exhaustive]
171pub struct EmptyJsonRpcBatch;
172
173impl std::error::Error for EmptyJsonRpcBatch {}
174
175/// A non-empty JSON-RPC 2.0 batch message.
176#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178#[cfg_attr(feature = "schemars", schemars(inline))]
179#[serde(transparent)]
180#[allow(
181    clippy::exhaustive_structs,
182    reason = "This comes from the JSON-RPC specification itself"
183)]
184pub struct JsonRpcBatch<M>(
185    #[cfg_attr(feature = "schemars", schemars(length(min = 1)))] Vec<JsonRpcMessage<M>>,
186);
187
188impl<M> JsonRpcBatch<M> {
189    /// Creates a non-empty JSON-RPC batch.
190    ///
191    /// Returns an error if `messages` is empty, because JSON-RPC 2.0 treats an
192    /// empty batch array as an invalid request.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`EmptyJsonRpcBatch`] when `messages` is empty.
197    pub fn new(messages: Vec<JsonRpcMessage<M>>) -> Result<Self, EmptyJsonRpcBatch> {
198        if messages.is_empty() {
199            Err(EmptyJsonRpcBatch)
200        } else {
201            Ok(Self(messages))
202        }
203    }
204
205    /// Returns the messages in this batch.
206    #[must_use]
207    pub fn as_slice(&self) -> &[JsonRpcMessage<M>] {
208        &self.0
209    }
210
211    /// Consumes this batch and returns its messages.
212    #[must_use]
213    pub fn into_vec(self) -> Vec<JsonRpcMessage<M>> {
214        self.0
215    }
216}
217
218impl<M> TryFrom<Vec<JsonRpcMessage<M>>> for JsonRpcBatch<M> {
219    type Error = EmptyJsonRpcBatch;
220
221    fn try_from(messages: Vec<JsonRpcMessage<M>>) -> Result<Self, Self::Error> {
222        Self::new(messages)
223    }
224}
225
226impl<'de, M> Deserialize<'de> for JsonRpcBatch<M>
227where
228    M: Deserialize<'de>,
229{
230    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231    where
232        D: serde::Deserializer<'de>,
233    {
234        let messages = Vec::<JsonRpcMessage<M>>::deserialize(deserializer)?;
235        Self::new(messages).map_err(serde::de::Error::custom)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    use crate::v1::{
244        AgentNotification, CancelNotification, ClientNotification, ContentBlock, ContentChunk,
245        SessionId, SessionNotification, SessionUpdate, TextContent,
246    };
247    use serde_json::{Number, Value, json};
248
249    #[test]
250    fn id_deserialization() {
251        let id = serde_json::from_value::<RequestId>(Value::Null).unwrap();
252        assert_eq!(id, RequestId::Null);
253
254        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_u128(1).unwrap()))
255            .unwrap();
256        assert_eq!(id, RequestId::Number(1));
257
258        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_i128(-1).unwrap()))
259            .unwrap();
260        assert_eq!(id, RequestId::Number(-1));
261
262        let id = serde_json::from_value::<RequestId>(Value::String("id".to_owned())).unwrap();
263        assert_eq!(id, RequestId::Str("id".to_owned()));
264    }
265
266    #[test]
267    fn id_serialization() {
268        let id = serde_json::to_value(RequestId::Null).unwrap();
269        assert_eq!(id, Value::Null);
270
271        let id = serde_json::to_value(RequestId::Number(1)).unwrap();
272        assert_eq!(id, Value::Number(Number::from_u128(1).unwrap()));
273
274        let id = serde_json::to_value(RequestId::Number(-1)).unwrap();
275        assert_eq!(id, Value::Number(Number::from_i128(-1).unwrap()));
276
277        let id = serde_json::to_value(RequestId::Str("id".to_owned())).unwrap();
278        assert_eq!(id, Value::String("id".to_owned()));
279    }
280
281    #[test]
282    fn id_display() {
283        let id = RequestId::Null;
284        assert_eq!(id.to_string(), "null");
285
286        let id = RequestId::Number(1);
287        assert_eq!(id.to_string(), "1");
288
289        let id = RequestId::Number(-1);
290        assert_eq!(id.to_string(), "-1");
291
292        let id = RequestId::Str("id".to_owned());
293        assert_eq!(id.to_string(), "id");
294    }
295
296    #[test]
297    fn batch_deserialization_requires_at_least_one_message() {
298        let err = serde_json::from_value::<JsonRpcBatch<Notification<ClientNotification>>>(
299            Value::Array(Vec::new()),
300        )
301        .unwrap_err();
302        assert!(err.to_string().contains("at least one message"));
303    }
304
305    #[test]
306    fn batch_serialization_round_trips_non_empty_messages() {
307        let notification = JsonRpcMessage::wrap(Notification {
308            method: "cancel".into(),
309            params: Some(ClientNotification::CancelNotification(CancelNotification {
310                session_id: SessionId("test-123".into()),
311                meta: None,
312            })),
313        });
314
315        let batch = JsonRpcBatch::new(vec![notification]).unwrap();
316        let serialized = serde_json::to_value(&batch).unwrap();
317        assert_eq!(
318            serialized,
319            json!([{
320                "jsonrpc": "2.0",
321                "method": "cancel",
322                "params": {
323                    "sessionId": "test-123"
324                },
325            }])
326        );
327
328        let deserialized =
329            serde_json::from_value::<JsonRpcBatch<Notification<ClientNotification>>>(serialized)
330                .unwrap();
331        assert_eq!(deserialized.as_slice().len(), 1);
332        assert_eq!(deserialized.as_slice()[0].inner().method.as_ref(), "cancel");
333    }
334
335    #[test]
336    fn notification_wire_format() {
337        // Test client -> agent notification wire format
338        let outgoing_msg = JsonRpcMessage::wrap(Notification {
339            method: "cancel".into(),
340            params: Some(ClientNotification::CancelNotification(CancelNotification {
341                session_id: SessionId("test-123".into()),
342                meta: None,
343            })),
344        });
345
346        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
347        assert_eq!(
348            serialized,
349            json!({
350                "jsonrpc": "2.0",
351                "method": "cancel",
352                "params": {
353                    "sessionId": "test-123"
354                },
355            })
356        );
357
358        // Test agent -> client notification wire format
359        let outgoing_msg = JsonRpcMessage::wrap(Notification {
360            method: "sessionUpdate".into(),
361            params: Some(AgentNotification::SessionNotification(
362                SessionNotification {
363                    session_id: SessionId("test-456".into()),
364                    update: SessionUpdate::AgentMessageChunk(ContentChunk {
365                        content: ContentBlock::Text(TextContent {
366                            annotations: None,
367                            text: "Hello".to_string(),
368                            meta: None,
369                        }),
370                        message_id: None,
371                        meta: None,
372                    }),
373                    meta: None,
374                },
375            )),
376        });
377
378        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
379        assert_eq!(
380            serialized,
381            json!({
382                "jsonrpc": "2.0",
383                "method": "sessionUpdate",
384                "params": {
385                    "sessionId": "test-456",
386                    "update": {
387                        "sessionUpdate": "agent_message_chunk",
388                        "content": {
389                            "type": "text",
390                            "text": "Hello"
391                        }
392                    }
393                }
394            })
395        );
396    }
397}