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}