protocol_droid/domain/a2a/task.rs
1//! A2A `Task`, `TaskStatus`, `TaskState`, and
2//! `Artifact` value objects.
3//!
4//! # What these are
5//!
6//! A [`Task`] is the unit of work an A2A agent
7//! tracks. A client opens a task by sending a
8//! `SendMessage` request; the agent creates a
9//! task, runs the work, and answers with a
10//! `Task` whose [`TaskStatus::state`] moves
11//! through [`Submitted`](TaskState::Submitted)
12//! → [`Working`](TaskState::Working) →
13//! [`Completed`](TaskState::Completed) (or one
14//! of the failure states).
15//!
16//! A [`Task`] carries:
17//! - An id (the task's identity) and an optional
18//! context id (a grouping across related
19//! tasks).
20//! - A [`TaskStatus`] — the current state, the
21//! status message, and the timestamp.
22//! - A list of [`Artifact`]s — the work's
23//! output. An artifact is a named bundle of
24//! [`Part`](super::message::Part)s.
25//! - A list of [`Message`]s
26//! — the conversation history that produced
27//! the task.
28//!
29//! # Why a `BTreeMap` for the metadata
30//!
31//! Same rationale as
32//! [`Message::metadata`](super::message::Message::metadata):
33//! the wire form is deterministic (alphabetical
34//! by key), which keeps test fixtures readable
35//! and makes diff-on-the-wire stable.
36//!
37//! # Why `timestamp` is a string
38//!
39//! The A2A spec uses RFC 3339 / ISO 8601
40//! timestamps. The Rust type that round-trips
41//! with the spec is a string — `chrono::DateTime`
42//! is a more typed alternative but adds a
43//! dependency and a `Result` on every read.
44//! The string is parsed by the consumer if a
45//! parsed form is needed.
46
47use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51use super::message::Message;
52
53/// A unit of work an A2A agent tracks.
54///
55/// Wire form:
56/// ```json
57/// {
58/// "id": "task-123",
59/// "contextId": "context-456",
60/// "status": { "state": "working" },
61/// "artifacts": [...],
62/// "history": [...],
63/// "metadata": {...}
64/// }
65/// ```
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct Task {
69 /// The task's id. Required and unique
70 /// within the agent.
71 pub id: Arc<str>,
72 /// An optional context id. A context groups
73 /// related tasks (a multi-turn conversation
74 /// is a context; each user turn is a task
75 /// within that context). The spec does not
76 /// require contexts; an agent that does not
77 /// support them leaves the field off the
78 /// wire.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub context_id: Option<Arc<str>>,
81 /// The task's current status.
82 pub status: TaskStatus,
83 /// The task's output. An empty list is a
84 /// task that has not produced any output
85 /// yet (or a task whose output is empty by
86 /// design).
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub artifacts: Vec<Artifact>,
89 /// The conversation history that produced
90 /// the task. A task created from a single
91 /// `SendMessage` request has a one-element
92 /// history; a task created from a streaming
93 /// interaction has more.
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub history: Vec<Message>,
96 /// Per-task metadata. The spec leaves the
97 /// shape open; the conventional keys are
98 /// `"trace_id"`, `"session_id"`, and the
99 /// custom names a particular deployment
100 /// uses.
101 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102 pub metadata: BTreeMap<String, serde_json::Value>,
103}
104
105use std::sync::Arc;
106
107impl Task {
108 /// Starts a new task in
109 /// [`Submitted`](TaskState::Submitted) state
110 /// with `id`. Use [`with_status`](Self::with_status)
111 /// to advance the state, [`with_artifact`](Self::with_artifact)
112 /// to attach an output, and so on.
113 #[must_use]
114 pub fn new(id: impl Into<Arc<str>>) -> Self {
115 Self {
116 id: id.into(),
117 context_id: None,
118 status: TaskStatus {
119 state: TaskState::Submitted,
120 message: None,
121 timestamp: None,
122 },
123 artifacts: Vec::new(),
124 history: Vec::new(),
125 metadata: BTreeMap::new(),
126 }
127 }
128
129 /// Replaces the task's status. The task is
130 /// consumed; chain
131 /// `Task::new(id).with_status(...).with_artifact(...)`
132 /// to build a task in one expression.
133 #[must_use]
134 pub fn with_status(mut self, status: TaskStatus) -> Self {
135 self.status = status;
136 self
137 }
138
139 /// Appends an artifact to the task.
140 #[must_use]
141 pub fn with_artifact(mut self, artifact: impl Into<Artifact>) -> Self {
142 self.artifacts.push(artifact.into());
143 self
144 }
145
146 /// Appends a message to the history.
147 #[must_use]
148 pub fn with_history_message(mut self, message: impl Into<Message>) -> Self {
149 self.history.push(message.into());
150 self
151 }
152}
153
154/// A task's current status.
155///
156/// Wire form:
157/// ```json
158/// {
159/// "state": "working",
160/// "message": { "role": "agent", "parts": [...] },
161/// "timestamp": "2025-01-01T00:00:00Z"
162/// }
163/// ```
164///
165/// `state` is required; `message` and
166/// `timestamp` are optional. A status with
167/// no message and no timestamp is the
168/// minimum valid wire form.
169#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct TaskStatus {
171 /// The state. Required.
172 pub state: TaskState,
173 /// An optional message the agent attaches
174 /// to the status — typically used for
175 /// error messages on a
176 /// [`Failed`](TaskState::Failed) status or
177 /// clarification questions on an
178 /// [`InputRequired`](TaskState::InputRequired)
179 /// status.
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub message: Option<Message>,
182 /// An optional RFC 3339 timestamp. The
183 /// string is the wire form; a consumer
184 /// that wants a parsed form does the
185 /// `chrono::DateTime::parse_from_rfc3339`
186 /// on demand.
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub timestamp: Option<Arc<str>>,
189}
190
191/// The state of a [`Task`].
192///
193/// The six values are the spec's exhaustive
194/// list. The state moves through the
195/// [`Submitted`](Self::Submitted) →
196/// [`Working`](Self::Working) → either
197/// [`Completed`](Self::Completed),
198/// [`Failed`](Self::Failed),
199/// `Cancelled`, or
200/// [`InputRequired`](Self::InputRequired)
201/// progression. The wire form is
202/// `snake_case` to match the spec's
203/// examples.
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum TaskState {
207 /// The task has been received by the
208 /// agent and is queued for execution.
209 #[default]
210 Submitted,
211 /// The task is currently being executed.
212 /// This is the only "in-flight" state;
213 /// every other state is terminal or
214 /// blocking.
215 Working,
216 /// The task finished successfully. The
217 /// task's `artifacts` list is the output.
218 Completed,
219 /// The task failed. The `status.message`
220 /// field, if present, explains why.
221 Failed,
222 /// The task was cancelled by the client
223 /// (a `CancelTask` request) or by the
224 /// agent. The state is terminal; a
225 /// cancelled task cannot be resumed.
226 Cancelled,
227 /// The task is blocked on user input.
228 /// The `status.message` field, if
229 /// present, is the agent's question.
230 InputRequired,
231}
232
233/// A unit of output produced by a [`Task`].
234///
235/// Wire form:
236/// ```json
237/// {
238/// "name": "answer",
239/// "description": "The agent's final answer.",
240/// "parts": [Part, ...],
241/// "metadata": {...}
242/// }
243/// ```
244#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
245pub struct Artifact {
246 /// An optional name. The spec does not
247 /// require it; a part-only artifact (no
248 /// name, no description) is valid.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub name: Option<String>,
251 /// An optional human-readable description.
252 /// Agents that want a UI surface to label
253 /// the artifact set it; agents that treat
254 /// the artifact as an opaque payload do
255 /// not.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub description: Option<String>,
258 /// The artifact's content. An empty list
259 /// is a valid artifact (an empty result);
260 /// the spec does not require at least one
261 /// part.
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub parts: Vec<super::message::Part>,
264 /// Per-artifact metadata. Same shape and
265 /// rationale as [`Task::metadata`].
266 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267 pub metadata: BTreeMap<String, serde_json::Value>,
268}
269
270impl Artifact {
271 /// Starts a new artifact with one part. Use
272 /// [`with_part`](Self::with_part) to add more
273 /// parts and [`with_name`](Self::with_name)
274 /// / [`with_description`](Self::with_description)
275 /// to set the optional fields.
276 #[must_use]
277 pub fn new(part: impl Into<super::message::Part>) -> Self {
278 Self {
279 name: None,
280 description: None,
281 parts: vec![part.into()],
282 metadata: BTreeMap::new(),
283 }
284 }
285
286 /// Appends a part to the artifact.
287 #[must_use]
288 pub fn with_part(mut self, part: impl Into<super::message::Part>) -> Self {
289 self.parts.push(part.into());
290 self
291 }
292
293 /// Sets the artifact's name.
294 #[must_use]
295 pub fn with_name(mut self, name: impl Into<String>) -> Self {
296 self.name = Some(name.into());
297 self
298 }
299
300 /// Sets the artifact's description.
301 #[must_use]
302 pub fn with_description(mut self, description: impl Into<String>) -> Self {
303 self.description = Some(description.into());
304 self
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use std::collections::BTreeMap;
311 use std::sync::Arc;
312
313 use super::{Artifact, Task, TaskState, TaskStatus};
314 use crate::domain::a2a::message::{Message, MessageRole, Part, TextPart};
315
316 type TestError = Box<dyn std::error::Error>;
317
318 fn text_part(text: &str) -> Part {
319 Part::Text(TextPart {
320 text: text.to_owned(),
321 metadata: BTreeMap::new(),
322 })
323 }
324
325 // --- Task wire form -----------------------------------------------
326
327 #[test]
328 fn task_serialises_with_required_id_and_status() -> Result<(), TestError> {
329 let task = Task::new("task-1");
330 let value = serde_json::to_value(&task)?;
331 let obj = value
332 .as_object()
333 .ok_or("a Task must serialise to a JSON object")?;
334 assert_eq!(
335 obj.get("id").and_then(serde_json::Value::as_str),
336 Some("task-1")
337 );
338 let status = obj
339 .get("status")
340 .and_then(serde_json::Value::as_object)
341 .ok_or("`status` must be a JSON object")?;
342 assert_eq!(
343 status.get("state").and_then(serde_json::Value::as_str),
344 Some("submitted")
345 );
346 Ok(())
347 }
348
349 #[test]
350 fn task_with_no_artifacts_or_history_omits_the_fields() -> Result<(), TestError> {
351 // Same `Option`-shaped wire form as
352 // `Message::metadata`: an empty list is
353 // absent, not present-but-empty.
354 let task = Task::new("task-1");
355 let value = serde_json::to_value(&task)?;
356 let obj = value
357 .as_object()
358 .ok_or("a Task must serialise to a JSON object")?;
359 assert!(!obj.contains_key("artifacts"));
360 assert!(!obj.contains_key("history"));
361 assert!(!obj.contains_key("metadata"));
362 assert!(!obj.contains_key("contextId"));
363 Ok(())
364 }
365
366 #[test]
367 fn task_round_trips_with_artifacts_and_history() -> Result<(), TestError> {
368 let task = Task::new("task-1")
369 .with_status(TaskStatus {
370 state: TaskState::Working,
371 message: Some(Message::new(MessageRole::Agent).with_part(text_part("thinking..."))),
372 timestamp: Some(Arc::from("2025-01-01T00:00:00Z")),
373 })
374 .with_artifact(Artifact::new(text_part("answer")))
375 .with_history_message(Message::new(MessageRole::User).with_part(text_part("hi")));
376 let json = serde_json::to_string(&task)?;
377 let parsed: Task = serde_json::from_str(&json)?;
378 assert_eq!(parsed, task);
379 Ok(())
380 }
381
382 // --- TaskState wire form ------------------------------------------
383
384 #[test]
385 fn task_state_serialises_in_lowercase() -> Result<(), TestError> {
386 // The wire form is `lowercase` to
387 // match the spec's examples. Every
388 // variant is pinned here so a future
389 // rename-with-`#[serde(rename_all = ...)]`
390 // change fails the lint.
391 for (state, expected) in [
392 (TaskState::Submitted, "submitted"),
393 (TaskState::Working, "working"),
394 (TaskState::Completed, "completed"),
395 (TaskState::Failed, "failed"),
396 (TaskState::Cancelled, "cancelled"),
397 (TaskState::InputRequired, "input_required"),
398 ] {
399 let value = serde_json::to_value(state)?;
400 let got = value
401 .as_str()
402 .ok_or("a TaskState must serialise to a JSON string")?;
403 assert_eq!(got, expected, "TaskState::{state:?}");
404 }
405 Ok(())
406 }
407
408 #[test]
409 fn task_state_round_trips_for_every_variant() -> Result<(), TestError> {
410 for state in [
411 TaskState::Submitted,
412 TaskState::Working,
413 TaskState::Completed,
414 TaskState::Failed,
415 TaskState::Cancelled,
416 TaskState::InputRequired,
417 ] {
418 let json = serde_json::to_string(&state)?;
419 let parsed: TaskState = serde_json::from_str(&json)?;
420 assert_eq!(parsed, state);
421 }
422 Ok(())
423 }
424
425 // --- TaskStatus wire form -----------------------------------------
426
427 #[test]
428 fn task_status_with_no_message_or_timestamp_is_minimal() -> Result<(), TestError> {
429 // A `TaskStatus` with just a state
430 // serialises to `{"state": "..."}`
431 // only. The other fields are absent
432 // when they are `None`.
433 let status = TaskStatus {
434 state: TaskState::Working,
435 message: None,
436 timestamp: None,
437 };
438 let value = serde_json::to_value(&status)?;
439 let obj = value
440 .as_object()
441 .ok_or("a TaskStatus must serialise to a JSON object")?;
442 assert_eq!(obj.len(), 1);
443 assert_eq!(
444 obj.get("state").and_then(serde_json::Value::as_str),
445 Some("working")
446 );
447 Ok(())
448 }
449
450 // --- Artifact wire form -------------------------------------------
451
452 #[test]
453 fn artifact_serialises_with_camel_case_name() -> Result<(), TestError> {
454 // The `Artifact` is the only A2A
455 // type that does not use the
456 // `camelCase` rename. The spec's
457 // wire form has `name` and
458 // `description` in their natural
459 // case (no `Name` / `Description`).
460 // The test pins that choice.
461 let artifact = Artifact::new(text_part("data"))
462 .with_name("answer")
463 .with_description("The agent's final answer.");
464 let value = serde_json::to_value(&artifact)?;
465 let obj = value
466 .as_object()
467 .ok_or("an Artifact must serialise to a JSON object")?;
468 assert_eq!(
469 obj.get("name").and_then(serde_json::Value::as_str),
470 Some("answer")
471 );
472 assert_eq!(
473 obj.get("description").and_then(serde_json::Value::as_str),
474 Some("The agent's final answer.")
475 );
476 assert!(!obj.contains_key("Name"));
477 assert!(!obj.contains_key("Description"));
478 Ok(())
479 }
480
481 #[test]
482 fn artifact_round_trips_with_multiple_parts() -> Result<(), TestError> {
483 let artifact = Artifact::new(text_part("first")).with_part(text_part("second"));
484 let json = serde_json::to_string(&artifact)?;
485 let parsed: Artifact = serde_json::from_str(&json)?;
486 assert_eq!(parsed, artifact);
487 Ok(())
488 }
489}