Skip to main content

a2a_rust/types/
message.rs

1use serde::{Deserialize, Serialize};
2
3use crate::A2AError;
4use crate::types::JsonObject;
5
6/// Message author role.
7#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
8pub enum Role {
9    #[default]
10    #[serde(rename = "ROLE_UNSPECIFIED")]
11    /// Unspecified role value.
12    Unspecified,
13    #[serde(rename = "ROLE_USER")]
14    /// End-user authored message.
15    User,
16    #[serde(rename = "ROLE_AGENT")]
17    /// Agent-authored message.
18    Agent,
19}
20
21/// Flat content part used in messages and artifacts.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Part {
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    /// Plain-text content.
27    pub text: Option<String>,
28    #[serde(
29        default,
30        skip_serializing_if = "Option::is_none",
31        with = "crate::types::base64_bytes::option"
32    )]
33    /// Raw binary content encoded as base64 in JSON.
34    pub raw: Option<Vec<u8>>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    /// URL content reference.
37    pub url: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    /// Structured JSON content.
40    pub data: Option<serde_json::Value>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    /// Optional metadata for the part.
43    pub metadata: Option<JsonObject>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    /// Optional filename for file-like parts.
46    pub filename: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    /// Optional media type for the content.
49    pub media_type: Option<String>,
50}
51
52impl Part {
53    /// Count how many mutually-exclusive content fields are populated.
54    pub fn content_count(&self) -> usize {
55        usize::from(self.text.is_some())
56            + usize::from(self.raw.is_some())
57            + usize::from(self.url.is_some())
58            + usize::from(self.data.is_some())
59    }
60
61    /// Return `true` when exactly one content field is populated.
62    pub fn has_single_content(&self) -> bool {
63        self.content_count() == 1
64    }
65
66    /// Validate the proto oneof-style content constraint.
67    pub fn validate(&self) -> Result<(), A2AError> {
68        match self.content_count() {
69            1 => Ok(()),
70            0 => Err(A2AError::InvalidRequest(
71                "part must contain exactly one of text, raw, url, or data".to_owned(),
72            )),
73            _ => Err(A2AError::InvalidRequest(
74                "part cannot contain more than one of text, raw, url, or data".to_owned(),
75            )),
76        }
77    }
78}
79
80/// Protocol message exchanged between user and agent.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct Message {
84    /// Unique message identifier.
85    pub message_id: String,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    /// Optional conversation context identifier.
88    pub context_id: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    /// Optional task identifier associated with the message.
91    pub task_id: Option<String>,
92    /// Message author role.
93    pub role: Role,
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    /// Ordered message parts.
96    pub parts: Vec<Part>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    /// Optional message metadata.
99    pub metadata: Option<JsonObject>,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    /// Extension URIs attached to the message.
102    pub extensions: Vec<String>,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    /// Related task identifiers referenced by the message.
105    pub reference_task_ids: Vec<String>,
106}
107
108impl Message {
109    /// Validate that the message contains at least one valid part.
110    pub fn validate(&self) -> Result<(), A2AError> {
111        if self.parts.is_empty() {
112            return Err(A2AError::InvalidRequest(
113                "message must contain at least one part".to_owned(),
114            ));
115        }
116
117        for part in &self.parts {
118            part.validate()?;
119        }
120
121        Ok(())
122    }
123}
124
125/// Output artifact produced by a task.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct Artifact {
129    /// Unique artifact identifier.
130    pub artifact_id: String,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    /// Optional artifact name.
133    pub name: Option<String>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    /// Optional artifact description.
136    pub description: Option<String>,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    /// Ordered artifact parts.
139    pub parts: Vec<Part>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    /// Optional artifact metadata.
142    pub metadata: Option<JsonObject>,
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    /// Extension URIs attached to the artifact.
145    pub extensions: Vec<String>,
146}
147
148impl Artifact {
149    /// Validate that the artifact contains at least one valid part.
150    pub fn validate(&self) -> Result<(), A2AError> {
151        if self.parts.is_empty() {
152            return Err(A2AError::InvalidRequest(
153                "artifact must contain at least one part".to_owned(),
154            ));
155        }
156
157        for part in &self.parts {
158            part.validate()?;
159        }
160
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{Artifact, Message, Part, Role};
168
169    #[test]
170    fn part_reports_single_content_field() {
171        let part = Part {
172            text: Some("hello".to_owned()),
173            raw: None,
174            url: None,
175            data: None,
176            metadata: None,
177            filename: None,
178            media_type: None,
179        };
180
181        assert_eq!(part.content_count(), 1);
182        assert!(part.has_single_content());
183    }
184
185    #[test]
186    fn part_raw_serializes_as_base64() {
187        let part = Part {
188            text: None,
189            raw: Some(vec![104, 105]),
190            url: None,
191            data: None,
192            metadata: None,
193            filename: None,
194            media_type: None,
195        };
196
197        let json = serde_json::to_string(&part).expect("part should serialize");
198        assert_eq!(json, r#"{"raw":"aGk="}"#);
199    }
200
201    #[test]
202    fn part_validate_rejects_multiple_content_fields() {
203        let part = Part {
204            text: Some("hello".to_owned()),
205            raw: Some(vec![104, 105]),
206            url: None,
207            data: None,
208            metadata: None,
209            filename: None,
210            media_type: None,
211        };
212
213        let error = part.validate().expect_err("part should be invalid");
214        assert!(
215            error
216                .to_string()
217                .contains("part cannot contain more than one")
218        );
219    }
220
221    #[test]
222    fn message_and_artifact_round_trip_serialization() {
223        let message = Message {
224            message_id: "msg-1".to_owned(),
225            context_id: Some("ctx-1".to_owned()),
226            task_id: Some("task-1".to_owned()),
227            role: Role::User,
228            parts: vec![Part {
229                text: Some("hello".to_owned()),
230                raw: None,
231                url: None,
232                data: None,
233                metadata: None,
234                filename: None,
235                media_type: None,
236            }],
237            metadata: None,
238            extensions: vec!["trace".to_owned()],
239            reference_task_ids: vec!["task-0".to_owned()],
240        };
241        let artifact = Artifact {
242            artifact_id: "artifact-1".to_owned(),
243            name: Some("transcript".to_owned()),
244            description: Some("conversation log".to_owned()),
245            parts: vec![Part {
246                text: Some("hello".to_owned()),
247                raw: None,
248                url: None,
249                data: None,
250                metadata: None,
251                filename: None,
252                media_type: None,
253            }],
254            metadata: None,
255            extensions: vec!["indexed".to_owned()],
256        };
257
258        let message_json = serde_json::to_string(&message).expect("message should serialize");
259        let artifact_json = serde_json::to_string(&artifact).expect("artifact should serialize");
260
261        let message_round_trip: Message =
262            serde_json::from_str(&message_json).expect("message should deserialize");
263        let artifact_round_trip: Artifact =
264            serde_json::from_str(&artifact_json).expect("artifact should deserialize");
265
266        assert_eq!(message_round_trip.message_id, "msg-1");
267        assert_eq!(artifact_round_trip.artifact_id, "artifact-1");
268        assert_eq!(artifact_round_trip.parts.len(), 1);
269    }
270
271    #[test]
272    fn message_validate_rejects_empty_parts() {
273        let message = Message {
274            message_id: "msg-1".to_owned(),
275            context_id: None,
276            task_id: None,
277            role: Role::User,
278            parts: Vec::new(),
279            metadata: None,
280            extensions: Vec::new(),
281            reference_task_ids: Vec::new(),
282        };
283
284        let error = message.validate().expect_err("message should be invalid");
285        assert!(
286            error
287                .to_string()
288                .contains("message must contain at least one part")
289        );
290    }
291
292    #[test]
293    fn artifact_validate_rejects_empty_parts() {
294        let artifact = Artifact {
295            artifact_id: "artifact-1".to_owned(),
296            name: None,
297            description: None,
298            parts: Vec::new(),
299            metadata: None,
300            extensions: Vec::new(),
301        };
302
303        let error = artifact.validate().expect_err("artifact should be invalid");
304        assert!(
305            error
306                .to_string()
307                .contains("artifact must contain at least one part")
308        );
309    }
310}