use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::task::{Artifact, TaskStatus};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskStatusUpdateEvent {
pub task_id: String,
pub context_id: String,
pub status: TaskStatus,
#[serde(rename = "final")]
pub is_final: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl TaskStatusUpdateEvent {
#[must_use]
pub fn new(
task_id: impl Into<String>,
context_id: impl Into<String>,
status: TaskStatus,
) -> Self {
Self {
task_id: task_id.into(),
context_id: context_id.into(),
status,
is_final: false,
metadata: BTreeMap::new(),
}
}
#[must_use]
pub const fn with_final(mut self) -> Self {
self.is_final = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskArtifactUpdateEvent {
pub task_id: String,
pub context_id: String,
pub artifact: Artifact,
#[serde(default, skip_serializing_if = "is_false")]
pub append: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub last_chunk: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "required by skip_serializing_if"
)]
const fn is_false(b: &bool) -> bool {
!*b
}
impl TaskArtifactUpdateEvent {
#[must_use]
pub fn new(
task_id: impl Into<String>,
context_id: impl Into<String>,
artifact: Artifact,
) -> Self {
Self {
task_id: task_id.into(),
context_id: context_id.into(),
artifact,
append: false,
last_chunk: false,
metadata: BTreeMap::new(),
}
}
#[must_use]
pub const fn with_append(mut self) -> Self {
self.append = true;
self
}
#[must_use]
pub const fn with_last_chunk(mut self) -> Self {
self.last_chunk = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StreamingEvent {
Status(TaskStatusUpdateEvent),
Artifact(TaskArtifactUpdateEvent),
}
impl From<TaskStatusUpdateEvent> for StreamingEvent {
fn from(event: TaskStatusUpdateEvent) -> Self {
Self::Status(event)
}
}
impl From<TaskArtifactUpdateEvent> for StreamingEvent {
fn from(event: TaskArtifactUpdateEvent) -> Self {
Self::Artifact(event)
}
}
#[cfg(test)]
mod tests {
use super::{StreamingEvent, TaskArtifactUpdateEvent, TaskStatusUpdateEvent};
use crate::domain::a2a::message::{Part, TextPart};
use crate::domain::a2a::task::{Artifact, TaskState, TaskStatus};
type TestError = Box<dyn std::error::Error>;
fn sample_status() -> TaskStatus {
TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
}
}
fn sample_artifact() -> Artifact {
Artifact::new(Part::Text(TextPart {
text: "answer".to_owned(),
metadata: std::collections::BTreeMap::new(),
}))
}
#[test]
fn status_event_serialises_with_required_fields() -> Result<(), TestError> {
let event = TaskStatusUpdateEvent::new("t-1", "c-1", sample_status());
let value = serde_json::to_value(&event)?;
let obj = value
.as_object()
.ok_or("a TaskStatusUpdateEvent must serialise to a JSON object")?;
assert_eq!(
obj.get("taskId").and_then(serde_json::Value::as_str),
Some("t-1")
);
assert_eq!(
obj.get("contextId").and_then(serde_json::Value::as_str),
Some("c-1")
);
assert!(obj.contains_key("status"));
assert!(
obj.contains_key("final"),
"the `final` field must be present even when false"
);
assert_eq!(
obj.get("final").and_then(serde_json::Value::as_bool),
Some(false)
);
Ok(())
}
#[test]
fn status_event_with_final_true_round_trips() -> Result<(), TestError> {
let event = TaskStatusUpdateEvent::new(
"t-1",
"c-1",
TaskStatus {
state: TaskState::Completed,
message: None,
timestamp: None,
},
)
.with_final();
let json = serde_json::to_string(&event)?;
let parsed: TaskStatusUpdateEvent = serde_json::from_str(&json)?;
assert_eq!(parsed, event);
assert!(json.contains(r#""final":true"#));
Ok(())
}
#[test]
fn artifact_event_serialises_with_artifact_field() -> Result<(), TestError> {
let event = TaskArtifactUpdateEvent::new("t-1", "c-1", sample_artifact());
let value = serde_json::to_value(&event)?;
let obj = value
.as_object()
.ok_or("a TaskArtifactUpdateEvent must serialise to a JSON object")?;
assert_eq!(
obj.get("taskId").and_then(serde_json::Value::as_str),
Some("t-1")
);
assert_eq!(
obj.get("contextId").and_then(serde_json::Value::as_str),
Some("c-1")
);
assert!(obj.contains_key("artifact"));
assert!(!obj.contains_key("append"));
assert!(!obj.contains_key("lastChunk"));
Ok(())
}
#[test]
fn artifact_event_with_append_and_last_chunk_round_trips() -> Result<(), TestError> {
let event = TaskArtifactUpdateEvent::new("t-1", "c-1", sample_artifact())
.with_append()
.with_last_chunk();
let json = serde_json::to_string(&event)?;
let parsed: TaskArtifactUpdateEvent = serde_json::from_str(&json)?;
assert_eq!(parsed, event);
assert!(json.contains(r#""append":true"#));
assert!(json.contains(r#""lastChunk":true"#));
Ok(())
}
#[test]
fn streaming_event_untagged_discrimination_works_both_ways() -> Result<(), TestError> {
let status_json =
r#"{"taskId":"t-1","contextId":"c-1","status":{"state":"working"},"final":false}"#;
let artifact_json =
r#"{"taskId":"t-1","contextId":"c-1","artifact":{"parts":[{"text":"x"}]}}"#;
let from_status: StreamingEvent = serde_json::from_str(status_json)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let from_artifact: StreamingEvent = serde_json::from_str(artifact_json)
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
assert!(matches!(from_status, StreamingEvent::Status(_)));
assert!(matches!(from_artifact, StreamingEvent::Artifact(_)));
Ok(())
}
}