Skip to main content

protocol_droid/domain/a2a/
task_lifecycle.rs

1//! A2A `GetTask` and `CancelTask` typed envelopes.
2//!
3//! # What these are
4//!
5//! [`GetTaskRequest`] and [`GetTaskResponse`] are
6//! the typed shapes for the `GetTask` JSON-RPC
7//! method. The method fetches a task's current
8//! state by id; a client polls it to advance a
9//! `Submitted` / `Working` task to a terminal
10//! state.
11//!
12//! [`CancelTaskRequest`] and
13//! [`CancelTaskResponse`] are the typed shapes
14//! for the `CancelTask` JSON-RPC method. The
15//! method requests that a task be cancelled;
16//! the response is the task as it was observed
17//! at the moment of cancellation.
18//!
19//! Both methods return a [`Task`] on success —
20//! `CancelTask` returns the task in
21//! `Cancelled` state
22//! (or the state it was in, if the task had
23//! already completed), and `GetTask` returns
24//! the task in whatever state it is currently
25//! in.
26//!
27//! # Why one file for two methods
28//!
29//! `GetTask` and `CancelTask` are paired: a
30//! client uses `GetTask` to poll a task to
31//! completion, and `CancelTask` to abort a
32//! long-running task. Their request shapes
33//! overlap (both take an id), and the
34//! file-split the project uses for the rest
35//! of the A2A IR is "one method per file",
36//! but pairing them keeps the relationship
37//! visible at a glance.
38
39use std::collections::BTreeMap;
40
41use serde::{Deserialize, Serialize};
42
43use super::task::Task;
44
45/// A typed `GetTask` request. Wire form:
46/// ```json
47/// {
48///   "id": "task-123",
49///   "historyLength": 10,
50///   "metadata": { ... }
51/// }
52/// ```
53///
54/// `id` is required; `historyLength` and
55/// `metadata` are optional. A request with
56/// only the id serialises to `{"id": "..."}`
57/// on the wire.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GetTaskRequest {
60    /// The task's id. Required.
61    pub id: String,
62    /// The number of history messages to
63    /// include in the response. A request
64    /// with no history length lets the
65    /// agent choose.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub history_length: Option<u32>,
68    /// Per-request metadata. The spec leaves
69    /// the shape open; the conventional
70    /// keys are `"trace_id"`, `"session_id"`,
71    /// and the custom names a particular
72    /// deployment uses.
73    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
74    pub metadata: BTreeMap<String, serde_json::Value>,
75}
76
77impl GetTaskRequest {
78    /// Starts a new request with `id`. Use
79    /// [`with_history_length`](Self::with_history_length)
80    /// to set the optional history length.
81    #[must_use]
82    pub fn new(id: impl Into<String>) -> Self {
83        Self {
84            id: id.into(),
85            history_length: None,
86            metadata: BTreeMap::new(),
87        }
88    }
89
90    /// Sets the history length.
91    #[must_use]
92    pub const fn with_history_length(mut self, length: u32) -> Self {
93        self.history_length = Some(length);
94        self
95    }
96}
97
98/// A typed `GetTask` response. The wire form
99/// is the [`Task`] object directly.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(transparent)]
102pub struct GetTaskResponse(pub Task);
103
104impl From<Task> for GetTaskResponse {
105    fn from(task: Task) -> Self {
106        Self(task)
107    }
108}
109
110impl std::ops::Deref for GetTaskResponse {
111    type Target = Task;
112
113    fn deref(&self) -> &Self::Target {
114        &self.0
115    }
116}
117
118/// A typed `CancelTask` request. Wire form:
119/// ```json
120/// { "id": "task-123", "metadata": { ... } }
121/// ```
122///
123/// `id` is required; `metadata` is optional.
124/// A request with only the id serialises to
125/// `{"id": "..."}` on the wire.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct CancelTaskRequest {
128    /// The task's id. Required.
129    pub id: String,
130    /// Per-request metadata.
131    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
132    pub metadata: BTreeMap<String, serde_json::Value>,
133}
134
135impl CancelTaskRequest {
136    /// Starts a new request with `id`.
137    #[must_use]
138    pub fn new(id: impl Into<String>) -> Self {
139        Self {
140            id: id.into(),
141            metadata: BTreeMap::new(),
142        }
143    }
144}
145
146/// A typed `CancelTask` response. The wire form
147/// is the [`Task`] object directly.
148///
149/// The task is whatever state it was in at the
150/// moment of cancellation — typically
151/// `Cancelled`,
152/// but possibly a terminal state if the task
153/// had already completed. The handler is
154/// responsible for setting the right state.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(transparent)]
157pub struct CancelTaskResponse(pub Task);
158
159impl From<Task> for CancelTaskResponse {
160    fn from(task: Task) -> Self {
161        Self(task)
162    }
163}
164
165impl std::ops::Deref for CancelTaskResponse {
166    type Target = Task;
167
168    fn deref(&self) -> &Self::Target {
169        &self.0
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{CancelTaskRequest, CancelTaskResponse, GetTaskRequest, GetTaskResponse};
176    use crate::domain::a2a::task::{Task, TaskState, TaskStatus};
177
178    type TestError = Box<dyn std::error::Error>;
179
180    // --- GetTaskRequest -----------------------------------------------
181
182    #[test]
183    fn get_task_request_serialises_with_id_only() -> Result<(), TestError> {
184        let request = GetTaskRequest::new("task-1");
185        let value = serde_json::to_value(&request)?;
186        let obj = value
187            .as_object()
188            .ok_or("a GetTaskRequest must serialise to a JSON object")?;
189        assert_eq!(
190            obj.get("id").and_then(serde_json::Value::as_str),
191            Some("task-1")
192        );
193        // The optional fields are absent on
194        // the wire when they are `None` /
195        // empty.
196        assert!(!obj.contains_key("historyLength"));
197        assert!(!obj.contains_key("metadata"));
198        Ok(())
199    }
200
201    #[test]
202    fn get_task_request_round_trips_with_history_length() -> Result<(), TestError> {
203        let request = GetTaskRequest::new("task-1").with_history_length(5);
204        let json = serde_json::to_string(&request)?;
205        let parsed: GetTaskRequest = serde_json::from_str(&json)?;
206        assert_eq!(parsed, request);
207        Ok(())
208    }
209
210    // --- GetTaskResponse ----------------------------------------------
211
212    #[test]
213    fn get_task_response_serialises_as_task_object() -> Result<(), TestError> {
214        let task = Task::new("task-1").with_status(TaskStatus {
215            state: TaskState::Working,
216            message: None,
217            timestamp: None,
218        });
219        let response = GetTaskResponse(task);
220        let value = serde_json::to_value(&response)?;
221        let obj = value
222            .as_object()
223            .ok_or("a GetTaskResponse must serialise to a JSON object")?;
224        // `#[serde(transparent)]` flattens the
225        // wrapper — the wire form is the
226        // inner Task, not a wrapping object.
227        assert_eq!(
228            obj.get("id").and_then(serde_json::Value::as_str),
229            Some("task-1")
230        );
231        assert!(obj.contains_key("status"));
232        Ok(())
233    }
234
235    #[test]
236    fn get_task_response_from_task_via_into_works() {
237        let task = Task::new("task-1");
238        let response: GetTaskResponse = task.clone().into();
239        assert_eq!(response.0, task);
240    }
241
242    // --- CancelTaskRequest --------------------------------------------
243
244    #[test]
245    fn cancel_task_request_serialises_with_id_only() -> Result<(), TestError> {
246        let request = CancelTaskRequest::new("task-1");
247        let value = serde_json::to_value(&request)?;
248        let obj = value
249            .as_object()
250            .ok_or("a CancelTaskRequest must serialise to a JSON object")?;
251        assert_eq!(
252            obj.get("id").and_then(serde_json::Value::as_str),
253            Some("task-1")
254        );
255        assert!(!obj.contains_key("metadata"));
256        Ok(())
257    }
258
259    #[test]
260    fn cancel_task_request_round_trips() -> Result<(), TestError> {
261        let request = CancelTaskRequest::new("task-1");
262        let json = serde_json::to_string(&request)?;
263        let parsed: CancelTaskRequest = serde_json::from_str(&json)?;
264        assert_eq!(parsed, request);
265        Ok(())
266    }
267
268    // --- CancelTaskResponse -------------------------------------------
269
270    #[test]
271    fn cancel_task_response_serialises_cancelled_state() -> Result<(), TestError> {
272        // The typical wire form: a task in
273        // `Cancelled` state. The handler
274        // is responsible for setting the
275        // right state; the response type
276        // just wraps the Task.
277        let task = Task::new("task-1").with_status(TaskStatus {
278            state: TaskState::Cancelled,
279            message: None,
280            timestamp: None,
281        });
282        let response = CancelTaskResponse(task);
283        let json = serde_json::to_string(&response)?;
284        let value: serde_json::Value = serde_json::from_str(&json)?;
285        let obj = value
286            .as_object()
287            .ok_or("a CancelTaskResponse must serialise to a JSON object")?;
288        let status = obj
289            .get("status")
290            .and_then(serde_json::Value::as_object)
291            .ok_or("`status` must be a JSON object")?;
292        assert_eq!(
293            status.get("state").and_then(serde_json::Value::as_str),
294            Some("cancelled")
295        );
296        Ok(())
297    }
298}