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}