Skip to main content

protocol_droid/domain/a2a/
streaming_events.rs

1//! A2A streaming event value objects — the
2//! `TaskStatusUpdateEvent` and
3//! `TaskArtifactUpdateEvent` types that flow
4//! over a Server-Sent Events connection.
5//!
6//! # What these are
7//!
8//! A2A's two streaming methods
9//! (`SendStreamingMessage` and
10//! `SubscribeToTask`) emit a stream of events
11//! over a single HTTP response. The events are
12//! the same shape regardless of which method
13//! the client used to open the stream:
14//!
15//! - [`TaskStatusUpdateEvent`] — the task's
16//!   status changed. The consumer reads the
17//!   new `status` field.
18//! - [`TaskArtifactUpdateEvent`] — the task
19//!   produced an artifact (or appended to an
20//!   existing one). The consumer reads the
21//!   `artifact` field.
22//!
23//! # Why the events are not a single enum
24//!
25//! The wire form is one of two distinct JSON
26//! objects; the consumer's matching code is
27//! field-presence discrimination. A typed
28//! enum ([`StreamingEvent`]) is also shipped
29//! for the consumer who wants exhaustive
30//! matching. The enum is
31//! `#[serde(untagged)]` so the wire form
32//! matches the spec.
33//!
34//! # Why `final` is on the event, not derived
35//! from `status.state`
36//!
37//! The proto's `TaskStatusUpdateEvent` has no
38//! `final` field — the consumer derives
39//! "this is the final update" from
40//! `status.state` being a terminal state
41//! (`Completed`, `Failed`, `Cancelled`,
42//! `Rejected`). The JSON wire form (per
43//! the A2A v1.0 spec) adds a `final: true`
44//! boolean so a client does not have to
45//! mirror the proto's terminal-state list.
46//! The Rust type carries the boolean
47//! explicitly; the consumer can read it
48//! without re-implementing the
49//! terminal-state check.
50
51use std::collections::BTreeMap;
52
53use serde::{Deserialize, Serialize};
54
55use super::task::{Artifact, TaskStatus};
56
57/// A streaming event that reports a task's
58/// status change.
59///
60/// Wire form:
61/// ```json
62/// {
63///   "taskId": "task-1",
64///   "contextId": "context-1",
65///   "status": { "state": "working" },
66///   "final": false,
67///   "metadata": { ... }
68/// }
69/// ```
70///
71/// `taskId`, `contextId`, `status`, and
72/// `final` are required; `metadata` is
73/// optional. The `final` field is the
74/// JSON-spec extension that lets a
75/// client close the stream without
76/// mirroring the proto's terminal-state
77/// list.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct TaskStatusUpdateEvent {
81    /// The task's id. Required.
82    pub task_id: String,
83    /// The task's context id. Required.
84    pub context_id: String,
85    /// The task's new status. Required.
86    pub status: TaskStatus,
87    /// Whether this is the last event for
88    /// the stream. Required by the JSON
89    /// spec; a value of `true` means the
90    /// client can close the connection.
91    #[serde(rename = "final")]
92    pub is_final: bool,
93    /// Per-event metadata.
94    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95    pub metadata: BTreeMap<String, serde_json::Value>,
96}
97
98impl TaskStatusUpdateEvent {
99    /// Starts a new event with `task_id`,
100    /// `context_id`, and `status`. The
101    /// `final` field defaults to `false`.
102    /// Use
103    /// [`with_final`](Self::with_final)
104    /// to mark the event as the last
105    /// one in the stream.
106    #[must_use]
107    pub fn new(
108        task_id: impl Into<String>,
109        context_id: impl Into<String>,
110        status: TaskStatus,
111    ) -> Self {
112        Self {
113            task_id: task_id.into(),
114            context_id: context_id.into(),
115            status,
116            is_final: false,
117            metadata: BTreeMap::new(),
118        }
119    }
120
121    /// Sets the `final` flag to `true`.
122    #[must_use]
123    pub const fn with_final(mut self) -> Self {
124        self.is_final = true;
125        self
126    }
127}
128
129/// A streaming event that reports a task
130/// artifact being produced or appended.
131///
132/// Wire form:
133/// ```json
134/// {
135///   "taskId": "task-1",
136///   "contextId": "context-1",
137///   "artifact": { "name": "answer", "parts": [...] },
138///   "append": false,
139///   "lastChunk": false,
140///   "metadata": { ... }
141/// }
142/// ```
143///
144/// `taskId`, `contextId`, and `artifact`
145/// are required; `append`, `lastChunk`,
146/// and `metadata` are optional. The
147/// `append` flag is `true` when the
148/// artifact should be appended to a
149/// previously sent artifact with the
150/// same id (chunked transfer); the
151/// `lastChunk` flag is `true` when this
152/// is the final chunk of an append.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct TaskArtifactUpdateEvent {
156    /// The task's id. Required.
157    pub task_id: String,
158    /// The task's context id. Required.
159    pub context_id: String,
160    /// The artifact that was produced or
161    /// updated. Required.
162    pub artifact: Artifact,
163    /// Whether the artifact should be
164    /// appended to a previously sent
165    /// artifact with the same id. The
166    /// default (`false`) means this
167    /// artifact replaces any previous
168    /// one with the same id.
169    #[serde(default, skip_serializing_if = "is_false")]
170    pub append: bool,
171    /// Whether this is the final chunk
172    /// of the artifact. Only meaningful
173    /// when `append` is `true`; ignored
174    /// otherwise.
175    #[serde(default, skip_serializing_if = "is_false")]
176    pub last_chunk: bool,
177    /// Per-event metadata.
178    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
179    pub metadata: BTreeMap<String, serde_json::Value>,
180}
181
182// `skip_serializing_if` requires
183// `fn(&T) -> bool`; the clippy lint is a
184// false positive for serde-internal helpers.
185#[expect(
186    clippy::trivially_copy_pass_by_ref,
187    reason = "required by skip_serializing_if"
188)]
189const fn is_false(b: &bool) -> bool {
190    !*b
191}
192
193impl TaskArtifactUpdateEvent {
194    /// Starts a new event with `task_id`,
195    /// `context_id`, and `artifact`. The
196    /// `append` and `lastChunk` flags
197    /// default to `false`. Use
198    /// [`with_append`](Self::with_append)
199    /// to mark the artifact as an
200    /// append, and
201    /// [`with_last_chunk`](Self::with_last_chunk)
202    /// to mark this as the final chunk.
203    #[must_use]
204    pub fn new(
205        task_id: impl Into<String>,
206        context_id: impl Into<String>,
207        artifact: Artifact,
208    ) -> Self {
209        Self {
210            task_id: task_id.into(),
211            context_id: context_id.into(),
212            artifact,
213            append: false,
214            last_chunk: false,
215            metadata: BTreeMap::new(),
216        }
217    }
218
219    /// Sets the `append` flag to `true`.
220    #[must_use]
221    pub const fn with_append(mut self) -> Self {
222        self.append = true;
223        self
224    }
225
226    /// Sets the `lastChunk` flag to `true`.
227    #[must_use]
228    pub const fn with_last_chunk(mut self) -> Self {
229        self.last_chunk = true;
230        self
231    }
232}
233
234/// An untagged enum of the two streaming
235/// event types.
236///
237/// The wire form is the inner object directly
238/// — a `TaskStatusUpdateEvent` has `taskId`,
239/// `contextId`, `status`, and `final`; a
240/// `TaskArtifactUpdateEvent` has `taskId`,
241/// `contextId`, and `artifact`. The two shapes
242/// are discriminated by whether `status` or
243/// `artifact` is the body field.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(untagged)]
246pub enum StreamingEvent {
247    /// A status update.
248    Status(TaskStatusUpdateEvent),
249    /// An artifact update.
250    Artifact(TaskArtifactUpdateEvent),
251}
252
253impl From<TaskStatusUpdateEvent> for StreamingEvent {
254    fn from(event: TaskStatusUpdateEvent) -> Self {
255        Self::Status(event)
256    }
257}
258
259impl From<TaskArtifactUpdateEvent> for StreamingEvent {
260    fn from(event: TaskArtifactUpdateEvent) -> Self {
261        Self::Artifact(event)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::{StreamingEvent, TaskArtifactUpdateEvent, TaskStatusUpdateEvent};
268    use crate::domain::a2a::message::{Part, TextPart};
269    use crate::domain::a2a::task::{Artifact, TaskState, TaskStatus};
270
271    type TestError = Box<dyn std::error::Error>;
272
273    fn sample_status() -> TaskStatus {
274        TaskStatus {
275            state: TaskState::Working,
276            message: None,
277            timestamp: None,
278        }
279    }
280
281    fn sample_artifact() -> Artifact {
282        Artifact::new(Part::Text(TextPart {
283            text: "answer".to_owned(),
284            metadata: std::collections::BTreeMap::new(),
285        }))
286    }
287
288    // --- TaskStatusUpdateEvent wire form ---------------------------
289
290    #[test]
291    fn status_event_serialises_with_required_fields() -> Result<(), TestError> {
292        let event = TaskStatusUpdateEvent::new("t-1", "c-1", sample_status());
293        let value = serde_json::to_value(&event)?;
294        let obj = value
295            .as_object()
296            .ok_or("a TaskStatusUpdateEvent must serialise to a JSON object")?;
297        assert_eq!(
298            obj.get("taskId").and_then(serde_json::Value::as_str),
299            Some("t-1")
300        );
301        assert_eq!(
302            obj.get("contextId").and_then(serde_json::Value::as_str),
303            Some("c-1")
304        );
305        assert!(obj.contains_key("status"));
306        // The `final` field is required
307        // by the JSON spec — a value of
308        // `false` is present on the wire.
309        assert!(
310            obj.contains_key("final"),
311            "the `final` field must be present even when false"
312        );
313        assert_eq!(
314            obj.get("final").and_then(serde_json::Value::as_bool),
315            Some(false)
316        );
317        Ok(())
318    }
319
320    #[test]
321    fn status_event_with_final_true_round_trips() -> Result<(), TestError> {
322        let event = TaskStatusUpdateEvent::new(
323            "t-1",
324            "c-1",
325            TaskStatus {
326                state: TaskState::Completed,
327                message: None,
328                timestamp: None,
329            },
330        )
331        .with_final();
332        let json = serde_json::to_string(&event)?;
333        let parsed: TaskStatusUpdateEvent = serde_json::from_str(&json)?;
334        assert_eq!(parsed, event);
335        assert!(json.contains(r#""final":true"#));
336        Ok(())
337    }
338
339    // --- TaskArtifactUpdateEvent wire form -------------------------
340
341    #[test]
342    fn artifact_event_serialises_with_artifact_field() -> Result<(), TestError> {
343        let event = TaskArtifactUpdateEvent::new("t-1", "c-1", sample_artifact());
344        let value = serde_json::to_value(&event)?;
345        let obj = value
346            .as_object()
347            .ok_or("a TaskArtifactUpdateEvent must serialise to a JSON object")?;
348        assert_eq!(
349            obj.get("taskId").and_then(serde_json::Value::as_str),
350            Some("t-1")
351        );
352        assert_eq!(
353            obj.get("contextId").and_then(serde_json::Value::as_str),
354            Some("c-1")
355        );
356        assert!(obj.contains_key("artifact"));
357        // The optional `append` and
358        // `lastChunk` flags are absent
359        // when they are `false`.
360        assert!(!obj.contains_key("append"));
361        assert!(!obj.contains_key("lastChunk"));
362        Ok(())
363    }
364
365    #[test]
366    fn artifact_event_with_append_and_last_chunk_round_trips() -> Result<(), TestError> {
367        let event = TaskArtifactUpdateEvent::new("t-1", "c-1", sample_artifact())
368            .with_append()
369            .with_last_chunk();
370        let json = serde_json::to_string(&event)?;
371        let parsed: TaskArtifactUpdateEvent = serde_json::from_str(&json)?;
372        assert_eq!(parsed, event);
373        assert!(json.contains(r#""append":true"#));
374        assert!(json.contains(r#""lastChunk":true"#));
375        Ok(())
376    }
377
378    // --- StreamingEvent untagged enum ------------------------------
379
380    #[test]
381    fn streaming_event_untagged_discrimination_works_both_ways() -> Result<(), TestError> {
382        // A status event JSON object
383        // decodes to `StreamingEvent::Status`,
384        // not `::Artifact`. An artifact
385        // event JSON object decodes to
386        // `::Artifact`.
387        let status_json =
388            r#"{"taskId":"t-1","contextId":"c-1","status":{"state":"working"},"final":false}"#;
389        let artifact_json =
390            r#"{"taskId":"t-1","contextId":"c-1","artifact":{"parts":[{"text":"x"}]}}"#;
391        let from_status: StreamingEvent = serde_json::from_str(status_json)
392            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
393        let from_artifact: StreamingEvent = serde_json::from_str(artifact_json)
394            .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
395        assert!(matches!(from_status, StreamingEvent::Status(_)));
396        assert!(matches!(from_artifact, StreamingEvent::Artifact(_)));
397        Ok(())
398    }
399}