use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aMessage {
pub message_id: String,
pub role: String,
pub parts: Vec<A2aPart>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum A2aPart {
#[serde(rename = "text")]
Text {
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<HashMap<String, serde_json::Value>>,
},
#[serde(rename = "file")]
File {
file: A2aFileContent,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<HashMap<String, serde_json::Value>>,
},
#[serde(rename = "data")]
Data {
data: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<HashMap<String, serde_json::Value>>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aFileContent {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TaskState {
Submitted,
Working,
InputRequired,
Completed,
Canceled,
Failed,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatus {
pub state: TaskState,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<A2aMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aTask {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifacts: Option<Vec<A2aArtifact>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aArtifact {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub parts: Vec<A2aPart>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatusUpdateEvent {
pub id: String,
pub status: TaskStatus,
#[serde(rename = "final")]
pub is_final: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskArtifactUpdateEvent {
pub id: String,
pub artifact: A2aArtifact,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a2a_message_round_trip_with_text_part() {
let msg = A2aMessage {
message_id: "msg-1".to_string(),
role: "user".to_string(),
parts: vec![A2aPart::Text {
text: "Hello, agent!".to_string(),
metadata: None,
}],
metadata: None,
};
let json = serde_json::to_string(&msg).unwrap();
let deserialized: A2aMessage = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.message_id, "msg-1");
assert_eq!(deserialized.role, "user");
assert_eq!(deserialized.parts.len(), 1);
match &deserialized.parts[0] {
A2aPart::Text { text, metadata } => {
assert_eq!(text, "Hello, agent!");
assert!(metadata.is_none());
}
_ => panic!("Expected Text part"),
}
assert!(json.contains("\"messageId\""));
assert!(!json.contains("\"message_id\""));
}
#[test]
fn a2a_part_text_serializes_with_kind_tag() {
let part = A2aPart::Text {
text: "hello".to_string(),
metadata: None,
};
let json = serde_json::to_string(&part).unwrap();
assert!(json.contains("\"kind\":\"text\""));
assert!(json.contains("\"text\":\"hello\""));
}
#[test]
fn a2a_part_file_serializes_with_kind_tag() {
let part = A2aPart::File {
file: A2aFileContent {
name: Some("image.png".to_string()),
mime_type: Some("image/png".to_string()),
bytes: Some("base64data".to_string()),
uri: None,
},
metadata: None,
};
let json = serde_json::to_string(&part).unwrap();
assert!(json.contains("\"kind\":\"file\""));
assert!(json.contains("\"image.png\""));
assert!(json.contains("\"mimeType\""));
let deserialized: A2aPart = serde_json::from_str(&json).unwrap();
match deserialized {
A2aPart::File { file, .. } => {
assert_eq!(file.name.as_deref(), Some("image.png"));
assert_eq!(file.mime_type.as_deref(), Some("image/png"));
assert_eq!(file.bytes.as_deref(), Some("base64data"));
assert!(file.uri.is_none());
}
_ => panic!("Expected File part"),
}
}
#[test]
fn a2a_part_data_serializes_with_kind_tag() {
let part = A2aPart::Data {
data: serde_json::json!({"key": "value", "count": 42}),
metadata: None,
};
let json = serde_json::to_string(&part).unwrap();
assert!(json.contains("\"kind\":\"data\""));
let deserialized: A2aPart = serde_json::from_str(&json).unwrap();
match deserialized {
A2aPart::Data { data, .. } => {
assert_eq!(data["key"], "value");
assert_eq!(data["count"], 42);
}
_ => panic!("Expected Data part"),
}
}
#[test]
fn a2a_file_content_with_bytes_vs_uri() {
let with_bytes = A2aFileContent {
name: Some("doc.pdf".to_string()),
mime_type: Some("application/pdf".to_string()),
bytes: Some("cGRmY29udGVudA==".to_string()),
uri: None,
};
let json = serde_json::to_string(&with_bytes).unwrap();
assert!(json.contains("\"bytes\""));
assert!(!json.contains("\"uri\""));
let with_uri = A2aFileContent {
name: Some("doc.pdf".to_string()),
mime_type: Some("application/pdf".to_string()),
bytes: None,
uri: Some("gs://bucket/doc.pdf".to_string()),
};
let json = serde_json::to_string(&with_uri).unwrap();
assert!(!json.contains("\"bytes\""));
assert!(json.contains("\"uri\""));
assert!(json.contains("gs://bucket/doc.pdf"));
}
#[test]
fn task_state_serde_camel_case() {
assert_eq!(
serde_json::to_string(&TaskState::Submitted).unwrap(),
"\"submitted\""
);
assert_eq!(
serde_json::to_string(&TaskState::Working).unwrap(),
"\"working\""
);
assert_eq!(
serde_json::to_string(&TaskState::InputRequired).unwrap(),
"\"inputRequired\""
);
assert_eq!(
serde_json::to_string(&TaskState::Completed).unwrap(),
"\"completed\""
);
assert_eq!(
serde_json::to_string(&TaskState::Canceled).unwrap(),
"\"canceled\""
);
assert_eq!(
serde_json::to_string(&TaskState::Failed).unwrap(),
"\"failed\""
);
assert_eq!(
serde_json::to_string(&TaskState::Unknown).unwrap(),
"\"unknown\""
);
let state: TaskState = serde_json::from_str("\"inputRequired\"").unwrap();
assert_eq!(state, TaskState::InputRequired);
}
#[test]
fn a2a_task_with_status_and_artifacts() {
let task = A2aTask {
id: "task-123".to_string(),
context_id: Some("ctx-456".to_string()),
status: TaskStatus {
state: TaskState::Completed,
message: Some(A2aMessage {
message_id: "msg-done".to_string(),
role: "agent".to_string(),
parts: vec![A2aPart::Text {
text: "Done!".to_string(),
metadata: None,
}],
metadata: None,
}),
timestamp: Some("2026-03-02T12:00:00Z".to_string()),
},
artifacts: Some(vec![A2aArtifact {
name: Some("result".to_string()),
parts: vec![A2aPart::Data {
data: serde_json::json!({"answer": 42}),
metadata: None,
}],
metadata: None,
}]),
metadata: None,
};
let json = serde_json::to_string_pretty(&task).unwrap();
let deserialized: A2aTask = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "task-123");
assert_eq!(deserialized.context_id.as_deref(), Some("ctx-456"));
assert_eq!(deserialized.status.state, TaskState::Completed);
assert!(deserialized.status.message.is_some());
assert!(deserialized.artifacts.is_some());
assert_eq!(deserialized.artifacts.as_ref().unwrap().len(), 1);
assert!(json.contains("\"contextId\""));
}
#[test]
fn task_status_update_event_final_field_rename() {
let event = TaskStatusUpdateEvent {
id: "task-789".to_string(),
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
is_final: false,
metadata: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"final\""));
assert!(!json.contains("\"is_final\""));
assert!(!json.contains("\"isFinal\""));
let json_input = r#"{"id":"task-789","status":{"state":"completed"},"final":true}"#;
let deserialized: TaskStatusUpdateEvent = serde_json::from_str(json_input).unwrap();
assert!(deserialized.is_final);
assert_eq!(deserialized.status.state, TaskState::Completed);
}
#[test]
fn task_artifact_update_event_round_trip() {
let event = TaskArtifactUpdateEvent {
id: "task-100".to_string(),
artifact: A2aArtifact {
name: Some("output".to_string()),
parts: vec![A2aPart::Text {
text: "Generated content".to_string(),
metadata: None,
}],
metadata: None,
},
metadata: Some({
let mut m = HashMap::new();
m.insert("source".to_string(), serde_json::json!("agent-a"));
m
}),
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: TaskArtifactUpdateEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "task-100");
assert_eq!(deserialized.artifact.name.as_deref(), Some("output"));
assert!(deserialized.metadata.is_some());
assert_eq!(
deserialized.metadata.as_ref().unwrap()["source"],
serde_json::json!("agent-a")
);
}
}