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        result: Result,
75    },
76    /// A failed JSON-RPC response.
77    Error {
78        /// The id of the request this response answers.
79        id: RequestId,
80        /// Method-specific error data.
81        error: Error,
82    },
83}
84
85impl<R, E> Response<R, E> {
86    /// Creates a JSON-RPC response from a Rust [`Result`].
87    #[must_use]
88    pub fn new(id: impl Into<RequestId>, result: std::result::Result<R, E>) -> Self {
89        match result {
90            Ok(result) => Self::Result {
91                id: id.into(),
92                result,
93            },
94            Err(error) => Self::Error {
95                id: id.into(),
96                error,
97            },
98        }
99    }
100}
101
102/// A JSON-RPC notification object.
103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
104#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
105#[allow(
106    clippy::exhaustive_structs,
107    reason = "This comes from the JSON-RPC specification itself"
108)]
109#[cfg_attr(feature = "schemars", schemars(rename = "{Params}", extend("x-docs-ignore" = true)))]
110#[skip_serializing_none]
111pub struct Notification<Params> {
112    /// The notification method name.
113    pub method: Arc<str>,
114    /// Method-specific notification parameters.
115    pub params: Option<Params>,
116}
117
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[cfg_attr(feature = "schemars", schemars(inline))]
121enum JsonRpcVersion {
122    #[serde(rename = "2.0")]
123    V2,
124}
125
126/// A message (request, response, or notification) with `"jsonrpc": "2.0"` specified as
127/// [required by JSON-RPC 2.0 Specification][1].
128///
129/// [1]: https://www.jsonrpc.org/specification#compatibility
130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[cfg_attr(feature = "schemars", schemars(inline))]
133pub struct JsonRpcMessage<M> {
134    jsonrpc: JsonRpcVersion,
135    #[serde(flatten)]
136    message: M,
137}
138
139impl<M> JsonRpcMessage<M> {
140    /// Wraps the provided message into a versioned [`JsonRpcMessage`].
141    #[must_use]
142    pub fn wrap(message: M) -> Self {
143        Self {
144            jsonrpc: JsonRpcVersion::V2,
145            message,
146        }
147    }
148
149    /// Returns the contained message.
150    #[must_use]
151    pub fn inner(&self) -> &M {
152        &self.message
153    }
154
155    /// Unwraps the contained message.
156    #[must_use]
157    pub fn into_inner(self) -> M {
158        self.message
159    }
160}
161
162/// Error returned when constructing an empty JSON-RPC batch.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
164#[display("JSON-RPC batch must contain at least one message")]
165#[non_exhaustive]
166pub struct EmptyJsonRpcBatch;
167
168impl std::error::Error for EmptyJsonRpcBatch {}
169
170/// A non-empty JSON-RPC 2.0 batch message.
171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173#[cfg_attr(feature = "schemars", schemars(inline))]
174#[serde(transparent)]
175#[allow(
176    clippy::exhaustive_structs,
177    reason = "This comes from the JSON-RPC specification itself"
178)]
179pub struct JsonRpcBatch<M>(
180    #[cfg_attr(feature = "schemars", schemars(length(min = 1)))] Vec<JsonRpcMessage<M>>,
181);
182
183impl<M> JsonRpcBatch<M> {
184    /// Creates a non-empty JSON-RPC batch.
185    ///
186    /// Returns an error if `messages` is empty, because JSON-RPC 2.0 treats an
187    /// empty batch array as an invalid request.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`EmptyJsonRpcBatch`] when `messages` is empty.
192    pub fn new(messages: Vec<JsonRpcMessage<M>>) -> Result<Self, EmptyJsonRpcBatch> {
193        if messages.is_empty() {
194            Err(EmptyJsonRpcBatch)
195        } else {
196            Ok(Self(messages))
197        }
198    }
199
200    /// Returns the messages in this batch.
201    #[must_use]
202    pub fn as_slice(&self) -> &[JsonRpcMessage<M>] {
203        &self.0
204    }
205
206    /// Consumes this batch and returns its messages.
207    #[must_use]
208    pub fn into_vec(self) -> Vec<JsonRpcMessage<M>> {
209        self.0
210    }
211}
212
213impl<M> TryFrom<Vec<JsonRpcMessage<M>>> for JsonRpcBatch<M> {
214    type Error = EmptyJsonRpcBatch;
215
216    fn try_from(messages: Vec<JsonRpcMessage<M>>) -> Result<Self, Self::Error> {
217        Self::new(messages)
218    }
219}
220
221impl<'de, M> Deserialize<'de> for JsonRpcBatch<M>
222where
223    M: Deserialize<'de>,
224{
225    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
226    where
227        D: serde::Deserializer<'de>,
228    {
229        let messages = Vec::<JsonRpcMessage<M>>::deserialize(deserializer)?;
230        Self::new(messages).map_err(serde::de::Error::custom)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    use crate::v1::{
239        AgentNotification, CancelNotification, ClientNotification, ContentBlock, ContentChunk,
240        SessionId, SessionNotification, SessionUpdate, TextContent,
241    };
242    use serde_json::{Number, Value, json};
243
244    #[test]
245    fn id_deserialization() {
246        let id = serde_json::from_value::<RequestId>(Value::Null).unwrap();
247        assert_eq!(id, RequestId::Null);
248
249        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_u128(1).unwrap()))
250            .unwrap();
251        assert_eq!(id, RequestId::Number(1));
252
253        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_i128(-1).unwrap()))
254            .unwrap();
255        assert_eq!(id, RequestId::Number(-1));
256
257        let id = serde_json::from_value::<RequestId>(Value::String("id".to_owned())).unwrap();
258        assert_eq!(id, RequestId::Str("id".to_owned()));
259    }
260
261    #[test]
262    fn id_serialization() {
263        let id = serde_json::to_value(RequestId::Null).unwrap();
264        assert_eq!(id, Value::Null);
265
266        let id = serde_json::to_value(RequestId::Number(1)).unwrap();
267        assert_eq!(id, Value::Number(Number::from_u128(1).unwrap()));
268
269        let id = serde_json::to_value(RequestId::Number(-1)).unwrap();
270        assert_eq!(id, Value::Number(Number::from_i128(-1).unwrap()));
271
272        let id = serde_json::to_value(RequestId::Str("id".to_owned())).unwrap();
273        assert_eq!(id, Value::String("id".to_owned()));
274    }
275
276    #[test]
277    fn id_display() {
278        let id = RequestId::Null;
279        assert_eq!(id.to_string(), "null");
280
281        let id = RequestId::Number(1);
282        assert_eq!(id.to_string(), "1");
283
284        let id = RequestId::Number(-1);
285        assert_eq!(id.to_string(), "-1");
286
287        let id = RequestId::Str("id".to_owned());
288        assert_eq!(id.to_string(), "id");
289    }
290
291    #[test]
292    fn batch_deserialization_requires_at_least_one_message() {
293        let err = serde_json::from_value::<JsonRpcBatch<Notification<ClientNotification>>>(
294            Value::Array(Vec::new()),
295        )
296        .unwrap_err();
297        assert!(err.to_string().contains("at least one message"));
298    }
299
300    #[test]
301    fn batch_serialization_round_trips_non_empty_messages() {
302        let notification = JsonRpcMessage::wrap(Notification {
303            method: "cancel".into(),
304            params: Some(ClientNotification::CancelNotification(CancelNotification {
305                session_id: SessionId("test-123".into()),
306                meta: None,
307            })),
308        });
309
310        let batch = JsonRpcBatch::new(vec![notification]).unwrap();
311        let serialized = serde_json::to_value(&batch).unwrap();
312        assert_eq!(
313            serialized,
314            json!([{
315                "jsonrpc": "2.0",
316                "method": "cancel",
317                "params": {
318                    "sessionId": "test-123"
319                },
320            }])
321        );
322
323        let deserialized =
324            serde_json::from_value::<JsonRpcBatch<Notification<ClientNotification>>>(serialized)
325                .unwrap();
326        assert_eq!(deserialized.as_slice().len(), 1);
327        assert_eq!(deserialized.as_slice()[0].inner().method.as_ref(), "cancel");
328    }
329
330    #[test]
331    fn notification_wire_format() {
332        // Test client -> agent notification wire format
333        let outgoing_msg = JsonRpcMessage::wrap(Notification {
334            method: "cancel".into(),
335            params: Some(ClientNotification::CancelNotification(CancelNotification {
336                session_id: SessionId("test-123".into()),
337                meta: None,
338            })),
339        });
340
341        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
342        assert_eq!(
343            serialized,
344            json!({
345                "jsonrpc": "2.0",
346                "method": "cancel",
347                "params": {
348                    "sessionId": "test-123"
349                },
350            })
351        );
352
353        // Test agent -> client notification wire format
354        let outgoing_msg = JsonRpcMessage::wrap(Notification {
355            method: "sessionUpdate".into(),
356            params: Some(AgentNotification::SessionNotification(
357                SessionNotification {
358                    session_id: SessionId("test-456".into()),
359                    update: SessionUpdate::AgentMessageChunk(ContentChunk {
360                        content: ContentBlock::Text(TextContent {
361                            annotations: None,
362                            text: "Hello".to_string(),
363                            meta: None,
364                        }),
365                        message_id: None,
366                        meta: None,
367                    }),
368                    meta: None,
369                },
370            )),
371        });
372
373        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
374        assert_eq!(
375            serialized,
376            json!({
377                "jsonrpc": "2.0",
378                "method": "sessionUpdate",
379                "params": {
380                    "sessionId": "test-456",
381                    "update": {
382                        "sessionUpdate": "agent_message_chunk",
383                        "content": {
384                            "type": "text",
385                            "text": "Hello"
386                        }
387                    }
388                }
389            })
390        );
391    }
392}