Skip to main content

protocol_droid/domain/a2a/
send_message.rs

1//! A2A `SendMessage` typed envelopes — the
2//! request, the configuration, and the response.
3//!
4//! # What these are
5//!
6//! [`SendMessageRequest`] is the typed shape of
7//! the `params` field for the `SendMessage`
8//! JSON-RPC method. [`SendMessageResponse`] is
9//! the typed shape of the `result` field —
10//! either a [`Task`] (the work is in progress or
11//! complete) or a [`Message`] (the agent
12//! answered directly without creating a task).
13//!
14//! The handler that implements
15//! [`A2aRequestHandler`](crate::application::a2a::A2aRequestHandler)
16//! decodes the raw `serde_json::Value` params
17//! into a `SendMessageRequest` and serialises
18//! the result back as a `SendMessageResponse`.
19//! The serving adapter
20//! ([`serve_jsonrpc`](crate::adapters::primary::a2a::jsonrpc_serve::serve_jsonrpc))
21//! handles the JSON-RPC envelope; the handler
22//! is responsible only for the method-specific
23//! decoding.
24//!
25//! # Why an untagged enum for the response
26//!
27//! The wire form of a `SendMessage` response is
28//! the inner object directly: a `Task` is
29//! `{"id": "...", "status": ...}`, a `Message`
30//! is `{"role": ..., "parts": ...}`. The two
31//! shapes are discriminated by which top-level
32//! field is present (`id` for `Task`, `role` for
33//! `Message`). An untagged enum matches the
34//! wire form; a tagged enum
35//! (`{"kind": "task", "task": {...}}`) would
36//! add a key the spec does not name.
37//!
38//! # Why `accepted_output_modes` is `BTreeSet`
39//! and not `Vec`
40//!
41//! The `accepted_output_modes` list is a
42//! set of MIME types the client can accept;
43//! a duplicate is a wire bug. The Rust
44//! type that surfaces a duplicate is a
45//! `BTreeSet` (ordered, so the wire form is
46//! deterministic; set, so duplicates fail
47//! at the type level). The trade-off is
48//! losing the order the client sent —
49//! which the spec does not require
50//! preserving.
51
52use std::collections::{BTreeMap, BTreeSet};
53
54use serde::{Deserialize, Serialize};
55
56use super::message::Message;
57use super::task::Task;
58
59/// A typed `SendMessage` request. Wire form:
60/// ```json
61/// {
62///   "message": { "role": "user", "parts": [...] },
63///   "configuration"?: { ... },
64///   "metadata"?: { ... }
65/// }
66/// ```
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct SendMessageRequest {
70    /// The message to send. Required.
71    pub message: Message,
72    /// Optional configuration for the request.
73    /// An absent `configuration` means the
74    /// agent uses its defaults.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub configuration: Option<MessageSendConfiguration>,
77    /// Per-request metadata. The spec leaves
78    /// the shape open; the conventional keys
79    /// are `"trace_id"`, `"session_id"`, and
80    /// the custom names a particular
81    /// deployment uses.
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub metadata: BTreeMap<String, serde_json::Value>,
84}
85
86impl SendMessageRequest {
87    /// Starts a new request with `message`. Use
88    /// [`with_configuration`](Self::with_configuration)
89    /// to set the optional configuration.
90    #[must_use]
91    pub const fn new(message: Message) -> Self {
92        Self {
93            message,
94            configuration: None,
95            metadata: BTreeMap::new(),
96        }
97    }
98
99    /// The configuration to send.
100    ///
101    /// Absent from the request unless this method is called.
102    #[must_use]
103    pub fn with_configuration(mut self, configuration: MessageSendConfiguration) -> Self {
104        self.configuration = Some(configuration);
105        self
106    }
107}
108
109/// Configuration for a `SendMessage` request.
110///
111/// Wire form:
112/// ```json
113/// {
114///   "acceptedOutputModes": ["text/plain", "application/json"],
115///   "historyLength": 10,
116///   "blocking": true
117/// }
118/// ```
119///
120/// All fields are optional. A configuration
121/// with every field set to its default (an
122/// empty `accepted_output_modes`, no history
123/// length, no blocking) serialises to `{}` on
124/// the wire.
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct MessageSendConfiguration {
128    /// The MIME types the client can accept.
129    /// A client that sends a message but the
130    /// agent's reply is in a type not in this
131    /// list is a wire contract violation; the
132    /// agent is free to refuse with a typed
133    /// error.
134    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
135    pub accepted_output_modes: BTreeSet<String>,
136    /// The number of history messages to
137    /// include in the response. A request with
138    /// no history length lets the agent choose.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub history_length: Option<u32>,
141    /// Whether the request should block
142    /// until the task completes. A
143    /// non-blocking request returns a `Task`
144    /// in `Submitted` or `Working` state;
145    /// the client polls with `GetTask` to
146    /// advance.
147    #[serde(default, skip_serializing_if = "is_false")]
148    pub blocking: bool,
149}
150
151// `skip_serializing_if` requires `fn(&T) -> bool`,
152// so the argument is `&bool` even though the
153// type is one byte. The clippy lint is a false
154// positive for serde-internal helpers.
155#[expect(
156    clippy::trivially_copy_pass_by_ref,
157    reason = "required by skip_serializing_if"
158)]
159const fn is_false(b: &bool) -> bool {
160    !*b
161}
162
163impl MessageSendConfiguration {
164    /// Starts a new configuration with the
165    /// accepted output modes from `modes`. Use
166    /// [`with_history_length`](Self::with_history_length)
167    /// and [`with_blocking`](Self::with_blocking)
168    /// to set the other fields.
169    #[must_use]
170    pub fn new(modes: impl IntoIterator<Item = impl Into<String>>) -> Self {
171        Self {
172            accepted_output_modes: modes.into_iter().map(Into::into).collect(),
173            history_length: None,
174            blocking: false,
175        }
176    }
177
178    /// How much history to carry.
179    ///
180    /// Absent from the request unless this method is called, in which case
181    /// the agent keeps the history it has.
182    #[must_use]
183    pub const fn with_history_length(mut self, length: u32) -> Self {
184        self.history_length = Some(length);
185        self
186    }
187
188    /// Marks the request as blocking.
189    #[must_use]
190    pub const fn with_blocking(mut self) -> Self {
191        self.blocking = true;
192        self
193    }
194}
195
196/// A typed `SendMessage` response.
197///
198/// The wire form is the inner object directly —
199/// a `Task` is `{"id": "...", "status": ...}`,
200/// a `Message` is `{"role": ..., "parts": ...}`.
201/// The two shapes are discriminated by which
202/// top-level field is present (`id` for `Task`,
203/// `role` for `Message`).
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(untagged)]
206pub enum SendMessageResponse {
207    /// The agent created (or is creating) a
208    /// task to do the work. The task's
209    /// `status.state` is one of `Submitted`,
210    /// `Working`, `Completed`, `Failed`,
211    /// `Cancelled`, or `InputRequired`. For
212    /// a blocking request, the state is
213    /// `Completed`, `Failed`, `Cancelled`, or
214    /// `InputRequired` by the time the
215    /// response is sent.
216    Task(Task),
217    /// The agent answered directly without
218    /// creating a task. The message is the
219    /// agent's reply.
220    Message(Message),
221}
222
223impl From<Task> for SendMessageResponse {
224    fn from(task: Task) -> Self {
225        Self::Task(task)
226    }
227}
228
229impl From<Message> for SendMessageResponse {
230    fn from(message: Message) -> Self {
231        Self::Message(message)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{MessageSendConfiguration, SendMessageRequest, SendMessageResponse};
238    use crate::domain::a2a::message::{Message, MessageRole, Part, TextPart};
239    use crate::domain::a2a::task::{Artifact, Task, TaskState, TaskStatus};
240
241    type TestError = Box<dyn std::error::Error>;
242
243    fn user_message() -> Message {
244        Message::new(MessageRole::User).with_part(Part::Text(TextPart {
245            text: "Hello.".to_owned(),
246            metadata: std::collections::BTreeMap::new(),
247        }))
248    }
249
250    // --- SendMessageRequest ------------------------------------------
251
252    #[test]
253    fn request_serialises_with_message_field() -> Result<(), TestError> {
254        let request = SendMessageRequest::new(user_message());
255        let value = serde_json::to_value(&request)?;
256        let obj = value
257            .as_object()
258            .ok_or("a SendMessageRequest must serialise to a JSON object")?;
259        assert!(obj.contains_key("message"));
260        // The optional fields are absent on
261        // the wire when they are `None` /
262        // empty.
263        assert!(!obj.contains_key("configuration"));
264        assert!(!obj.contains_key("metadata"));
265        Ok(())
266    }
267
268    #[test]
269    fn request_round_trips_with_configuration() -> Result<(), TestError> {
270        let request = SendMessageRequest::new(user_message()).with_configuration(
271            MessageSendConfiguration::new(["text/plain", "application/json"])
272                .with_history_length(10)
273                .with_blocking(),
274        );
275        let json = serde_json::to_string(&request)?;
276        let parsed: SendMessageRequest = serde_json::from_str(&json)?;
277        assert_eq!(parsed, request);
278        Ok(())
279    }
280
281    // --- MessageSendConfiguration ------------------------------------
282
283    #[test]
284    fn configuration_with_no_fields_is_minimal() -> Result<(), TestError> {
285        // A configuration with every field
286        // at its default serialises to `{}`
287        // on the wire. The `blocking` field
288        // is `false` by default, the
289        // `accepted_output_modes` is empty,
290        // and `history_length` is `None`.
291        let config = MessageSendConfiguration::default();
292        let value = serde_json::to_value(&config)?;
293        let obj = value
294            .as_object()
295            .ok_or("a MessageSendConfiguration must serialise to a JSON object")?;
296        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
297        Ok(())
298    }
299
300    #[test]
301    fn configuration_blocking_field_omits_when_false() -> Result<(), TestError> {
302        // `blocking: false` is the default,
303        // and a field that is at its default
304        // must not be on the wire — the
305        // spec's `OpenAPI 3.x` shape does
306        // not distinguish "not present" from
307        // "present and false", but a strict
308        // peer is entitled to.
309        let config = MessageSendConfiguration::new(["text/plain"]);
310        let value = serde_json::to_value(&config)?;
311        let obj = value
312            .as_object()
313            .ok_or("a MessageSendConfiguration must serialise to a JSON object")?;
314        assert!(!obj.contains_key("blocking"));
315        Ok(())
316    }
317
318    #[test]
319    fn configuration_accepted_modes_round_trip_in_sorted_order() -> Result<(), TestError> {
320        // The `BTreeSet` gives the wire form
321        // a deterministic order (alphabetical),
322        // which keeps test fixtures readable
323        // and the diff-on-the-wire stable.
324        let config = MessageSendConfiguration::new(["text/plain", "application/json", "image/png"]);
325        let value = serde_json::to_value(&config)?;
326        let modes = value
327            .get("acceptedOutputModes")
328            .and_then(serde_json::Value::as_array)
329            .ok_or("`acceptedOutputModes` must be a JSON array")?;
330        let names: Vec<&str> = modes.iter().filter_map(serde_json::Value::as_str).collect();
331        assert_eq!(
332            names,
333            vec!["application/json", "image/png", "text/plain"],
334            "BTreeSet is sorted alphabetically"
335        );
336        Ok(())
337    }
338
339    // --- SendMessageResponse -----------------------------------------
340
341    #[test]
342    fn response_serialises_task_as_task_object() -> Result<(), TestError> {
343        let task = Task::new("task-1").with_status(TaskStatus {
344            state: TaskState::Working,
345            message: None,
346            timestamp: None,
347        });
348        let response = SendMessageResponse::Task(task);
349        let value = serde_json::to_value(&response)?;
350        let obj = value
351            .as_object()
352            .ok_or("a SendMessageResponse must serialise to a JSON object")?;
353        // The wire form is the inner Task
354        // object directly — no wrapper.
355        assert_eq!(
356            obj.get("id").and_then(serde_json::Value::as_str),
357            Some("task-1")
358        );
359        assert!(obj.contains_key("status"));
360        // A `Task` has `id`, a `Message` has
361        // `role`. The discriminator is the
362        // presence of one or the other.
363        assert!(!obj.contains_key("role"));
364        Ok(())
365    }
366
367    #[test]
368    fn response_serialises_message_as_message_object() -> Result<(), TestError> {
369        let message = Message::new(MessageRole::Agent).with_part(Part::Text(TextPart {
370            text: "Hello, user.".to_owned(),
371            metadata: std::collections::BTreeMap::new(),
372        }));
373        let response = SendMessageResponse::Message(message);
374        let value = serde_json::to_value(&response)?;
375        let obj = value
376            .as_object()
377            .ok_or("a SendMessageResponse must serialise to a JSON object")?;
378        assert_eq!(
379            obj.get("role").and_then(serde_json::Value::as_str),
380            Some("agent")
381        );
382        assert!(!obj.contains_key("id"));
383        Ok(())
384    }
385
386    #[test]
387    fn response_untagged_discrimination_works_both_ways() -> Result<(), TestError> {
388        // A `Task` JSON object decodes to
389        // `SendMessageResponse::Task`, not
390        // `::Message`. A `Message` JSON
391        // object decodes to `::Message`.
392        let task_json = r#"{"id":"task-1","status":{"state":"working"}}"#;
393        let message_json = r#"{"role":"agent","parts":[{"text":"hi"}]}"#;
394        let from_task: SendMessageResponse = serde_json::from_str(task_json)
395            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
396        let from_message: SendMessageResponse = serde_json::from_str(message_json)
397            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
398        assert!(matches!(from_task, SendMessageResponse::Task(_)));
399        assert!(matches!(from_message, SendMessageResponse::Message(_)));
400        Ok(())
401    }
402
403    #[test]
404    fn response_task_with_artifacts_round_trips() -> Result<(), TestError> {
405        let task = Task::new("task-1").with_artifact(Artifact::new(Part::Text(TextPart {
406            text: "answer".to_owned(),
407            metadata: std::collections::BTreeMap::new(),
408        })));
409        let response = SendMessageResponse::Task(task);
410        let json = serde_json::to_string(&response)?;
411        let parsed: SendMessageResponse = serde_json::from_str(&json)?;
412        assert_eq!(parsed, response);
413        Ok(())
414    }
415
416    #[test]
417    fn response_from_message_via_into_works() {
418        let message = user_message();
419        let response: SendMessageResponse = message.clone().into();
420        assert_eq!(response, SendMessageResponse::Message(message));
421    }
422}