Skip to main content

agent_client_protocol_schema/
rpc.rs

1use std::sync::Arc;
2
3use derive_more::{Display, From};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_with::skip_serializing_none;
7
8use crate::{Error, Result};
9
10/// JSON RPC Request Id
11///
12/// 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]
13///
14/// 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.
15///
16/// [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.
17///
18/// [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.
19#[derive(
20    Debug,
21    PartialEq,
22    Clone,
23    Hash,
24    Eq,
25    Deserialize,
26    Serialize,
27    PartialOrd,
28    Ord,
29    Display,
30    JsonSchema,
31    From,
32)]
33#[serde(untagged)]
34#[allow(
35    clippy::exhaustive_enums,
36    reason = "This comes from the JSON-RPC specification itself"
37)]
38#[from(String, i64)]
39pub enum RequestId {
40    #[display("null")]
41    Null,
42    Number(i64),
43    Str(String),
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
47#[allow(
48    clippy::exhaustive_structs,
49    reason = "This comes from the JSON-RPC specification itself"
50)]
51#[schemars(rename = "{Params}", extend("x-docs-ignore" = true))]
52#[skip_serializing_none]
53pub struct Request<Params> {
54    pub id: RequestId,
55    pub method: Arc<str>,
56    pub params: Option<Params>,
57}
58
59#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
60#[allow(
61    clippy::exhaustive_enums,
62    reason = "This comes from the JSON-RPC specification itself"
63)]
64#[serde(untagged)]
65#[schemars(rename = "{Result}", extend("x-docs-ignore" = true))]
66pub enum Response<Result> {
67    Result { id: RequestId, result: Result },
68    Error { id: RequestId, error: Error },
69}
70
71impl<R> Response<R> {
72    #[must_use]
73    pub fn new(id: impl Into<RequestId>, result: Result<R>) -> Self {
74        match result {
75            Ok(result) => Self::Result {
76                id: id.into(),
77                result,
78            },
79            Err(error) => Self::Error {
80                id: id.into(),
81                error,
82            },
83        }
84    }
85}
86
87#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
88#[allow(
89    clippy::exhaustive_structs,
90    reason = "This comes from the JSON-RPC specification itself"
91)]
92#[schemars(rename = "{Params}", extend("x-docs-ignore" = true))]
93#[skip_serializing_none]
94pub struct Notification<Params> {
95    pub method: Arc<str>,
96    pub params: Option<Params>,
97}
98
99#[derive(Debug, Serialize, Deserialize, JsonSchema)]
100#[schemars(inline)]
101enum JsonRpcVersion {
102    #[serde(rename = "2.0")]
103    V2,
104}
105
106/// A message (request, response, or notification) with `"jsonrpc": "2.0"` specified as
107/// [required by JSON-RPC 2.0 Specification][1].
108///
109/// [1]: https://www.jsonrpc.org/specification#compatibility
110#[derive(Debug, Serialize, Deserialize, JsonSchema)]
111#[schemars(inline)]
112pub struct JsonRpcMessage<M> {
113    jsonrpc: JsonRpcVersion,
114    #[serde(flatten)]
115    message: M,
116}
117
118impl<M> JsonRpcMessage<M> {
119    /// Wraps the provided message into a versioned [`JsonRpcMessage`].
120    #[must_use]
121    pub fn wrap(message: M) -> Self {
122        Self {
123            jsonrpc: JsonRpcVersion::V2,
124            message,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    use crate::{
134        AgentNotification, CancelNotification, ClientNotification, ContentBlock, ContentChunk,
135        SessionId, SessionNotification, SessionUpdate, TextContent,
136    };
137    use serde_json::{Number, Value, json};
138
139    #[test]
140    fn id_deserialization() {
141        let id = serde_json::from_value::<RequestId>(Value::Null).unwrap();
142        assert_eq!(id, RequestId::Null);
143
144        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_u128(1).unwrap()))
145            .unwrap();
146        assert_eq!(id, RequestId::Number(1));
147
148        let id = serde_json::from_value::<RequestId>(Value::Number(Number::from_i128(-1).unwrap()))
149            .unwrap();
150        assert_eq!(id, RequestId::Number(-1));
151
152        let id = serde_json::from_value::<RequestId>(Value::String("id".to_owned())).unwrap();
153        assert_eq!(id, RequestId::Str("id".to_owned()));
154    }
155
156    #[test]
157    fn id_serialization() {
158        let id = serde_json::to_value(RequestId::Null).unwrap();
159        assert_eq!(id, Value::Null);
160
161        let id = serde_json::to_value(RequestId::Number(1)).unwrap();
162        assert_eq!(id, Value::Number(Number::from_u128(1).unwrap()));
163
164        let id = serde_json::to_value(RequestId::Number(-1)).unwrap();
165        assert_eq!(id, Value::Number(Number::from_i128(-1).unwrap()));
166
167        let id = serde_json::to_value(RequestId::Str("id".to_owned())).unwrap();
168        assert_eq!(id, Value::String("id".to_owned()));
169    }
170
171    #[test]
172    fn id_display() {
173        let id = RequestId::Null;
174        assert_eq!(id.to_string(), "null");
175
176        let id = RequestId::Number(1);
177        assert_eq!(id.to_string(), "1");
178
179        let id = RequestId::Number(-1);
180        assert_eq!(id.to_string(), "-1");
181
182        let id = RequestId::Str("id".to_owned());
183        assert_eq!(id.to_string(), "id");
184    }
185
186    #[test]
187    fn notification_wire_format() {
188        // Test client -> agent notification wire format
189        let outgoing_msg = JsonRpcMessage::wrap(Notification {
190            method: "cancel".into(),
191            params: Some(ClientNotification::CancelNotification(CancelNotification {
192                session_id: SessionId("test-123".into()),
193                meta: None,
194            })),
195        });
196
197        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
198        assert_eq!(
199            serialized,
200            json!({
201                "jsonrpc": "2.0",
202                "method": "cancel",
203                "params": {
204                    "sessionId": "test-123"
205                },
206            })
207        );
208
209        // Test agent -> client notification wire format
210        let outgoing_msg = JsonRpcMessage::wrap(Notification {
211            method: "sessionUpdate".into(),
212            params: Some(AgentNotification::SessionNotification(
213                SessionNotification {
214                    session_id: SessionId("test-456".into()),
215                    update: SessionUpdate::AgentMessageChunk(ContentChunk {
216                        content: ContentBlock::Text(TextContent {
217                            annotations: None,
218                            text: "Hello".to_string(),
219                            meta: None,
220                        }),
221                        #[cfg(feature = "unstable_message_id")]
222                        message_id: None,
223                        meta: None,
224                    }),
225                    meta: None,
226                },
227            )),
228        });
229
230        let serialized: Value = serde_json::to_value(&outgoing_msg).unwrap();
231        assert_eq!(
232            serialized,
233            json!({
234                "jsonrpc": "2.0",
235                "method": "sessionUpdate",
236                "params": {
237                    "sessionId": "test-456",
238                    "update": {
239                        "sessionUpdate": "agent_message_chunk",
240                        "content": {
241                            "type": "text",
242                            "text": "Hello"
243                        }
244                    }
245                }
246            })
247        );
248    }
249}