Skip to main content

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}