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 the status filter.
135    #[must_use]
136    pub const fn with_status(mut self, status: TaskState) -> Self {
137        self.status = Some(status);
138        self
139    }
140
141    /// Sets the page size.
142    #[must_use]
143    pub const fn with_page_size(mut self, page_size: u32) -> Self {
144        self.page_size = Some(page_size);
145        self
146    }
147
148    /// Sets the page token.
149    #[must_use]
150    pub fn with_page_token(mut self, page_token: impl Into<String>) -> Self {
151        self.page_token = Some(page_token.into());
152        self
153    }
154
155    /// Sets the history length.
156    #[must_use]
157    pub const fn with_history_length(mut self, length: u32) -> Self {
158        self.history_length = Some(length);
159        self
160    }
161
162    /// Sets the status timestamp filter.
163    #[must_use]
164    pub fn with_status_timestamp_after(mut self, timestamp: impl Into<String>) -> Self {
165        self.status_timestamp_after = Some(timestamp.into());
166        self
167    }
168}
169
170/// A typed `ListTasks` response. Wire form:
171/// ```json
172/// {
173///   "tasks": [Task, ...],
174///   "nextPageToken": "...",
175///   "pageSize": 50,
176///   "totalSize": 123
177/// }
178/// ```
179///
180/// `tasks` is required (but may be empty).
181/// `nextPageToken` is required: the spec
182/// uses an empty string to mean "no more
183/// pages", which is the spec's idiomatic
184/// way of expressing the absence. The Rust
185/// type mirrors that with a `String` (not
186/// an `Option<String>`).
187///
188/// `pageSize` and `totalSize` are also
189/// required. The spec names them as
190/// required fields.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct ListTasksResponse {
194    /// The tasks. An empty list is a valid
195    /// response (no tasks matched the
196    /// filters).
197    pub tasks: Vec<Task>,
198    /// A token to retrieve the next page of
199    /// results, or an empty string if there
200    /// are no more results. The spec
201    /// requires this field; an empty string
202    /// is the absence signal.
203    pub next_page_token: String,
204    /// The page size used for this
205    /// response. The spec requires this
206    /// field; a value of `0` is a malformed
207    /// response (the page size is at least
208    /// `1` per the spec).
209    pub page_size: u32,
210    /// The total number of tasks available
211    /// before pagination. The spec requires
212    /// this field.
213    pub total_size: u32,
214}
215
216impl ListTasksResponse {
217    /// Starts a new response with `tasks`.
218    /// Use [`with_next_page_token`](Self::with_next_page_token),
219    /// [`with_page_size`](Self::with_page_size),
220    /// and [`with_total_size`](Self::with_total_size)
221    /// to set the required metadata fields.
222    #[must_use]
223    pub fn new(tasks: impl IntoIterator<Item = Task>) -> Self {
224        Self {
225            tasks: tasks.into_iter().collect(),
226            next_page_token: String::new(),
227            page_size: 0,
228            total_size: 0,
229        }
230    }
231
232    /// Sets the next page token. An empty
233    /// string is the spec's "no more pages"
234    /// signal.
235    #[must_use]
236    pub fn with_next_page_token(mut self, next_page_token: impl Into<String>) -> Self {
237        self.next_page_token = next_page_token.into();
238        self
239    }
240
241    /// Sets the page size.
242    #[must_use]
243    pub const fn with_page_size(mut self, page_size: u32) -> Self {
244        self.page_size = page_size;
245        self
246    }
247
248    /// Sets the total size.
249    #[must_use]
250    pub const fn with_total_size(mut self, total_size: u32) -> Self {
251        self.total_size = total_size;
252        self
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::{ListTasksRequest, ListTasksResponse};
259    use crate::domain::a2a::task::{Task, TaskState};
260
261    type TestError = Box<dyn std::error::Error>;
262
263    // --- ListTasksRequest --------------------------------------------
264
265    #[test]
266    fn list_request_with_no_fields_is_an_empty_object() -> Result<(), TestError> {
267        // The default request serialises to
268        // `{}` on the wire — every field is
269        // `Option` and skipped when `None`,
270        // and `metadata` is empty.
271        let request = ListTasksRequest::default();
272        let value = serde_json::to_value(&request)?;
273        let obj = value
274            .as_object()
275            .ok_or("a ListTasksRequest must serialise to a JSON object")?;
276        assert!(obj.is_empty(), "expected `{{}}`, got {value}");
277        Ok(())
278    }
279
280    #[test]
281    fn list_request_round_trips_with_all_filters() -> Result<(), TestError> {
282        let request = ListTasksRequest::new()
283            .with_context_id("ctx-1")
284            .with_status(TaskState::Working)
285            .with_page_size(50)
286            .with_page_token("next")
287            .with_history_length(10)
288            .with_status_timestamp_after("2025-01-01T00:00:00Z");
289        let json = serde_json::to_string(&request)?;
290        let parsed: ListTasksRequest = serde_json::from_str(&json)?;
291        assert_eq!(parsed, request);
292        Ok(())
293    }
294
295    // --- ListTasksResponse -------------------------------------------
296
297    #[test]
298    fn list_response_with_no_next_page_uses_empty_string() -> Result<(), TestError> {
299        // The spec uses an empty string
300        // for `nextPageToken` to mean "no
301        // more pages". The field is
302        // required; the empty string is
303        // the absence signal.
304        let response = ListTasksResponse::new(Vec::<Task>::new());
305        let value = serde_json::to_value(&response)?;
306        let obj = value
307            .as_object()
308            .ok_or("a ListTasksResponse must serialise to a JSON object")?;
309        assert_eq!(
310            obj.get("nextPageToken").and_then(serde_json::Value::as_str),
311            Some("")
312        );
313        Ok(())
314    }
315
316    #[test]
317    fn list_response_round_trips_with_tasks_and_pagination() -> Result<(), TestError> {
318        let response = ListTasksResponse::new([Task::new("task-1"), Task::new("task-2")])
319            .with_next_page_token("next")
320            .with_page_size(50)
321            .with_total_size(123);
322        let json = serde_json::to_string(&response)?;
323        let parsed: ListTasksResponse = serde_json::from_str(&json)?;
324        assert_eq!(parsed, response);
325        Ok(())
326    }
327}