Skip to main content

protocol_droid/domain/a2a/
message.rs

1//! A2A `Message` and `Part` value objects.
2//!
3//! # What these are
4//!
5//! A [`Message`] is the unit of conversation between
6//! an A2A client and an A2A agent. It has a role
7//! (the user or the agent), a list of [`Part`]s
8//! (the content), and an optional metadata map.
9//!
10//! A [`Part`] is the unit of content within a
11//! message. A2A defines three part kinds:
12//!
13//! - [`TextPart`](Part::Text) — a UTF-8 string.
14//! - [`FilePart`](Part::File) — a file (named,
15//!   MIME-typed, with either an inline base64
16//!   payload or a URI).
17//! - [`DataPart`](Part::Data) — a structured JSON
18//!   value.
19//!
20//! The wire form of a `Part` is the proto's
21//! `oneof content`: the JSON object has exactly
22//! one of `text`, `file`, or `data` as a top-level
23//! field. The optional `metadata` field rides
24//! alongside whichever content field is present.
25//!
26//! # Why an untagged enum, not a tagged one
27//!
28//! A tagged enum (`{ "kind": "text", "text": "..." }`)
29//! is unambiguous but adds a key the spec does
30//! not name. An untagged enum
31//! (`{ "text": "..." }` for text, `{ "file": {...} }`
32//! for file, `{ "data": {...} }` for data) is
33//! what the spec writes, and serde's untagged
34//! deserialisation is the matching Rust shape.
35//! The trade-off is that a malformed part with
36//! both `text` and `file` deserialises as the
37//! first variant (text) and silently drops the
38//! `file` field; the alternative is
39//! `#[serde(deny_unknown_fields)]` on each
40//! variant, which would reject the malformed
41//! case as a deserialisation error. The spec
42//! allows the lenient shape (a part with at
43//! most one of `text`/`file`/`data`), so the
44//! lenient deserialisation is the right wire
45//! match.
46//!
47//! # Why metadata is on each part variant, not on
48//! the enum
49//!
50//! The proto's `Part` message is a wrapper around
51//! `oneof content` plus a `metadata` field. The
52//! JSON form flattens this — a `Part` is
53//! `{ <content-field>, "metadata": {...}? }`,
54//! and the metadata field rides alongside
55//! whichever content field is present. The Rust
56//! shape that round-trips with the wire form
57//! is one struct per variant, each with its own
58//! `metadata` field; the enum just dispatches
59//! on which of `text`/`file`/`data` is present.
60
61use std::collections::BTreeMap;
62
63use serde::{Deserialize, Serialize};
64
65/// A message exchanged between a client and an
66/// agent.
67///
68/// The wire form is `{ "role": "user" | "agent",
69/// "parts": [<Part>, ...], "metadata"?: {...} }`.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Message {
73    /// The role of the message's author. The
74    /// spec fixes the two values; anything else
75    /// is a wire error.
76    pub role: MessageRole,
77    /// The message's content. A message with no
78    /// parts is technically valid per the
79    /// `OpenAPI 3.x` shape, but it is not
80    /// useful — a message with no content is a
81    /// no-op on the wire.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub parts: Vec<Part>,
84    /// Per-message metadata. The spec leaves the
85    /// shape open; the conventional keys are
86    /// `"trace_id"`, `"session_id"`, and the
87    /// custom names a particular deployment
88    /// uses. The map is `BTreeMap` so the wire
89    /// form is deterministic (alphabetical by
90    /// key), which keeps test fixtures readable.
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    pub metadata: BTreeMap<String, serde_json::Value>,
93}
94
95impl Message {
96    /// Starts a new message with `role` and no
97    /// parts. Parts are added with
98    /// [`with_part`](Self::with_part) (or
99    /// directly via `parts.push(...)`).
100    #[must_use]
101    pub const fn new(role: MessageRole) -> Self {
102        Self {
103            role,
104            parts: Vec::new(),
105            metadata: BTreeMap::new(),
106        }
107    }
108
109    /// Appends a part to the message. The
110    /// message is consumed; chain
111    /// `Message::new(role).with_part(p).with_part(q)`
112    /// to build a multi-part message.
113    #[must_use]
114    pub fn with_part(mut self, part: impl Into<Part>) -> Self {
115        self.parts.push(part.into());
116        self
117    }
118}
119
120/// The author of a [`Message`].
121///
122/// The two values are the spec's exhaustive
123/// list. A message is either from the user
124/// (the human or the calling system) or from
125/// the agent (the A2A server). There is no
126/// "system" role in v1.0.
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum MessageRole {
130    /// The message was sent by the user (the
131    /// human or the calling system on the
132    /// human's behalf).
133    #[default]
134    User,
135    /// The message was sent by the agent (the
136    /// A2A server, in response to a previous
137    /// user message or proactively).
138    Agent,
139}
140
141/// A unit of content within a [`Message`].
142///
143/// The wire form is the proto's `oneof content`:
144/// the JSON object has exactly one of
145/// `text`, `file`, or `data` as a top-level
146/// field. The optional `metadata` field rides
147/// alongside whichever content field is
148/// present.
149///
150/// # Examples
151///
152/// ```
153/// use protocol_droid::domain::a2a::message::{Message, MessageRole, Part, TextPart};
154///
155/// let message = Message::new(MessageRole::User)
156///     .with_part(Part::Text(TextPart {
157///         text: "Hello, agent.".to_owned(),
158///         metadata: std::collections::BTreeMap::new(),
159///     }));
160/// let json = serde_json::to_string(&message).unwrap();
161/// assert!(json.contains(r#""role":"user""#));
162/// assert!(json.contains(r#""text":"Hello, agent.""#));
163/// ```
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(untagged)]
166pub enum Part {
167    /// A UTF-8 string. Wire form:
168    /// `{ "text": "..." }`.
169    Text(TextPart),
170    /// A file (named, MIME-typed, with either an
171    /// inline base64 payload or a URI). Wire
172    /// form: `{ "file": {...} }`.
173    File(FilePart),
174    /// A structured JSON value. Wire form:
175    /// `{ "data": {...} }`.
176    Data(DataPart),
177}
178
179impl From<TextPart> for Part {
180    fn from(part: TextPart) -> Self {
181        Self::Text(part)
182    }
183}
184
185impl From<FilePart> for Part {
186    fn from(part: FilePart) -> Self {
187        Self::File(part)
188    }
189}
190
191impl From<DataPart> for Part {
192    fn from(part: DataPart) -> Self {
193        Self::Data(part)
194    }
195}
196
197/// A text part. Wire form:
198/// `{ "text": "...", "metadata"?: {...} }`.
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
200pub struct TextPart {
201    /// The text. The spec says it is a UTF-8
202    /// string; the Rust type is `String` so the
203    /// value is validated by the JSON parser.
204    pub text: String,
205    /// Per-part metadata. Same shape and
206    /// rationale as [`Message::metadata`].
207    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
208    pub metadata: BTreeMap<String, serde_json::Value>,
209}
210
211/// A file part. Wire form:
212/// `{ "file": {...}, "metadata"?: {...} }`.
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
214pub struct FilePart {
215    /// The file's content. Either `bytes` (an
216    /// inline base64 string) or `uri` is set;
217    /// the spec does not allow both. The Rust
218    /// type does not enforce the rule at the
219    /// type level — a builder that wants the
220    /// guarantee enforces it before
221    /// construction.
222    pub file: FileContent,
223    /// Per-part metadata. Same shape and
224    /// rationale as [`Message::metadata`].
225    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
226    pub metadata: BTreeMap<String, serde_json::Value>,
227}
228
229/// The content of a [`FilePart`]. Wire form:
230/// `{ "name"?: "...", "mimeType"?: "...",
231/// "bytes"?: "base64...", "uri"?: "..." }`.
232#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct FileContent {
235    /// An optional file name. The spec does not
236    /// require it; a part that is a
237    /// `text/plain` snippet with no name is
238    /// valid.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub name: Option<String>,
241    /// An optional MIME type. The spec
242    /// recommends setting it; an agent that
243    /// has to guess the type from a URI or a
244    /// base64 prefix is doing more work than
245    /// the wire form requires.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub mime_type: Option<String>,
248    /// The file's bytes, base64-encoded. Set
249    /// when the file is small enough to fit in
250    /// the message; for larger files, set
251    /// `uri` instead and let the recipient
252    /// fetch it.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub bytes: Option<String>,
255    /// A URI the recipient can fetch the file
256    /// from. The URI is the SSRF surface the
257    /// A2A SSRF denylist filters on the server
258    /// side; a client that sends a URI is
259    /// trusting the server to either fetch it
260    /// or refuse.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub uri: Option<String>,
263}
264
265/// A structured data part. Wire form:
266/// `{ "data": {...}, "metadata"?: {...} }`.
267#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
268pub struct DataPart {
269    /// The data. The shape is whatever the
270    /// sending side and the receiving side
271    /// agree on; the spec does not fix it. A
272    /// consumer who wants a typed shape
273    /// deserialises this into their own type.
274    pub data: serde_json::Value,
275    /// Per-part metadata. Same shape and
276    /// rationale as [`Message::metadata`].
277    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278    pub metadata: BTreeMap<String, serde_json::Value>,
279}
280
281#[cfg(test)]
282mod tests {
283    use std::collections::BTreeMap;
284
285    use super::{DataPart, FileContent, FilePart, Message, MessageRole, Part, TextPart};
286
287    type TestError = Box<dyn std::error::Error>;
288
289    fn metadata_with(key: &str, value: serde_json::Value) -> BTreeMap<String, serde_json::Value> {
290        let mut map = BTreeMap::new();
291        map.insert(key.to_owned(), value);
292        map
293    }
294
295    // --- Message wire form -------------------------------------------
296
297    #[test]
298    fn message_serialises_with_role_and_parts() -> Result<(), TestError> {
299        let message = Message::new(MessageRole::User).with_part(TextPart {
300            text: "Hello.".to_owned(),
301            metadata: BTreeMap::new(),
302        });
303        let value = serde_json::to_value(&message)?;
304        let obj = value
305            .as_object()
306            .ok_or("Message must serialise to a JSON object")?;
307        assert_eq!(
308            obj.get("role").and_then(serde_json::Value::as_str),
309            Some("user")
310        );
311        let parts = obj
312            .get("parts")
313            .and_then(serde_json::Value::as_array)
314            .ok_or("`parts` must be a JSON array")?;
315        assert_eq!(parts.len(), 1);
316        Ok(())
317    }
318
319    #[test]
320    fn message_with_no_metadata_omits_the_field() -> Result<(), TestError> {
321        // The `metadata` field is `Option`-shaped
322        // in the wire form: present only when
323        // the message has metadata. A message
324        // with no metadata must not serialise
325        // `"metadata": {}` on the wire — that
326        // would be a parser-level signal that
327        // differs from "no metadata" on a
328        // strict peer.
329        let message = Message::new(MessageRole::Agent);
330        let value = serde_json::to_value(&message)?;
331        let obj = value
332            .as_object()
333            .ok_or("Message must serialise to a JSON object")?;
334        assert!(!obj.contains_key("metadata"));
335        assert!(!obj.contains_key("parts"));
336        Ok(())
337    }
338
339    #[test]
340    fn message_round_trips_with_role_and_metadata() -> Result<(), TestError> {
341        let message = Message {
342            role: MessageRole::Agent,
343            parts: vec![],
344            metadata: metadata_with("trace_id", serde_json::json!("abc-123")),
345        };
346        let json = serde_json::to_string(&message)?;
347        let parsed: Message = serde_json::from_str(&json)?;
348        assert_eq!(parsed, message);
349        Ok(())
350    }
351
352    // --- Part wire form ----------------------------------------------
353
354    #[test]
355    fn text_part_serialises_with_text_field() -> Result<(), TestError> {
356        let part = Part::Text(TextPart {
357            text: "Hello.".to_owned(),
358            metadata: BTreeMap::new(),
359        });
360        let value = serde_json::to_value(&part)?;
361        let obj = value
362            .as_object()
363            .ok_or("a Part must serialise to a JSON object")?;
364        assert_eq!(
365            obj.get("text").and_then(serde_json::Value::as_str),
366            Some("Hello.")
367        );
368        // A text part must NOT have `file` or
369        // `data` on the wire.
370        assert!(!obj.contains_key("file"));
371        assert!(!obj.contains_key("data"));
372        Ok(())
373    }
374
375    #[test]
376    fn file_part_serialises_with_file_field() -> Result<(), TestError> {
377        let part = Part::File(FilePart {
378            file: FileContent {
379                name: Some("hello.txt".to_owned()),
380                mime_type: Some("text/plain".to_owned()),
381                bytes: Some("SGVsbG8=".to_owned()),
382                uri: None,
383            },
384            metadata: BTreeMap::new(),
385        });
386        let value = serde_json::to_value(&part)?;
387        let obj = value
388            .as_object()
389            .ok_or("a Part must serialise to a JSON object")?;
390        let file = obj
391            .get("file")
392            .and_then(serde_json::Value::as_object)
393            .ok_or("`file` must be a JSON object")?;
394        assert_eq!(
395            file.get("name").and_then(serde_json::Value::as_str),
396            Some("hello.txt")
397        );
398        assert_eq!(
399            file.get("mimeType").and_then(serde_json::Value::as_str),
400            Some("text/plain")
401        );
402        assert_eq!(
403            file.get("bytes").and_then(serde_json::Value::as_str),
404            Some("SGVsbG8=")
405        );
406        // The optional `uri` must not appear
407        // when it is `None` — same shape as
408        // `metadata` above.
409        assert!(!file.contains_key("uri"));
410        Ok(())
411    }
412
413    #[test]
414    fn data_part_serialises_with_data_field() -> Result<(), TestError> {
415        let part = Part::Data(DataPart {
416            data: serde_json::json!({ "answer": 42 }),
417            metadata: BTreeMap::new(),
418        });
419        let value = serde_json::to_value(&part)?;
420        let obj = value
421            .as_object()
422            .ok_or("a Part must serialise to a JSON object")?;
423        let data = obj
424            .get("data")
425            .and_then(serde_json::Value::as_object)
426            .ok_or("`data` must be a JSON object")?;
427        assert_eq!(
428            data.get("answer").and_then(serde_json::Value::as_i64),
429            Some(42)
430        );
431        Ok(())
432    }
433
434    #[test]
435    fn parts_are_unambiguous_on_deserialise() -> Result<(), TestError> {
436        // The wire form is field-presence
437        // discrimination. A `{"text": "..."}`
438        // object decodes to `Part::Text`, not
439        // `Part::File` or `Part::Data`; same
440        // for the others.
441        let text_json = r#"{"text":"hi"}"#;
442        let file_json = r#"{"file":{"name":"x"}}"#;
443        let data_json = r#"{"data":{"a":1}}"#;
444        let text: Part = serde_json::from_str(text_json)
445            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
446        let file: Part = serde_json::from_str(file_json)
447            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
448        let data: Part = serde_json::from_str(data_json)
449            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
450        assert!(matches!(text, Part::Text(_)));
451        assert!(matches!(file, Part::File(_)));
452        assert!(matches!(data, Part::Data(_)));
453        Ok(())
454    }
455
456    #[test]
457    fn text_part_with_metadata_round_trips() -> Result<(), TestError> {
458        let part = Part::Text(TextPart {
459            text: "Hello.".to_owned(),
460            metadata: metadata_with("lang", serde_json::json!("en")),
461        });
462        let json = serde_json::to_string(&part)?;
463        let parsed: Part = serde_json::from_str(&json)?;
464        assert_eq!(parsed, part);
465        // The metadata is a sibling of `text`
466        // on the wire, not nested inside an
467        // inner object.
468        assert!(json.contains(r#""metadata":{"lang":"en"}"#));
469        Ok(())
470    }
471}