-
Notifications
You must be signed in to change notification settings - Fork 122
feat: update tasks/list implementation to match A2A 0.4.0 spec #459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import static org.junit.jupiter.api.Assertions.assertNull; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import java.time.OffsetDateTime; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
|
|
@@ -418,12 +419,14 @@ public void testListTasksCombinedFilters() { | |
| @Test | ||
| @Transactional | ||
| public void testListTasksPagination() { | ||
| // Create 5 tasks | ||
| // Create 5 tasks with same timestamp to ensure ID-based pagination works | ||
| // (With timestamp DESC sorting, same timestamps allow ID ASC tie-breaking) | ||
| OffsetDateTime sameTimestamp = OffsetDateTime.now(java.time.ZoneOffset.UTC); | ||
| for (int i = 1; i <= 5; i++) { | ||
| Task task = new Task.Builder() | ||
| .id("task-page-" + i) | ||
| .contextId("context-pagination") | ||
| .status(new TaskStatus(TaskState.SUBMITTED)) | ||
| .status(new TaskStatus(TaskState.SUBMITTED, null, sameTimestamp)) | ||
| .build(); | ||
| taskStore.save(task); | ||
| } | ||
|
|
@@ -465,6 +468,122 @@ public void testListTasksPagination() { | |
| assertNull(result3.nextPageToken(), "Last page should have no next page token"); | ||
| } | ||
|
|
||
| @Test | ||
| @Transactional | ||
| public void testListTasksPaginationWithDifferentTimestamps() { | ||
| // Create tasks with different timestamps to verify keyset pagination | ||
| // with composite sort (timestamp DESC, id ASC) | ||
| OffsetDateTime now = OffsetDateTime.now(java.time.ZoneOffset.UTC); | ||
|
|
||
| // Task 1: 10 minutes ago, ID="task-diff-a" | ||
| Task task1 = new Task.Builder() | ||
| .id("task-diff-a") | ||
| .contextId("context-diff-timestamps") | ||
| .status(new TaskStatus(TaskState.WORKING, null, now.minusMinutes(10))) | ||
| .build(); | ||
| taskStore.save(task1); | ||
|
|
||
| // Task 2: 5 minutes ago, ID="task-diff-b" | ||
| Task task2 = new Task.Builder() | ||
| .id("task-diff-b") | ||
| .contextId("context-diff-timestamps") | ||
| .status(new TaskStatus(TaskState.WORKING, null, now.minusMinutes(5))) | ||
| .build(); | ||
| taskStore.save(task2); | ||
|
|
||
| // Task 3: 5 minutes ago, ID="task-diff-c" (same timestamp as task2, tests ID tie-breaker) | ||
| Task task3 = new Task.Builder() | ||
| .id("task-diff-c") | ||
| .contextId("context-diff-timestamps") | ||
| .status(new TaskStatus(TaskState.WORKING, null, now.minusMinutes(5))) | ||
| .build(); | ||
| taskStore.save(task3); | ||
|
|
||
| // Task 4: Now, ID="task-diff-d" | ||
| Task task4 = new Task.Builder() | ||
| .id("task-diff-d") | ||
| .contextId("context-diff-timestamps") | ||
| .status(new TaskStatus(TaskState.WORKING, null, now)) | ||
| .build(); | ||
| taskStore.save(task4); | ||
|
|
||
| // Task 5: 1 minute ago, ID="task-diff-e" | ||
| Task task5 = new Task.Builder() | ||
| .id("task-diff-e") | ||
| .contextId("context-diff-timestamps") | ||
| .status(new TaskStatus(TaskState.WORKING, null, now.minusMinutes(1))) | ||
| .build(); | ||
| taskStore.save(task5); | ||
|
|
||
| // Expected order (timestamp DESC, id ASC): | ||
| // 1. task-diff-d (now) | ||
| // 2. task-diff-e (1 min ago) | ||
| // 3. task-diff-b (5 min ago, ID 'b') | ||
| // 4. task-diff-c (5 min ago, ID 'c') | ||
| // 5. task-diff-a (10 min ago) | ||
|
|
||
| // Page 1: Get first 2 tasks | ||
| ListTasksParams params1 = new ListTasksParams.Builder() | ||
| .contextId("context-diff-timestamps") | ||
| .pageSize(2) | ||
| .build(); | ||
| ListTasksResult result1 = taskStore.list(params1); | ||
|
|
||
| assertEquals(5, result1.totalSize()); | ||
| assertEquals(2, result1.pageSize()); | ||
| assertNotNull(result1.nextPageToken(), "Should have next page token"); | ||
|
|
||
| // Verify first page order | ||
| assertEquals("task-diff-d", result1.tasks().get(0).getId(), "First task should be most recent"); | ||
| assertEquals("task-diff-e", result1.tasks().get(1).getId(), "Second task should be 1 min ago"); | ||
|
|
||
| // Verify pageToken format: "timestamp_millis:taskId" | ||
| assertTrue(result1.nextPageToken().contains(":"), "PageToken should have format timestamp:id"); | ||
| String[] tokenParts = result1.nextPageToken().split(":", 2); | ||
| assertEquals(2, tokenParts.length, "PageToken should have exactly 2 parts"); | ||
| assertEquals("task-diff-e", tokenParts[1], "PageToken should contain last task ID"); | ||
|
|
||
| // Page 2: Get next 2 tasks | ||
| ListTasksParams params2 = new ListTasksParams.Builder() | ||
| .contextId("context-diff-timestamps") | ||
| .pageSize(2) | ||
| .pageToken(result1.nextPageToken()) | ||
| .build(); | ||
| ListTasksResult result2 = taskStore.list(params2); | ||
|
|
||
| assertEquals(5, result2.totalSize()); | ||
| assertEquals(2, result2.pageSize()); | ||
| assertNotNull(result2.nextPageToken(), "Should have next page token"); | ||
|
|
||
| // Verify second page order (tasks with same timestamp, sorted by ID) | ||
| assertEquals("task-diff-b", result2.tasks().get(0).getId(), "Third task should be 5 min ago, ID 'b'"); | ||
| assertEquals("task-diff-c", result2.tasks().get(1).getId(), "Fourth task should be 5 min ago, ID 'c'"); | ||
|
|
||
| // Page 3: Get last task | ||
| ListTasksParams params3 = new ListTasksParams.Builder() | ||
| .contextId("context-diff-timestamps") | ||
| .pageSize(2) | ||
| .pageToken(result2.nextPageToken()) | ||
| .build(); | ||
| ListTasksResult result3 = taskStore.list(params3); | ||
|
|
||
| assertEquals(5, result3.totalSize()); | ||
| assertEquals(1, result3.pageSize()); | ||
| assertNull(result3.nextPageToken(), "Last page should have no next page token"); | ||
|
|
||
| // Verify last task | ||
| assertEquals("task-diff-a", result3.tasks().get(0).getId(), "Last task should be oldest"); | ||
|
|
||
| // Verify no duplicates across all pages | ||
| List<String> allTaskIds = new ArrayList<>(); | ||
| allTaskIds.addAll(result1.tasks().stream().map(Task::getId).toList()); | ||
| allTaskIds.addAll(result2.tasks().stream().map(Task::getId).toList()); | ||
| allTaskIds.addAll(result3.tasks().stream().map(Task::getId).toList()); | ||
|
|
||
| assertEquals(5, allTaskIds.size(), "Should have exactly 5 tasks across all pages"); | ||
| assertEquals(5, allTaskIds.stream().distinct().count(), "Should have no duplicate tasks"); | ||
| } | ||
|
|
||
| @Test | ||
| @Transactional | ||
| public void testListTasksHistoryLimiting() { | ||
|
|
@@ -573,34 +692,80 @@ public void testListTasksDefaultPageSize() { | |
| assertNotNull(result.nextPageToken(), "Should have next page"); | ||
| } | ||
|
|
||
| @Test | ||
| @Transactional | ||
| public void testListTasksInvalidPageTokenFormat() { | ||
| // Create a task | ||
| Task task = new Task.Builder() | ||
| .id("task-invalid-token") | ||
| .contextId("context-invalid-token") | ||
| .status(new TaskStatus(TaskState.WORKING)) | ||
| .build(); | ||
| taskStore.save(task); | ||
|
|
||
| // Test 1: Legacy ID-only pageToken should throw InvalidParamsError | ||
| ListTasksParams params1 = new ListTasksParams.Builder() | ||
| .contextId("context-invalid-token") | ||
| .pageToken("task-invalid-token") // ID-only format (legacy) | ||
| .build(); | ||
|
|
||
| try { | ||
| taskStore.list(params1); | ||
| throw new AssertionError("Expected InvalidParamsError for legacy ID-only pageToken"); | ||
| } catch (io.a2a.spec.InvalidParamsError e) { | ||
| // Expected - legacy format not supported | ||
| assertTrue(e.getMessage().contains("Invalid pageToken format"), | ||
| "Error message should mention invalid format"); | ||
| } | ||
|
|
||
| // Test 2: Malformed timestamp in pageToken should throw InvalidParamsError | ||
| ListTasksParams params2 = new ListTasksParams.Builder() | ||
| .contextId("context-invalid-token") | ||
| .pageToken("not-a-number:task-id") // Invalid timestamp | ||
| .build(); | ||
|
|
||
| try { | ||
| taskStore.list(params2); | ||
| throw new AssertionError("Expected InvalidParamsError for malformed timestamp"); | ||
| } catch (io.a2a.spec.InvalidParamsError e) { | ||
| // Expected - malformed timestamp | ||
| assertTrue(e.getMessage().contains("timestamp must be numeric"), | ||
| "Error message should mention numeric timestamp requirement"); | ||
| } | ||
| } | ||
|
Comment on lines
+697
to
+735
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test uses public void testListTasksInvalidPageTokenFormat() {
// Create a task
Task task = new Task.Builder()
.id("task-invalid-token")
.contextId("context-invalid-token")
.status(new TaskStatus(TaskState.WORKING))
.build();
taskStore.save(task);
// Test 1: Legacy ID-only pageToken should throw InvalidParamsError
ListTasksParams params1 = new ListTasksParams.Builder()
.contextId("context-invalid-token")
.pageToken("task-invalid-token") // ID-only format (legacy)
.build();
var e1 = assertThrows(io.a2a.spec.InvalidParamsError.class, () -> taskStore.list(params1));
assertTrue(e1.getMessage().contains("Invalid pageToken format"),
"Error message should mention invalid format");
// Test 2: Malformed timestamp in pageToken should throw InvalidParamsError
ListTasksParams params2 = new ListTasksParams.Builder()
.contextId("context-invalid-token")
.pageToken("not-a-number:task-id") // Invalid timestamp
.build();
var e2 = assertThrows(io.a2a.spec.InvalidParamsError.class, () -> taskStore.list(params2));
assertTrue(e2.getMessage().contains("timestamp must be numeric"),
"Error message should mention numeric timestamp requirement");
} |
||
|
|
||
|
|
||
| @Test | ||
| @Transactional | ||
| public void testListTasksOrderingById() { | ||
| // Create tasks with IDs that will sort in specific order | ||
| // Create tasks with same timestamp to test ID-based tie-breaking | ||
| // (spec requires sorting by timestamp DESC, then ID ASC) | ||
| OffsetDateTime sameTimestamp = OffsetDateTime.now(java.time.ZoneOffset.UTC); | ||
|
|
||
| Task task1 = new Task.Builder() | ||
| .id("task-order-a") | ||
| .contextId("context-order") | ||
| .status(new TaskStatus(TaskState.SUBMITTED)) | ||
| .status(new TaskStatus(TaskState.SUBMITTED, null, sameTimestamp)) | ||
| .build(); | ||
|
|
||
| Task task2 = new Task.Builder() | ||
| .id("task-order-b") | ||
| .contextId("context-order") | ||
| .status(new TaskStatus(TaskState.SUBMITTED)) | ||
| .status(new TaskStatus(TaskState.SUBMITTED, null, sameTimestamp)) | ||
| .build(); | ||
|
|
||
| Task task3 = new Task.Builder() | ||
| .id("task-order-c") | ||
| .contextId("context-order") | ||
| .status(new TaskStatus(TaskState.SUBMITTED)) | ||
| .status(new TaskStatus(TaskState.SUBMITTED, null, sameTimestamp)) | ||
| .build(); | ||
|
|
||
| // Save in reverse order | ||
| taskStore.save(task3); | ||
| taskStore.save(task1); | ||
| taskStore.save(task2); | ||
|
|
||
| // List should return in ID order | ||
| // List should return sorted by timestamp DESC (all same), then by ID ASC | ||
| ListTasksParams params = new ListTasksParams.Builder() | ||
| .contextId("context-order") | ||
| .build(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic to parse and validate the
pageTokenis duplicated. It's first handled here to build the query string, and then again in lines 267-286 to set the query parameters. This redundancy can lead to maintainability issues and potential inconsistencies if one part is modified but the other is not.Consider refactoring to parse the token once at the beginning of the method, store the parts, and reuse them. Here's an example of how you could structure it: