Skip to main content

a2a_protocol_types/
task.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F.
3
4//! Task types for the A2A protocol.
5//!
6//! A [`Task`] is the stateful unit of work managed by an agent. Its lifecycle
7//! is tracked through [`TaskStatus`] and [`TaskState`]. The [`TaskState`] enum
8//! uses `SCREAMING_SNAKE_CASE` with type prefix per `ProtoJSON` convention
9//! (e.g. `"TASK_STATE_INPUT_REQUIRED"`).
10//!
11//! # ID newtypes
12//!
13//! [`TaskId`], [`ContextId`], and [`TaskVersion`] are newtypes over `String`
14//! (or `u64`) for compile-time type safety.
15
16use serde::{Deserialize, Serialize};
17
18use crate::artifact::Artifact;
19use crate::message::Message;
20
21// ── TaskId ────────────────────────────────────────────────────────────────────
22
23/// Opaque unique identifier for a [`Task`].
24///
25/// IDs are compared as raw byte strings (via the derived [`PartialEq`] on
26/// the inner `String`). No Unicode normalization is applied, so two IDs
27/// that look identical but use different Unicode representations (e.g.
28/// NFC vs. NFD) will be considered distinct.
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub struct TaskId(pub String);
31
32impl TaskId {
33    /// Creates a new [`TaskId`] from any string-like value.
34    #[must_use]
35    pub fn new(s: impl Into<String>) -> Self {
36        Self(s.into())
37    }
38}
39
40impl std::fmt::Display for TaskId {
41    #[inline]
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.write_str(&self.0)
44    }
45}
46
47impl From<String> for TaskId {
48    fn from(s: String) -> Self {
49        Self(s)
50    }
51}
52
53impl From<&str> for TaskId {
54    fn from(s: &str) -> Self {
55        Self(s.to_owned())
56    }
57}
58
59impl AsRef<str> for TaskId {
60    #[inline]
61    fn as_ref(&self) -> &str {
62        &self.0
63    }
64}
65
66// ── ContextId ─────────────────────────────────────────────────────────────────
67
68/// Opaque unique identifier for a conversation context.
69///
70/// A context groups related tasks under a single logical conversation thread.
71///
72/// Like [`TaskId`], IDs are compared as raw byte strings without Unicode
73/// normalization.
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub struct ContextId(pub String);
76
77impl ContextId {
78    /// Creates a new [`ContextId`] from any string-like value.
79    #[must_use]
80    pub fn new(s: impl Into<String>) -> Self {
81        Self(s.into())
82    }
83}
84
85impl std::fmt::Display for ContextId {
86    #[inline]
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(&self.0)
89    }
90}
91
92impl From<String> for ContextId {
93    fn from(s: String) -> Self {
94        Self(s)
95    }
96}
97
98impl From<&str> for ContextId {
99    fn from(s: &str) -> Self {
100        Self(s.to_owned())
101    }
102}
103
104impl AsRef<str> for ContextId {
105    #[inline]
106    fn as_ref(&self) -> &str {
107        &self.0
108    }
109}
110
111// ── TaskVersion ───────────────────────────────────────────────────────────────
112
113/// Monotonically increasing version counter for optimistic concurrency control.
114///
115/// Incremented every time a [`Task`] is mutated.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117pub struct TaskVersion(pub u64);
118
119impl TaskVersion {
120    /// Creates a [`TaskVersion`] from a `u64`.
121    #[must_use]
122    pub const fn new(v: u64) -> Self {
123        Self(v)
124    }
125
126    /// Returns the inner `u64` value.
127    #[must_use]
128    pub const fn get(self) -> u64 {
129        self.0
130    }
131}
132
133impl std::fmt::Display for TaskVersion {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(f, "{}", self.0)
136    }
137}
138
139impl From<u64> for TaskVersion {
140    fn from(v: u64) -> Self {
141        Self(v)
142    }
143}
144
145// ── TaskState ─────────────────────────────────────────────────────────────────
146
147/// The lifecycle state of a [`Task`].
148///
149/// Uses `SCREAMING_SNAKE_CASE` with `TASK_STATE_` prefix per `ProtoJSON`
150/// convention (e.g. `TaskState::InputRequired` ↔ `"TASK_STATE_INPUT_REQUIRED"`).
151#[non_exhaustive]
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
153pub enum TaskState {
154    /// Proto default (0-value); should not appear in normal usage.
155    #[serde(rename = "TASK_STATE_UNSPECIFIED")]
156    Unspecified,
157    /// Task received, not yet started.
158    #[serde(rename = "TASK_STATE_SUBMITTED")]
159    Submitted,
160    /// Task is actively being processed.
161    #[serde(rename = "TASK_STATE_WORKING")]
162    Working,
163    /// Agent requires additional input from the client to proceed.
164    #[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
165    InputRequired,
166    /// Agent requires the client to complete an authentication step.
167    #[serde(rename = "TASK_STATE_AUTH_REQUIRED")]
168    AuthRequired,
169    /// Task finished successfully.
170    #[serde(rename = "TASK_STATE_COMPLETED")]
171    Completed,
172    /// Task finished with an error.
173    #[serde(rename = "TASK_STATE_FAILED")]
174    Failed,
175    /// Task was canceled by the client.
176    #[serde(rename = "TASK_STATE_CANCELED")]
177    Canceled,
178    /// Task was rejected by the agent before execution.
179    #[serde(rename = "TASK_STATE_REJECTED")]
180    Rejected,
181}
182
183impl TaskState {
184    /// Returns `true` if this state is a terminal (final) state.
185    ///
186    /// Terminal states: `Completed`, `Failed`, `Canceled`, `Rejected`.
187    #[inline]
188    #[must_use]
189    pub const fn is_terminal(self) -> bool {
190        matches!(
191            self,
192            Self::Completed | Self::Failed | Self::Canceled | Self::Rejected
193        )
194    }
195
196    /// Returns `true` if transitioning from `self` to `next` is a valid
197    /// state transition per the A2A protocol.
198    ///
199    /// Terminal states cannot transition to any other state.
200    /// `Unspecified` can transition to any state.
201    #[inline]
202    #[must_use]
203    pub const fn can_transition_to(self, next: Self) -> bool {
204        // Terminal states are final — no transitions allowed.
205        if self.is_terminal() {
206            return false;
207        }
208        // Allow any transition from Unspecified (proto default).
209        if matches!(self, Self::Unspecified) {
210            return true;
211        }
212        matches!(
213            (self, next),
214            // Submitted → Working, Failed, Canceled, Rejected
215            (Self::Submitted, Self::Working | Self::Failed | Self::Canceled | Self::Rejected)
216            // Working → Completed, Failed, Canceled, InputRequired, AuthRequired
217            | (Self::Working,
218               Self::Completed | Self::Failed | Self::Canceled | Self::InputRequired | Self::AuthRequired)
219            // InputRequired / AuthRequired → Working, Failed, Canceled
220            | (Self::InputRequired | Self::AuthRequired,
221               Self::Working | Self::Failed | Self::Canceled)
222        )
223    }
224}
225
226impl std::fmt::Display for TaskState {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        let s = match self {
229            Self::Unspecified => "TASK_STATE_UNSPECIFIED",
230            Self::Submitted => "TASK_STATE_SUBMITTED",
231            Self::Working => "TASK_STATE_WORKING",
232            Self::InputRequired => "TASK_STATE_INPUT_REQUIRED",
233            Self::AuthRequired => "TASK_STATE_AUTH_REQUIRED",
234            Self::Completed => "TASK_STATE_COMPLETED",
235            Self::Failed => "TASK_STATE_FAILED",
236            Self::Canceled => "TASK_STATE_CANCELED",
237            Self::Rejected => "TASK_STATE_REJECTED",
238        };
239        f.write_str(s)
240    }
241}
242
243// ── TaskStatus ────────────────────────────────────────────────────────────────
244
245/// The current status of a [`Task`], combining state with an optional message
246/// and timestamp.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct TaskStatus {
250    /// Current lifecycle state.
251    pub state: TaskState,
252
253    /// Optional agent message accompanying this status (e.g. error details).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub message: Option<Message>,
256
257    /// ISO 8601 timestamp of when this status was set.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub timestamp: Option<String>,
260}
261
262impl TaskStatus {
263    /// Creates a [`TaskStatus`] with just a state and no timestamp.
264    ///
265    /// Prefer [`TaskStatus::with_timestamp`] in production code so that
266    /// status changes carry an ISO 8601 timestamp.
267    #[must_use]
268    pub const fn new(state: TaskState) -> Self {
269        Self {
270            state,
271            message: None,
272            timestamp: None,
273        }
274    }
275
276    /// Creates a [`TaskStatus`] with a state and the current UTC timestamp.
277    #[must_use]
278    pub fn with_timestamp(state: TaskState) -> Self {
279        Self {
280            state,
281            message: None,
282            timestamp: Some(crate::utc_now_iso8601()),
283        }
284    }
285}
286
287// ── Task ──────────────────────────────────────────────────────────────────────
288
289/// A unit of work managed by an A2A agent.
290///
291/// The wire `kind` field (`"task"`) is injected by enclosing discriminated
292/// unions such as [`crate::events::StreamResponse`] and
293/// [`crate::responses::SendMessageResponse`]. Standalone `Task` values received
294/// over the wire may include `kind`; serde silently tolerates unknown fields, so
295/// no action is needed on the receiving side.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct Task {
299    /// Unique task identifier.
300    pub id: TaskId,
301
302    /// Conversation context this task belongs to.
303    pub context_id: ContextId,
304
305    /// Current status of the task.
306    pub status: TaskStatus,
307
308    /// Historical messages exchanged during this task.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub history: Option<Vec<Message>>,
311
312    /// Artifacts produced by this task.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub artifacts: Option<Vec<Artifact>>,
315
316    /// Arbitrary metadata.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub metadata: Option<serde_json::Value>,
319}
320
321// ── Tests ─────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn make_task() -> Task {
328        Task {
329            id: TaskId::new("task-1"),
330            context_id: ContextId::new("ctx-1"),
331            status: TaskStatus::new(TaskState::Working),
332            history: None,
333            artifacts: None,
334            metadata: None,
335        }
336    }
337
338    #[test]
339    fn task_state_screaming_snake_case() {
340        assert_eq!(
341            serde_json::to_string(&TaskState::InputRequired).expect("ser"),
342            "\"TASK_STATE_INPUT_REQUIRED\""
343        );
344        assert_eq!(
345            serde_json::to_string(&TaskState::AuthRequired).expect("ser"),
346            "\"TASK_STATE_AUTH_REQUIRED\""
347        );
348        assert_eq!(
349            serde_json::to_string(&TaskState::Submitted).expect("ser"),
350            "\"TASK_STATE_SUBMITTED\""
351        );
352        assert_eq!(
353            serde_json::to_string(&TaskState::Unspecified).expect("ser"),
354            "\"TASK_STATE_UNSPECIFIED\""
355        );
356    }
357
358    #[test]
359    fn task_state_is_terminal() {
360        assert!(TaskState::Completed.is_terminal());
361        assert!(TaskState::Failed.is_terminal());
362        assert!(TaskState::Canceled.is_terminal());
363        assert!(TaskState::Rejected.is_terminal());
364        assert!(!TaskState::Working.is_terminal());
365        assert!(!TaskState::Submitted.is_terminal());
366    }
367
368    #[test]
369    fn task_roundtrip() {
370        let task = make_task();
371        let json = serde_json::to_string(&task).expect("serialize");
372        assert!(json.contains("\"id\":\"task-1\""));
373
374        let back: Task = serde_json::from_str(&json).expect("deserialize");
375        assert_eq!(back.id, TaskId::new("task-1"));
376        assert_eq!(back.context_id, ContextId::new("ctx-1"));
377        assert_eq!(back.status.state, TaskState::Working);
378    }
379
380    #[test]
381    fn optional_fields_omitted() {
382        let task = make_task();
383        let json = serde_json::to_string(&task).expect("serialize");
384        assert!(!json.contains("\"history\""), "history should be omitted");
385        assert!(
386            !json.contains("\"artifacts\""),
387            "artifacts should be omitted"
388        );
389        assert!(!json.contains("\"metadata\""), "metadata should be omitted");
390    }
391
392    #[test]
393    fn task_version_ordering() {
394        assert!(TaskVersion::new(2) > TaskVersion::new(1));
395        assert_eq!(TaskVersion::new(5).get(), 5);
396    }
397
398    #[test]
399    fn wire_format_submitted_state() {
400        // Spec: TASK_STATE_SUBMITTED (not TASK_STATE_PENDING)
401        let json = serde_json::to_string(&TaskState::Submitted).unwrap();
402        assert_eq!(json, "\"TASK_STATE_SUBMITTED\"");
403
404        let back: TaskState = serde_json::from_str("\"TASK_STATE_SUBMITTED\"").unwrap();
405        assert_eq!(back, TaskState::Submitted);
406    }
407
408    #[test]
409    fn task_version_serde_roundtrip() {
410        let v = TaskVersion::new(42);
411        let json = serde_json::to_string(&v).expect("serialize");
412        assert_eq!(json, "42");
413
414        let back: TaskVersion = serde_json::from_str(&json).expect("deserialize");
415        assert_eq!(back, TaskVersion::new(42));
416
417        // Also test zero
418        let v0 = TaskVersion::new(0);
419        let json0 = serde_json::to_string(&v0).expect("serialize zero");
420        assert_eq!(json0, "0");
421        let back0: TaskVersion = serde_json::from_str(&json0).expect("deserialize zero");
422        assert_eq!(back0, TaskVersion::new(0));
423
424        // And u64::MAX
425        let vmax = TaskVersion::new(u64::MAX);
426        let json_max = serde_json::to_string(&vmax).expect("serialize max");
427        let back_max: TaskVersion = serde_json::from_str(&json_max).expect("deserialize max");
428        assert_eq!(back_max, vmax);
429    }
430
431    #[test]
432    fn empty_string_ids_work() {
433        let tid = TaskId::new("");
434        let json = serde_json::to_string(&tid).expect("serialize empty TaskId");
435        assert_eq!(json, "\"\"");
436        let back: TaskId = serde_json::from_str(&json).expect("deserialize empty TaskId");
437        assert_eq!(back, TaskId::new(""));
438
439        let cid = ContextId::new("");
440        let json = serde_json::to_string(&cid).expect("serialize empty ContextId");
441        assert_eq!(json, "\"\"");
442        let back: ContextId = serde_json::from_str(&json).expect("deserialize empty ContextId");
443        assert_eq!(back, ContextId::new(""));
444
445        // A task with empty IDs should still roundtrip.
446        let task = Task {
447            id: TaskId::new(""),
448            context_id: ContextId::new(""),
449            status: TaskStatus::new(TaskState::Submitted),
450            history: None,
451            artifacts: None,
452            metadata: None,
453        };
454        let json = serde_json::to_string(&task).expect("serialize task with empty ids");
455        let back: Task = serde_json::from_str(&json).expect("deserialize task with empty ids");
456        assert_eq!(back.id, TaskId::new(""));
457        assert_eq!(back.context_id, ContextId::new(""));
458    }
459
460    #[test]
461    fn task_state_display_trait() {
462        assert_eq!(TaskState::Working.to_string(), "TASK_STATE_WORKING");
463        assert_eq!(TaskState::Completed.to_string(), "TASK_STATE_COMPLETED");
464        assert_eq!(TaskState::Failed.to_string(), "TASK_STATE_FAILED");
465        assert_eq!(TaskState::Canceled.to_string(), "TASK_STATE_CANCELED");
466        assert_eq!(TaskState::Rejected.to_string(), "TASK_STATE_REJECTED");
467        assert_eq!(TaskState::Submitted.to_string(), "TASK_STATE_SUBMITTED");
468        assert_eq!(
469            TaskState::InputRequired.to_string(),
470            "TASK_STATE_INPUT_REQUIRED"
471        );
472        assert_eq!(
473            TaskState::AuthRequired.to_string(),
474            "TASK_STATE_AUTH_REQUIRED"
475        );
476        assert_eq!(TaskState::Unspecified.to_string(), "TASK_STATE_UNSPECIFIED");
477    }
478}