Skip to main content

protocol_droid/domain/a2a/
list_tasks.rs

1//! A2A `ListTasks` typed envelopes.
2//!
3//! # What these are
4//!
5//! [`ListTasksRequest`] and
6//! [`ListTasksResponse`] are the typed shapes
7//! for the `ListTasks` JSON-RPC method. The
8//! method fetches a paginated list of tasks,
9//! optionally filtered by context id and
10//! status.
11//!
12//! # Why the response carries `page_size` and
13//! `total_size`
14//!
15//! The spec's wire form includes the page
16//! size used and the total number of tasks
17//! available (before pagination). A client
18//! that wants to know "how many pages?" can
19//! read the field directly instead of polling
20//! until the `nextPageToken` is empty.
21//!
22//! # Why `page_size` and `total_size` are
23//! `u32` (not `i32`)
24//!
25//! The spec's `int32` for these fields
26//! accepts negatives on the wire, but a
27//! negative page size or total size is
28//! nonsensical. The Rust type that
29//! refuses them is `u32`. A client that
30//! sends a negative value gets a
31//! deserialisation error, which the
32//! serving adapter surfaces as
33//! `A2aError::InvalidParams`.
34
35use std::collections::BTreeMap;
36
37use serde::{Deserialize, Serialize};
38
39use super::task::{Task, TaskState};
40
41/// A typed `ListTasks` request. Wire form:
42/// ```json
43/// {
44///   "contextId": "ctx-1",
45///   "status": "working",
46///   "pageSize": 50,
47///   "pageToken": "...",
48///   "historyLength": 10,
49///   "statusTimestampAfter": "2025-01-01T00:00:00Z",
50///   "metadata": { ... }
51/// }
52/// ```
53///
54/// All fields are optional. A request with
55/// no filters and no pagination serialises
56/// to `{}` on the wire.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct ListTasksRequest {
60    /// Filter tasks by context id. A
61    /// request with a context id is a
62    /// request for the tasks within that
63    /// context; a request with no context
64    /// id is a request across all contexts
65    /// (subject to the agent's policy).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub context_id: Option<String>,
68    /// Filter tasks by status. A request
69    /// with a status is a request for the
70    /// tasks in that state; a request with
71    /// no status is a request across all
72    /// states.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub status: Option<TaskState>,
75    /// The maximum number of tasks to
76    /// return. A request with no page size
77    /// lets the agent choose (the spec's
78    /// default is 50, capped at 100).
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub page_size: Option<u32>,
81    /// A page token received from a
82    /// previous `ListTasksResponse`. The
83    /// agent uses it to continue from the
84    /// last page.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub page_token: Option<String>,
87    /// The maximum number of history
88    /// messages to include in each task's
89    /// history. A request with no history
90    /// length lets the agent choose.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub history_length: Option<u32>,
93    /// Filter tasks whose status was
94    /// updated after the provided
95    /// timestamp. The format is ISO 8601
96    /// (e.g. `"2025-01-01T00:00:00Z"`).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub status_timestamp_after: Option<String>,
99    /// Per-request metadata.
100    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101    pub metadata: BTreeMap<String, serde_json::Value>,
102}
103
104impl ListTasksRequest {
105    /// Starts a new empty request. Use
106    /// [`with_context_id`](Self::with_context_id),
107    /// [`with_status`](Self::with_status),
108    /// [`with_page_size`](Self::with_page_size),
109    /// [`with_page_token`](Self::with_page_token),
110    /// [`with_history_length`](Self::with_history_length),
111    /// and
112    /// [`with_status_timestamp_after`](Self::with_status_timestamp_after)
113    /// to set the optional fields.
114    #[must_use]
115    pub const fn new() -> Self {
116        Self {
117            context_id: None,
118            status: None,
119            page_size: None,
120            page_token: None,
121            history_length: None,
122            status_timestamp_after: None,
123            metadata: BTreeMap::new(),
124        }
125    }
126
127    /// Sets the context id filter.
128    #[must_use]
129    pub fn with_context_id(mut self, context_id: impl Into<String>) -> Self {
130        self.context_id = Some(context_id.into());
131        self
132    }
133
134    /// Sets `status`.
135    ///
136    /// Absent from the request unless this method is called, which is a
137    /// request across all states.
138    #[must_use]
139    pub const fn with_status(mut self, status: TaskState) -> Self {
140        self.status = Some(status);
141        self
142    }
143
144    /// Sets `pageSize`.
145    ///
146    /// Absent from the request unless this method is called.
147    #[must_use]
148    pub const fn with_page_size(mut self, page_size: u32) -> Self {
149        self.page_size = Some(page_size);
150        self
151    }
152
153    /// Sets `pageToken`.
154    ///
155    /// Absent from the request unless this method is called, which is the
156    /// first page.
157    #[must_use]
158    pub fn with_page_token(mut self, page_token: impl Into<String>) -> Self {
159        self.page_token = Some(page_token.into());
160        self
161    }
162
163    /// Sets `historyLength`.
164    ///
165    /// Absent from the request unless this method is called.
166    #[must_use]
167    pub const fn with_history_length(mut self, length: u32) -> Self {
168        self.history_length = Some(length);
169        self
170    }
171
172    /// Sets the status timestamp filter.
173    #[must_use]
174    pub fn with_status_timestamp_after(mut self, timestamp: impl Into<String>) -> Self {
175        self.status_timestamp_after = Some(timestamp.into());
176        self
177    }
178}
179
180/// A typed `ListTasks` response. Wire form:
181/// ```json
182/// {
183///   "tasks": [Task, ...],
184///   "nextPageToken": "...",
185///   "pageSize": 50,
186///   "totalSize": 123
187/// }
188/// ```
189///
190/// `tasks` is required (but may be empty).
191/// `nextPageToken` is required: the spec
192/// uses an empty string to mean "no more
193/// pages", which is the spec's idiomatic
194/// way of expressing the absence. The Rust
195/// type mirrors that with a `String` (not
196/// an `Option<String>`).
197///
198/// `pageSize` and `totalSize` are also
199/// required. The spec names them as
200/// required fields.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct ListTasksResponse {
204    /// The tasks. An empty list is a valid
205    /// response (no tasks matched the
206    /// filters).
207    pub tasks: Vec<Task>,
208    /// A token to retrieve the next page of
209    /// results, or an empty string if there
210    /// are no more results. The spec
211    /// requires this field; an empty string
212    /// is the absence signal.
213    pub next_page_token: String,
214    /// The page size used for this
215    /// response. The spec requires this
216    /// field; a value of `0` is a malformed
217    /// response (the page size is at least
218    /// `1` per the spec).
219    pub page_size: u32,
220    /// The total number of tasks available
221    /// before pagination. The spec requires
222    /// this field.
223    pub total_size: u32,
224}
225
226impl ListTasksResponse {
227    /// Starts a new response with `tasks`.
228    /// Use [`with_next_page_token`](Self::with_next_page_token),
229    /// [`with_page_size`](Self::with_page_size),
230    /// and [`with_total_size`](Self::with_total_size)
231    /// to set the required metadata fields.
232    #[must_use]
233    pub fn new(tasks: impl IntoIterator<Item = Task>) -> Self {
234        Self {
235            tasks: tasks.into_iter().collect(),
236            next_page_token: String::new(),
237            page_size: 0,
238            total_size: 0,
239        }
240    }
241
242    /// Sets the next page token. An empty
243    /// string is the spec's "no more pages"
244    /// signal.
245    #[must_use]
246    pub fn with_next_page_token(mut self, next_page_token: impl Into<String>) -> Self {
247        self.next_page_token = next_page_token.into();
248        self
249    }
250
251    /// Sets `pageSize`.
252    ///
253    /// The spec requires this field, and a value of `0` is a malformed
254    /// response because the page size is at least `1`.
255    #[must_use]
256    pub const fn with_page_size(mut self, page_size: u32) -> Self {
257        self.page_size = page_size;
258        self
259    }
260
261    /// Sets `totalSize`.
262    ///
263    /// Absent from the request unless this method is called.
264    #[must_use]
265    pub const fn with_total_size(mut self, total_size: u32) -> Self {
266        self.total_size = total_size;
267        self
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::{ListTasksRequest, ListTasksResponse};
274    use crate::domain::a2a::task::{Task, TaskState};
275
276    type TestError = Box<dyn std::error::Error>;
277
278    // --- ListTasksRequest --------------------------------------------
279
280    #[test]
281    fn list_request_with_no_fields_is_an_empty_object() -> Result<(), TestError> {
282        // The default request serialises to
283        // `{}` on the wire — every field is
284        // `Option` and skipped when `None`,
285        // and `metadata` is empty.
286        let request = ListTasksRequest::default();
287        let value = serde_json::to_value(&request)?;
288        let obj = value
289            .as_object()
290            .ok_or("a ListTasksRequest must serialise to a JSON object")?;
291        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
292        Ok(())
293    }
294
295    #[test]
296    fn list_request_round_trips_with_all_filters() -> Result<(), TestError> {
297        let request = ListTasksRequest::new()
298            .with_context_id("ctx-1")
299            .with_status(TaskState::Working)
300            .with_page_size(50)
301            .with_page_token("next")
302            .with_history_length(10)
303            .with_status_timestamp_after("2025-01-01T00:00:00Z");
304        let json = serde_json::to_string(&request)?;
305        let parsed: ListTasksRequest = serde_json::from_str(&json)?;
306        assert_eq!(parsed, request);
307        Ok(())
308    }
309
310    // --- ListTasksResponse -------------------------------------------
311
312    #[test]
313    fn list_response_with_no_next_page_uses_empty_string() -> Result<(), TestError> {
314        // The spec uses an empty string
315        // for `nextPageToken` to mean "no
316        // more pages". The field is
317        // required; the empty string is
318        // the absence signal.
319        let response = ListTasksResponse::new(Vec::<Task>::new());
320        let value = serde_json::to_value(&response)?;
321        let obj = value
322            .as_object()
323            .ok_or("a ListTasksResponse must serialise to a JSON object")?;
324        assert_eq!(
325            obj.get("nextPageToken").and_then(serde_json::Value::as_str),
326            Some("")
327        );
328        Ok(())
329    }
330
331    #[test]
332    fn list_response_round_trips_with_tasks_and_pagination() -> Result<(), TestError> {
333        let response = ListTasksResponse::new([Task::new("task-1"), Task::new("task-2")])
334            .with_next_page_token("next")
335            .with_page_size(50)
336            .with_total_size(123);
337        let json = serde_json::to_string(&response)?;
338        let parsed: ListTasksResponse = serde_json::from_str(&json)?;
339        assert_eq!(parsed, response);
340        Ok(())
341    }
342}