Skip to main content

a2a_protocol_types/
message.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Message types for the A2A protocol.
5//!
6//! A [`Message`] is the fundamental communication unit between a client and an
7//! agent. Each message has a [`MessageRole`] (`ROLE_USER` or `ROLE_AGENT`) and
8//! carries one or more [`Part`] values.
9//!
10//! # Part oneof
11//!
12//! [`Part`] is a flat struct with a [`PartContent`] oneof discriminated by
13//! field presence: `{"text": "hi"}`, `{"raw": "base64..."}`, `{"url": "..."}`,
14//! or `{"data": {...}}`.
15
16use serde::{Deserialize, Serialize};
17
18use crate::task::{ContextId, TaskId};
19
20// ── MessageId ─────────────────────────────────────────────────────────────────
21
22/// Opaque unique identifier for a [`Message`].
23///
24/// Wraps a `String` for compile-time type safety — a [`MessageId`] cannot be
25/// accidentally passed where a [`TaskId`] is expected.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct MessageId(pub String);
28
29impl MessageId {
30    /// Creates a new [`MessageId`] from any string-like value.
31    #[must_use]
32    pub fn new(s: impl Into<String>) -> Self {
33        Self(s.into())
34    }
35}
36
37impl std::fmt::Display for MessageId {
38    #[inline]
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44impl From<String> for MessageId {
45    fn from(s: String) -> Self {
46        Self(s)
47    }
48}
49
50impl From<&str> for MessageId {
51    fn from(s: &str) -> Self {
52        Self(s.to_owned())
53    }
54}
55
56impl AsRef<str> for MessageId {
57    fn as_ref(&self) -> &str {
58        &self.0
59    }
60}
61
62// ── MessageRole ───────────────────────────────────────────────────────────────
63
64/// The originator of a [`Message`].
65#[non_exhaustive]
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum MessageRole {
68    /// Proto default (0-value); should not appear in normal usage.
69    #[serde(rename = "ROLE_UNSPECIFIED")]
70    Unspecified,
71    /// Sent by the human/client side.
72    #[serde(rename = "ROLE_USER")]
73    User,
74    /// Sent by the agent.
75    #[serde(rename = "ROLE_AGENT")]
76    Agent,
77}
78
79impl std::fmt::Display for MessageRole {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        let s = match self {
82            Self::Unspecified => "ROLE_UNSPECIFIED",
83            Self::User => "ROLE_USER",
84            Self::Agent => "ROLE_AGENT",
85        };
86        f.write_str(s)
87    }
88}
89
90// ── Message ───────────────────────────────────────────────────────────────────
91
92/// A message exchanged between a client and an agent.
93///
94/// The wire `kind` field (`"message"`) is injected by enclosing discriminated
95/// unions such as [`crate::events::StreamResponse`] and
96/// [`crate::responses::SendMessageResponse`]. Standalone `Message` values
97/// received over the wire may include `kind`; serde silently tolerates unknown
98/// fields, so no action is needed.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "camelCase")]
101pub struct Message {
102    /// Unique message identifier.
103    #[serde(rename = "messageId")]
104    pub id: MessageId,
105
106    /// Role of the message originator.
107    pub role: MessageRole,
108
109    /// Message content parts.
110    ///
111    /// **Spec requirement:** Must contain at least one element. The A2A
112    /// protocol does not define behavior for empty parts lists.
113    pub parts: Vec<Part>,
114
115    /// Task this message belongs to, if any.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub task_id: Option<TaskId>,
118
119    /// Conversation context this message belongs to, if any.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub context_id: Option<ContextId>,
122
123    /// IDs of tasks referenced by this message.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub reference_task_ids: Option<Vec<TaskId>>,
126
127    /// URIs of extensions used in this message.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub extensions: Option<Vec<String>>,
130
131    /// Arbitrary metadata.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub metadata: Option<serde_json::Value>,
134}
135
136// ── Part ─────────────────────────────────────────────────────────────────────
137
138/// A content part within a [`Message`] or [`crate::artifact::Artifact`].
139///
140/// A flat struct with a [`PartContent`] oneof and common fields. In JSON,
141/// exactly one of `text`, `raw`, `url`, or `data` is present, which
142/// determines the content type.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct Part {
146    /// The content of this part (one of text, raw, url, or data).
147    #[serde(flatten)]
148    pub content: PartContent,
149
150    /// Arbitrary metadata for this part.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub metadata: Option<serde_json::Value>,
153
154    /// Optional filename associated with this part.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub filename: Option<String>,
157
158    /// MIME type of the content (e.g. `"image/png"`).
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub media_type: Option<String>,
161}
162
163impl Part {
164    /// Creates a text [`Part`] with the given content.
165    #[must_use]
166    pub fn text(text: impl Into<String>) -> Self {
167        Self {
168            content: PartContent::Text { text: text.into() },
169            metadata: None,
170            filename: None,
171            media_type: None,
172        }
173    }
174
175    /// Creates a raw (bytes) [`Part`] with base64-encoded data.
176    #[must_use]
177    pub fn raw(raw: impl Into<String>) -> Self {
178        Self {
179            content: PartContent::Raw { raw: raw.into() },
180            metadata: None,
181            filename: None,
182            media_type: None,
183        }
184    }
185
186    /// Creates a URL [`Part`].
187    #[must_use]
188    pub fn url(url: impl Into<String>) -> Self {
189        Self {
190            content: PartContent::Url { url: url.into() },
191            metadata: None,
192            filename: None,
193            media_type: None,
194        }
195    }
196
197    /// Creates a data [`Part`] carrying structured JSON.
198    #[must_use]
199    pub const fn data(data: serde_json::Value) -> Self {
200        Self {
201            content: PartContent::Data { data },
202            metadata: None,
203            filename: None,
204            media_type: None,
205        }
206    }
207}
208
209// ── PartContent ──────────────────────────────────────────────────────────────
210
211/// The content of a [`Part`], discriminated by field presence (proto oneof).
212///
213/// In JSON, exactly one field is present: `"text"`, `"raw"`, `"url"`, or
214/// `"data"`.
215#[non_exhaustive]
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(untagged)]
218pub enum PartContent {
219    /// Plain-text content.
220    Text {
221        /// The text content.
222        text: String,
223    },
224    /// Raw binary content (base64-encoded in JSON).
225    Raw {
226        /// Base64-encoded bytes.
227        raw: String,
228    },
229    /// URL reference to content.
230    Url {
231        /// Absolute URL.
232        url: String,
233    },
234    /// Structured JSON data.
235    Data {
236        /// Structured JSON payload.
237        data: serde_json::Value,
238    },
239}
240
241// ── Tests ─────────────────────────────────────────────────────────────────────
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn make_message() -> Message {
248        Message {
249            id: MessageId::new("msg-1"),
250            role: MessageRole::User,
251            parts: vec![Part::text("Hello")],
252            task_id: None,
253            context_id: None,
254            reference_task_ids: None,
255            extensions: None,
256            metadata: None,
257        }
258    }
259
260    #[test]
261    fn message_roundtrip() {
262        let msg = make_message();
263        let json = serde_json::to_string(&msg).expect("serialize");
264        assert!(json.contains("\"messageId\":\"msg-1\""));
265        assert!(json.contains("\"role\":\"ROLE_USER\""));
266
267        let back: Message = serde_json::from_str(&json).expect("deserialize");
268        assert_eq!(back.id, MessageId::new("msg-1"));
269        assert_eq!(back.role, MessageRole::User);
270    }
271
272    #[test]
273    fn text_part_roundtrip() {
274        let part = Part::text("hello world");
275        let json = serde_json::to_string(&part).expect("serialize");
276        assert!(!json.contains("\"kind\""), "v1.0 should not have kind tag");
277        assert!(json.contains("\"text\":\"hello world\""));
278        let back: Part = serde_json::from_str(&json).expect("deserialize");
279        assert!(matches!(back.content, PartContent::Text { .. }));
280    }
281
282    #[test]
283    fn raw_part_roundtrip() {
284        let mut part = Part::raw("aGVsbG8=");
285        part.filename = Some("test.png".into());
286        part.media_type = Some("image/png".into());
287        let json = serde_json::to_string(&part).expect("serialize");
288        assert!(json.contains("\"raw\""));
289        assert!(json.contains("\"filename\""));
290        assert!(json.contains("\"mediaType\""));
291        let back: Part = serde_json::from_str(&json).expect("deserialize");
292        assert!(matches!(back.content, PartContent::Raw { .. }));
293        assert_eq!(back.filename.as_deref(), Some("test.png"));
294    }
295
296    #[test]
297    fn url_part_roundtrip() {
298        let part = Part::url("https://example.com/file.pdf");
299        let json = serde_json::to_string(&part).expect("serialize");
300        assert!(json.contains("\"url\""));
301        let back: Part = serde_json::from_str(&json).expect("deserialize");
302        assert!(matches!(back.content, PartContent::Url { .. }));
303    }
304
305    #[test]
306    fn data_part_roundtrip() {
307        let part = Part::data(serde_json::json!({"key": "value"}));
308        let json = serde_json::to_string(&part).expect("serialize");
309        assert!(!json.contains("\"kind\""), "v1.0 should not have kind tag");
310        assert!(json.contains("\"data\""));
311        let back: Part = serde_json::from_str(&json).expect("deserialize");
312        assert!(matches!(back.content, PartContent::Data { .. }));
313    }
314
315    #[test]
316    fn none_fields_omitted() {
317        let msg = make_message();
318        let json = serde_json::to_string(&msg).expect("serialize");
319        assert!(
320            !json.contains("\"taskId\""),
321            "taskId should be omitted: {json}"
322        );
323        assert!(
324            !json.contains("\"metadata\""),
325            "metadata should be omitted: {json}"
326        );
327    }
328
329    #[test]
330    fn wire_format_role_unspecified_roundtrip() {
331        let json = serde_json::to_string(&MessageRole::Unspecified).unwrap();
332        assert_eq!(json, "\"ROLE_UNSPECIFIED\"");
333
334        let back: MessageRole = serde_json::from_str("\"ROLE_UNSPECIFIED\"").unwrap();
335        assert_eq!(back, MessageRole::Unspecified);
336    }
337
338    #[test]
339    fn message_role_display_trait() {
340        assert_eq!(MessageRole::User.to_string(), "ROLE_USER");
341        assert_eq!(MessageRole::Agent.to_string(), "ROLE_AGENT");
342        assert_eq!(MessageRole::Unspecified.to_string(), "ROLE_UNSPECIFIED");
343    }
344
345    #[test]
346    fn mixed_part_message_roundtrip() {
347        let msg = Message {
348            id: MessageId::new("msg-mixed"),
349            role: MessageRole::Agent,
350            parts: vec![
351                Part::text("Here is the result"),
352                Part::raw("aGVsbG8="),
353                Part::url("https://example.com/output.pdf"),
354            ],
355            task_id: None,
356            context_id: None,
357            reference_task_ids: None,
358            extensions: None,
359            metadata: None,
360        };
361
362        let json = serde_json::to_string(&msg).expect("serialize mixed-part message");
363        assert!(json.contains("\"text\":\"Here is the result\""));
364        assert!(json.contains("\"raw\":\"aGVsbG8=\""));
365        assert!(json.contains("\"url\":\"https://example.com/output.pdf\""));
366
367        let back: Message = serde_json::from_str(&json).expect("deserialize mixed-part message");
368        assert_eq!(back.parts.len(), 3);
369        assert!(
370            matches!(&back.parts[0].content, PartContent::Text { text } if text == "Here is the result")
371        );
372        assert!(matches!(&back.parts[1].content, PartContent::Raw { raw } if raw == "aGVsbG8="));
373        assert!(
374            matches!(&back.parts[2].content, PartContent::Url { url } if url == "https://example.com/output.pdf")
375        );
376    }
377
378    #[test]
379    fn message_with_reference_task_ids() {
380        use crate::task::TaskId;
381
382        let msg = Message {
383            id: MessageId::new("msg-ref"),
384            role: MessageRole::User,
385            parts: vec![Part::text("check these tasks")],
386            task_id: None,
387            context_id: None,
388            reference_task_ids: Some(vec![TaskId::new("task-100"), TaskId::new("task-200")]),
389            extensions: None,
390            metadata: None,
391        };
392
393        let json = serde_json::to_string(&msg).expect("serialize");
394        assert!(
395            json.contains("\"referenceTaskIds\""),
396            "referenceTaskIds should be present: {json}"
397        );
398        assert!(json.contains("\"task-100\""));
399        assert!(json.contains("\"task-200\""));
400
401        let back: Message = serde_json::from_str(&json).expect("deserialize");
402        let refs = back
403            .reference_task_ids
404            .expect("should have reference_task_ids");
405        assert_eq!(refs.len(), 2);
406        assert_eq!(refs[0], TaskId::new("task-100"));
407        assert_eq!(refs[1], TaskId::new("task-200"));
408    }
409}