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}