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}