Skip to main content

rullama_a2a/
types.rs

1//! Core A2A message types: Message, Part, Artifact, Role.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Sender role in A2A communication.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum Role {
9    /// Client-to-server message.
10    #[serde(rename = "ROLE_USER")]
11    User,
12    /// Server-to-client message.
13    #[serde(rename = "ROLE_AGENT")]
14    Agent,
15    /// Unspecified role.
16    #[serde(rename = "ROLE_UNSPECIFIED")]
17    Unspecified,
18}
19
20/// A single unit of communication content.
21///
22/// Exactly one of `text`, `raw`, `url`, or `data` must be set.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct Part {
25    /// Text content.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub text: Option<String>,
28    /// Base64-encoded raw bytes.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub raw: Option<String>,
31    /// URL reference.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub url: Option<String>,
34    /// Structured JSON data.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub data: Option<serde_json::Value>,
37    /// MIME type of the content.
38    #[serde(skip_serializing_if = "Option::is_none", rename = "mediaType")]
39    pub media_type: Option<String>,
40    /// File name.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub filename: Option<String>,
43    /// Custom metadata.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub metadata: Option<HashMap<String, serde_json::Value>>,
46}
47
48/// A single communication message between client and server.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct Message {
51    /// Unique message identifier.
52    #[serde(rename = "messageId")]
53    pub message_id: String,
54    /// Sender role.
55    pub role: Role,
56    /// Content parts.
57    pub parts: Vec<Part>,
58    /// Context identifier (conversation/session).
59    #[serde(rename = "contextId", skip_serializing_if = "Option::is_none")]
60    pub context_id: Option<String>,
61    /// Associated task identifier.
62    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
63    pub task_id: Option<String>,
64    /// Referenced task identifiers for additional context.
65    #[serde(rename = "referenceTaskIds", skip_serializing_if = "Option::is_none")]
66    pub reference_task_ids: Option<Vec<String>>,
67    /// Custom metadata.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub metadata: Option<HashMap<String, serde_json::Value>>,
70    /// Extension URIs present in this message.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub extensions: Option<Vec<String>>,
73}
74
75impl Message {
76    /// Create a new user message with text content.
77    pub fn user_text(text: impl Into<String>) -> Self {
78        Self {
79            message_id: uuid::Uuid::new_v4().to_string(),
80            role: Role::User,
81            parts: vec![Part {
82                text: Some(text.into()),
83                raw: None,
84                url: None,
85                data: None,
86                media_type: None,
87                filename: None,
88                metadata: None,
89            }],
90            context_id: None,
91            task_id: None,
92            reference_task_ids: None,
93            metadata: None,
94            extensions: None,
95        }
96    }
97
98    /// Create a new agent message with text content.
99    pub fn agent_text(text: impl Into<String>) -> Self {
100        let mut msg = Self::user_text(text);
101        msg.role = Role::Agent;
102        msg
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    // --- Role ---
111
112    #[test]
113    fn role_serializes_to_expected_strings() {
114        assert_eq!(
115            serde_json::to_string(&Role::User).unwrap(),
116            r#""ROLE_USER""#
117        );
118        assert_eq!(
119            serde_json::to_string(&Role::Agent).unwrap(),
120            r#""ROLE_AGENT""#
121        );
122        assert_eq!(
123            serde_json::to_string(&Role::Unspecified).unwrap(),
124            r#""ROLE_UNSPECIFIED""#
125        );
126    }
127
128    #[test]
129    fn role_roundtrip() {
130        for role in [Role::User, Role::Agent, Role::Unspecified] {
131            let json = serde_json::to_string(&role).unwrap();
132            let back: Role = serde_json::from_str(&json).unwrap();
133            assert_eq!(back, role);
134        }
135    }
136
137    // --- Part ---
138
139    #[test]
140    fn part_text_only_omits_other_fields() {
141        let p = Part {
142            text: Some("hello".to_string()),
143            raw: None,
144            url: None,
145            data: None,
146            media_type: None,
147            filename: None,
148            metadata: None,
149        };
150        let json = serde_json::to_string(&p).unwrap();
151        assert!(json.contains("hello"));
152        assert!(!json.contains("raw"));
153        assert!(!json.contains("url"));
154    }
155
156    #[test]
157    fn part_roundtrip() {
158        let p = Part {
159            text: Some("content".to_string()),
160            raw: None,
161            url: Some("https://example.com".to_string()),
162            data: Some(serde_json::json!({"key": 1})),
163            media_type: Some("text/plain".to_string()),
164            filename: Some("file.txt".to_string()),
165            metadata: None,
166        };
167        let json = serde_json::to_string(&p).unwrap();
168        let back: Part = serde_json::from_str(&json).unwrap();
169        assert_eq!(back, p);
170    }
171
172    // --- Message ---
173
174    #[test]
175    fn user_text_creates_user_role_message() {
176        let msg = Message::user_text("hi");
177        assert_eq!(msg.role, Role::User);
178        assert_eq!(msg.parts.len(), 1);
179        assert_eq!(msg.parts[0].text.as_deref(), Some("hi"));
180        assert!(!msg.message_id.is_empty());
181    }
182
183    #[test]
184    fn agent_text_creates_agent_role_message() {
185        let msg = Message::agent_text("response");
186        assert_eq!(msg.role, Role::Agent);
187        assert_eq!(msg.parts[0].text.as_deref(), Some("response"));
188    }
189
190    #[test]
191    fn message_ids_are_unique() {
192        let a = Message::user_text("a");
193        let b = Message::user_text("b");
194        assert_ne!(a.message_id, b.message_id);
195    }
196
197    #[test]
198    fn message_roundtrip() {
199        let msg = Message::user_text("roundtrip test");
200        let json = serde_json::to_string(&msg).unwrap();
201        let back: Message = serde_json::from_str(&json).unwrap();
202        assert_eq!(back, msg);
203    }
204
205    #[test]
206    fn message_json_uses_camel_case_message_id() {
207        let msg = Message::user_text("x");
208        let json = serde_json::to_string(&msg).unwrap();
209        assert!(json.contains("messageId"));
210        assert!(!json.contains("message_id"));
211    }
212
213    #[test]
214    fn message_optional_fields_omitted_when_none() {
215        let msg = Message::user_text("x");
216        let json = serde_json::to_string(&msg).unwrap();
217        assert!(!json.contains("contextId"));
218        assert!(!json.contains("taskId"));
219        assert!(!json.contains("extensions"));
220    }
221}
222
223/// Task output artifact.
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
225pub struct Artifact {
226    /// Unique artifact identifier (unique within a task).
227    #[serde(rename = "artifactId")]
228    pub artifact_id: String,
229    /// Human-readable name.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub name: Option<String>,
232    /// Human-readable description.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub description: Option<String>,
235    /// Artifact content parts.
236    pub parts: Vec<Part>,
237    /// Custom metadata.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub metadata: Option<HashMap<String, serde_json::Value>>,
240    /// Extension URIs.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub extensions: Option<Vec<String>>,
243}