Skip to main content

a2a_protocol_types/
events.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Server-sent event types for A2A streaming.
5//!
6//! When a client calls `SendStreamingMessage` or `SubscribeToTask`, the server
7//! responds with a stream of Server-Sent Events. Each event carries a
8//! [`StreamResponse`] JSON payload discriminated by field presence (untagged).
9//!
10//! # Stream event variants
11//!
12//! | JSON field | Rust variant |
13//! |---|---|
14//! | `"task"` | [`crate::task::Task`] |
15//! | `"message"` | [`crate::message::Message`] |
16//! | `"statusUpdate"` | [`TaskStatusUpdateEvent`] |
17//! | `"artifactUpdate"` | [`TaskArtifactUpdateEvent`] |
18
19use serde::{Deserialize, Serialize};
20
21use crate::artifact::Artifact;
22use crate::message::Message;
23use crate::task::{ContextId, Task, TaskId, TaskStatus};
24
25// ── TaskStatusUpdateEvent ─────────────────────────────────────────────────────
26
27/// A streaming event that reports a change in task state.
28///
29/// In v1.0, this wraps [`TaskStatus`] directly instead of separate
30/// `state`/`message` fields. The `final` field has been removed.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct TaskStatusUpdateEvent {
34    /// The task whose status changed.
35    pub task_id: TaskId,
36
37    /// Conversation context the task belongs to.
38    pub context_id: ContextId,
39
40    /// The new task status (state + optional message + timestamp).
41    pub status: TaskStatus,
42
43    /// Arbitrary metadata.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub metadata: Option<serde_json::Value>,
46}
47
48// ── TaskArtifactUpdateEvent ───────────────────────────────────────────────────
49
50/// A streaming event that delivers a new or updated artifact.
51///
52/// The wire `kind` field (`"artifact-update"`) is injected by the enclosing
53/// [`StreamResponse`] discriminated union.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct TaskArtifactUpdateEvent {
57    /// The task that produced the artifact.
58    pub task_id: TaskId,
59
60    /// Conversation context the task belongs to.
61    pub context_id: ContextId,
62
63    /// The artifact being delivered.
64    pub artifact: Artifact,
65
66    /// If `true`, this event's artifact parts should be appended to the
67    /// previously-received artifact with the same ID rather than replacing it.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub append: Option<bool>,
70
71    /// If `true`, this is the final chunk for the artifact.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub last_chunk: Option<bool>,
74
75    /// Arbitrary metadata.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub metadata: Option<serde_json::Value>,
78}
79
80// ── StreamResponse ────────────────────────────────────────────────────────────
81
82/// A single event payload in an A2A streaming response.
83///
84/// Discriminated by field presence (untagged oneof). Exactly one of
85/// `task`, `message`, `statusUpdate`, or `artifactUpdate` is present.
86#[non_exhaustive]
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub enum StreamResponse {
90    /// A complete task object (initial response before streaming begins).
91    Task(Task),
92
93    /// A complete message (returned synchronously for short responses).
94    Message(Message),
95
96    /// A task state change event.
97    StatusUpdate(TaskStatusUpdateEvent),
98
99    /// An artifact delivery event.
100    ArtifactUpdate(TaskArtifactUpdateEvent),
101}
102
103// ── Tests ─────────────────────────────────────────────────────────────────────
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::artifact::ArtifactId;
109    use crate::message::Part;
110    use crate::task::{ContextId, TaskState};
111
112    #[test]
113    fn status_update_event_roundtrip() {
114        let event = TaskStatusUpdateEvent {
115            task_id: TaskId::new("task-1"),
116            context_id: ContextId::new("ctx-1"),
117            status: TaskStatus::new(TaskState::Completed),
118            metadata: None,
119        };
120        let json = serde_json::to_string(&event).expect("serialize");
121        assert!(!json.contains("\"final\""), "v1.0 removed final field");
122        assert!(json.contains("\"status\""), "should have status field");
123
124        let back: TaskStatusUpdateEvent = serde_json::from_str(&json).expect("deserialize");
125        assert_eq!(back.status.state, TaskState::Completed);
126    }
127
128    #[test]
129    fn artifact_update_event_roundtrip() {
130        let event = TaskArtifactUpdateEvent {
131            task_id: TaskId::new("task-1"),
132            context_id: ContextId::new("ctx-1"),
133            artifact: Artifact::new(ArtifactId::new("art-1"), vec![Part::text("output")]),
134            append: Some(false),
135            last_chunk: Some(true),
136            metadata: None,
137        };
138        let json = serde_json::to_string(&event).expect("serialize");
139        let back: TaskArtifactUpdateEvent = serde_json::from_str(&json).expect("deserialize");
140        assert_eq!(back.last_chunk, Some(true));
141    }
142
143    #[test]
144    fn stream_response_task_variant() {
145        let task = Task {
146            id: TaskId::new("t1"),
147            context_id: ContextId::new("c1"),
148            status: TaskStatus::new(TaskState::Working),
149            history: None,
150            artifacts: None,
151            metadata: None,
152        };
153        let resp = StreamResponse::Task(task);
154        let json = serde_json::to_string(&resp).expect("serialize");
155        assert!(
156            !json.contains("\"kind\""),
157            "v1.0 should not have kind tag: {json}"
158        );
159
160        let back: StreamResponse = serde_json::from_str(&json).expect("deserialize");
161        assert!(matches!(back, StreamResponse::Task(_)));
162    }
163
164    #[test]
165    fn stream_response_status_update_variant() {
166        let event = TaskStatusUpdateEvent {
167            task_id: TaskId::new("t1"),
168            context_id: ContextId::new("c1"),
169            status: TaskStatus::new(TaskState::Failed),
170            metadata: None,
171        };
172        let resp = StreamResponse::StatusUpdate(event);
173        let json = serde_json::to_string(&resp).expect("serialize");
174        assert!(
175            !json.contains("\"kind\""),
176            "v1.0 should not have kind tag: {json}"
177        );
178
179        let back: StreamResponse = serde_json::from_str(&json).expect("deserialize");
180        assert!(matches!(back, StreamResponse::StatusUpdate(_)));
181    }
182
183    #[test]
184    fn stream_response_message_variant_roundtrip() {
185        use crate::message::{MessageId, MessageRole, Part};
186        use crate::task::TaskId;
187
188        let msg = crate::message::Message {
189            id: MessageId::new("msg-stream-1"),
190            role: MessageRole::Agent,
191            parts: vec![Part::text("streaming response")],
192            task_id: Some(TaskId::new("t1")),
193            context_id: Some(ContextId::new("c1")),
194            reference_task_ids: None,
195            extensions: None,
196            metadata: None,
197        };
198        let resp = StreamResponse::Message(msg);
199        let json = serde_json::to_string(&resp).expect("serialize");
200        assert!(
201            !json.contains("\"kind\""),
202            "v1.0 should not have kind tag: {json}"
203        );
204        assert!(json.contains("\"messageId\":\"msg-stream-1\""));
205        assert!(json.contains("\"role\":\"ROLE_AGENT\""));
206
207        let back: StreamResponse = serde_json::from_str(&json).expect("deserialize");
208        match back {
209            StreamResponse::Message(m) => {
210                assert_eq!(m.id, MessageId::new("msg-stream-1"));
211                assert_eq!(m.role, MessageRole::Agent);
212                assert_eq!(m.parts.len(), 1);
213            }
214            other => panic!("expected Message variant, got {other:?}"),
215        }
216    }
217}