1use serde::{Deserialize, Serialize};
7use systemprompt_identifiers::{ContextId, MessageId, TaskId};
8
9#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
10#[serde(rename_all = "camelCase")]
11#[expect(
12 clippy::struct_field_names,
13 reason = "A2A wire schema names these fields message_id/task_id/context_id — public protocol \
14 vocabulary"
15)]
16pub struct Message {
17 pub role: MessageRole,
18 pub parts: Vec<Part>,
19 pub message_id: MessageId,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub task_id: Option<TaskId>,
22 pub context_id: ContextId,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub extensions: Option<Vec<String>>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub reference_task_ids: Option<Vec<TaskId>>,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
33pub enum MessageRole {
34 #[serde(rename = "ROLE_USER")]
35 User,
36 #[serde(rename = "ROLE_AGENT")]
37 Agent,
38}
39
40#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
41#[serde(rename_all = "camelCase", try_from = "PartWire")]
42pub struct Part {
43 #[serde(flatten)]
44 pub content: PartContent,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub filename: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub media_type: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
52}
53
54#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
55#[serde(rename_all = "lowercase")]
56pub enum PartContent {
57 Text(String),
58 Raw(String),
59 Url(String),
60 Data(serde_json::Value),
62}
63
64#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
65pub enum PartContentError {
66 #[error("A2A part carries no content: exactly one of text, raw, url or data is required")]
67 Missing,
68 #[error(
69 "A2A part carries {count} content fields: exactly one of text, raw, url or data is allowed"
70 )]
71 Multiple { count: usize },
72}
73
74#[derive(Deserialize)]
75#[serde(rename_all = "camelCase")]
76struct PartWire {
77 #[serde(default)]
78 text: Option<String>,
79 #[serde(default)]
80 raw: Option<String>,
81 #[serde(default)]
82 url: Option<String>,
83 #[serde(default, deserialize_with = "present_value")]
85 data: Option<serde_json::Value>,
86 #[serde(default)]
87 filename: Option<String>,
88 #[serde(default)]
89 media_type: Option<String>,
90 #[serde(default)]
92 metadata: Option<serde_json::Map<String, serde_json::Value>>,
93}
94
95fn present_value<'de, D>(deserializer: D) -> Result<Option<serde_json::Value>, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 serde_json::Value::deserialize(deserializer).map(Some)
101}
102
103impl TryFrom<PartWire> for Part {
104 type Error = PartContentError;
105
106 fn try_from(wire: PartWire) -> Result<Self, Self::Error> {
107 let mut contents = [
108 wire.text.map(PartContent::Text),
109 wire.raw.map(PartContent::Raw),
110 wire.url.map(PartContent::Url),
111 wire.data.map(PartContent::Data),
112 ]
113 .into_iter()
114 .flatten();
115 let content = contents.next().ok_or(PartContentError::Missing)?;
116 let extra = contents.count();
117 if extra > 0 {
118 return Err(PartContentError::Multiple { count: extra + 1 });
119 }
120 Ok(Self {
121 content,
122 filename: wire.filename,
123 media_type: wire.media_type,
124 metadata: wire.metadata,
125 })
126 }
127}
128
129impl Part {
130 const fn from_content(content: PartContent, media_type: Option<String>) -> Self {
131 Self {
132 content,
133 filename: None,
134 media_type,
135 metadata: None,
136 }
137 }
138
139 pub fn text(text: impl Into<String>) -> Self {
140 Self::from_content(PartContent::Text(text.into()), None)
141 }
142
143 #[must_use]
145 pub const fn data(data: serde_json::Value) -> Self {
146 Self::from_content(PartContent::Data(data), None)
147 }
148
149 pub fn raw(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
150 Self::from_content(PartContent::Raw(base64.into()), Some(media_type.into()))
151 }
152
153 pub fn url(url: impl Into<String>, media_type: impl Into<String>) -> Self {
154 Self::from_content(PartContent::Url(url.into()), Some(media_type.into()))
155 }
156
157 #[must_use]
158 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
159 self.filename = Some(filename.into());
160 self
161 }
162
163 #[must_use]
164 pub fn with_media_type(mut self, media_type: impl Into<String>) -> Self {
165 self.media_type = Some(media_type.into());
166 self
167 }
168
169 #[must_use]
170 pub fn as_text(&self) -> Option<&str> {
171 match &self.content {
172 PartContent::Text(text) => Some(text),
173 _ => None,
174 }
175 }
176
177 #[must_use]
179 pub const fn as_data(&self) -> Option<&serde_json::Value> {
180 match &self.content {
181 PartContent::Data(data) => Some(data),
182 _ => None,
183 }
184 }
185
186 #[must_use]
187 pub fn as_raw(&self) -> Option<&str> {
188 match &self.content {
189 PartContent::Raw(raw) => Some(raw),
190 _ => None,
191 }
192 }
193
194 #[must_use]
195 pub fn as_url(&self) -> Option<&str> {
196 match &self.content {
197 PartContent::Url(url) => Some(url),
198 _ => None,
199 }
200 }
201
202 #[must_use]
203 pub const fn is_file(&self) -> bool {
204 matches!(self.content, PartContent::Raw(_) | PartContent::Url(_))
205 }
206}