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    /// Sets the configuration.
100    #[must_use]
101    pub fn with_configuration(mut self, configuration: MessageSendConfiguration) -> Self {
102        self.configuration = Some(configuration);
103        self
104    }
105}
106
107/// Configuration for a `SendMessage` request.
108///
109/// Wire form:
110/// ```json
111/// {
112///   "acceptedOutputModes": ["text/plain", "application/json"],
113///   "historyLength": 10,
114///   "blocking": true
115/// }
116/// ```
117///
118/// All fields are optional. A configuration
119/// with every field set to its default (an
120/// empty `accepted_output_modes`, no history
121/// length, no blocking) serialises to `{}` on
122/// the wire.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct MessageSendConfiguration {
126    /// The MIME types the client can accept.
127    /// A client that sends a message but the
128    /// agent's reply is in a type not in this
129    /// list is a wire contract violation; the
130    /// agent is free to refuse with a typed
131    /// error.
132    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
133    pub accepted_output_modes: BTreeSet<String>,
134    /// The number of history messages to
135    /// include in the response. A request with
136    /// no history length lets the agent choose.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub history_length: Option<u32>,
139    /// Whether the request should block
140    /// until the task completes. A
141    /// non-blocking request returns a `Task`
142    /// in `Submitted` or `Working` state;
143    /// the client polls with `GetTask` to
144    /// advance.
145    #[serde(default, skip_serializing_if = "is_false")]
146    pub blocking: bool,
147}
148
149// `skip_serializing_if` requires `fn(&T) -> bool`,
150// so the argument is `&bool` even though the
151// type is one byte. The clippy lint is a false
152// positive for serde-internal helpers.
153#[expect(
154    clippy::trivially_copy_pass_by_ref,
155    reason = "required by skip_serializing_if"
156)]
157const fn is_false(b: &bool) -> bool {
158    !*b
159}
160
161impl MessageSendConfiguration {
162    /// Starts a new configuration with the
163    /// accepted output modes from `modes`. Use
164    /// [`with_history_length`](Self::with_history_length)
165    /// and [`with_blocking`](Self::with_blocking)
166    /// to set the other fields.
167    #[must_use]
168    pub fn new(modes: impl IntoIterator<Item = impl Into<String>>) -> Self {
169        Self {
170            accepted_output_modes: modes.into_iter().map(Into::into).collect(),
171            history_length: None,
172            blocking: false,
173        }
174    }
175
176    /// Sets the history length.
177    #[must_use]
178    pub const fn with_history_length(mut self, length: u32) -> Self {
179        self.history_length = Some(length);
180        self
181    }
182
183    /// Marks the request as blocking.
184    #[must_use]
185    pub const fn with_blocking(mut self) -> Self {
186        self.blocking = true;
187        self
188    }
189}
190
191/// A typed `SendMessage` response.
192///
193/// The wire form is the inner object directly —
194/// a `Task` is `{"id": "...", "status": ...}`,
195/// a `Message` is `{"role": ..., "parts": ...}`.
196/// The two shapes are discriminated by which
197/// top-level field is present (`id` for `Task`,
198/// `role` for `Message`).
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(untagged)]
201pub enum SendMessageResponse {
202    /// The agent created (or is creating) a
203    /// task to do the work. The task's
204    /// `status.state` is one of `Submitted`,
205    /// `Working`, `Completed`, `Failed`,
206    /// `Cancelled`, or `InputRequired`. For
207    /// a blocking request, the state is
208    /// `Completed`, `Failed`, `Cancelled`, or
209    /// `InputRequired` by the time the
210    /// response is sent.
211    Task(Task),
212    /// The agent answered directly without
213    /// creating a task. The message is the
214    /// agent's reply.
215    Message(Message),
216}
217
218impl From<Task> for SendMessageResponse {
219    fn from(task: Task) -> Self {
220        Self::Task(task)
221    }
222}
223
224impl From<Message> for SendMessageResponse {
225    fn from(message: Message) -> Self {
226        Self::Message(message)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::{MessageSendConfiguration, SendMessageRequest, SendMessageResponse};
233    use crate::domain::a2a::message::{Message, MessageRole, Part, TextPart};
234    use crate::domain::a2a::task::{Artifact, Task, TaskState, TaskStatus};
235
236    type TestError = Box<dyn std::error::Error>;
237
238    fn user_message() -> Message {
239        Message::new(MessageRole::User).with_part(Part::Text(TextPart {
240            text: "Hello.".to_owned(),
241            metadata: std::collections::BTreeMap::new(),
242        }))
243    }
244
245    // --- SendMessageRequest ------------------------------------------
246
247    #[test]
248    fn request_serialises_with_message_field() -> Result<(), TestError> {
249        let request = SendMessageRequest::new(user_message());
250        let value = serde_json::to_value(&request)?;
251        let obj = value
252            .as_object()
253            .ok_or("a SendMessageRequest must serialise to a JSON object")?;
254        assert!(obj.contains_key("message"));
255        // The optional fields are absent on
256        // the wire when they are `None` /
257        // empty.
258        assert!(!obj.contains_key("configuration"));
259        assert!(!obj.contains_key("metadata"));
260        Ok(())
261    }
262
263    #[test]
264    fn request_round_trips_with_configuration() -> Result<(), TestError> {
265        let request = SendMessageRequest::new(user_message()).with_configuration(
266            MessageSendConfiguration::new(["text/plain", "application/json"])
267                .with_history_length(10)
268                .with_blocking(),
269        );
270        let json = serde_json::to_string(&request)?;
271        let parsed: SendMessageRequest = serde_json::from_str(&json)?;
272        assert_eq!(parsed, request);
273        Ok(())
274    }
275
276    // --- MessageSendConfiguration ------------------------------------
277
278    #[test]
279    fn configuration_with_no_fields_is_minimal() -> Result<(), TestError> {
280        // A configuration with every field
281        // at its default serialises to `{}`
282        // on the wire. The `blocking` field
283        // is `false` by default, the
284        // `accepted_output_modes` is empty,
285        // and `history_length` is `None`.
286        let config = MessageSendConfiguration::default();
287        let value = serde_json::to_value(&config)?;
288        let obj = value
289            .as_object()
290            .ok_or("a MessageSendConfiguration must serialise to a JSON object")?;
291        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
292        Ok(())
293    }
294
295    #[test]
296    fn configuration_blocking_field_omits_when_false() -> Result<(), TestError> {
297        // `blocking: false` is the default,
298        // and a field that is at its default
299        // must not be on the wire — the
300        // spec's `OpenAPI 3.x` shape does
301        // not distinguish "not present" from
302        // "present and false", but a strict
303        // peer is entitled to.
304        let config = MessageSendConfiguration::new(["text/plain"]);
305        let value = serde_json::to_value(&config)?;
306        let obj = value
307            .as_object()
308            .ok_or("a MessageSendConfiguration must serialise to a JSON object")?;
309        assert!(!obj.contains_key("blocking"));
310        Ok(())
311    }
312
313    #[test]
314    fn configuration_accepted_modes_round_trip_in_sorted_order() -> Result<(), TestError> {
315        // The `BTreeSet` gives the wire form
316        // a deterministic order (alphabetical),
317        // which keeps test fixtures readable
318        // and the diff-on-the-wire stable.
319        let config = MessageSendConfiguration::new(["text/plain", "application/json", "image/png"]);
320        let value = serde_json::to_value(&config)?;
321        let modes = value
322            .get("acceptedOutputModes")
323            .and_then(serde_json::Value::as_array)
324            .ok_or("`acceptedOutputModes` must be a JSON array")?;
325        let names: Vec<&str> = modes.iter().filter_map(serde_json::Value::as_str).collect();
326        assert_eq!(
327            names,
328            vec!["application/json", "image/png", "text/plain"],
329            "BTreeSet is sorted alphabetically"
330        );
331        Ok(())
332    }
333
334    // --- SendMessageResponse -----------------------------------------
335
336    #[test]
337    fn response_serialises_task_as_task_object() -> Result<(), TestError> {
338        let task = Task::new("task-1").with_status(TaskStatus {
339            state: TaskState::Working,
340            message: None,
341            timestamp: None,
342        });
343        let response = SendMessageResponse::Task(task);
344        let value = serde_json::to_value(&response)?;
345        let obj = value
346            .as_object()
347            .ok_or("a SendMessageResponse must serialise to a JSON object")?;
348        // The wire form is the inner Task
349        // object directly — no wrapper.
350        assert_eq!(
351            obj.get("id").and_then(serde_json::Value::as_str),
352            Some("task-1")
353        );
354        assert!(obj.contains_key("status"));
355        // A `Task` has `id`, a `Message` has
356        // `role`. The discriminator is the
357        // presence of one or the other.
358        assert!(!obj.contains_key("role"));
359        Ok(())
360    }
361
362    #[test]
363    fn response_serialises_message_as_message_object() -> Result<(), TestError> {
364        let message = Message::new(MessageRole::Agent).with_part(Part::Text(TextPart {
365            text: "Hello, user.".to_owned(),
366            metadata: std::collections::BTreeMap::new(),
367        }));
368        let response = SendMessageResponse::Message(message);
369        let value = serde_json::to_value(&response)?;
370        let obj = value
371            .as_object()
372            .ok_or("a SendMessageResponse must serialise to a JSON object")?;
373        assert_eq!(
374            obj.get("role").and_then(serde_json::Value::as_str),
375            Some("agent")
376        );
377        assert!(!obj.contains_key("id"));
378        Ok(())
379    }
380
381    #[test]
382    fn response_untagged_discrimination_works_both_ways() -> Result<(), TestError> {
383        // A `Task` JSON object decodes to
384        // `SendMessageResponse::Task`, not
385        // `::Message`. A `Message` JSON
386        // object decodes to `::Message`.
387        let task_json = r#"{"id":"task-1","status":{"state":"working"}}"#;
388        let message_json = r#"{"role":"agent","parts":[{"text":"hi"}]}"#;
389        let from_task: SendMessageResponse = serde_json::from_str(task_json)
390            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
391        let from_message: SendMessageResponse = serde_json::from_str(message_json)
392            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
393        assert!(matches!(from_task, SendMessageResponse::Task(_)));
394        assert!(matches!(from_message, SendMessageResponse::Message(_)));
395        Ok(())
396    }
397
398    #[test]
399    fn response_task_with_artifacts_round_trips() -> Result<(), TestError> {
400        let task = Task::new("task-1").with_artifact(Artifact::new(Part::Text(TextPart {
401            text: "answer".to_owned(),
402            metadata: std::collections::BTreeMap::new(),
403        })));
404        let response = SendMessageResponse::Task(task);
405        let json = serde_json::to_string(&response)?;
406        let parsed: SendMessageResponse = serde_json::from_str(&json)?;
407        assert_eq!(parsed, response);
408        Ok(())
409    }
410
411    #[test]
412    fn response_from_message_via_into_works() {
413        let message = user_message();
414        let response: SendMessageResponse = message.clone().into();
415        assert_eq!(response, SendMessageResponse::Message(message));
416    }
417}