diff --git a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java index 24dd3648d..f7a652d60 100644 --- a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java +++ b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStore.java @@ -227,13 +227,29 @@ public ListTasksResult list(ListTasksParams params) { countQueryBuilder.append(" AND t.state = :state"); } - // Apply pagination cursor (tasks after pageToken) + // Apply lastUpdatedAfter filter using denormalized timestamp column + if (params.lastUpdatedAfter() != null) { + queryBuilder.append(" AND t.statusTimestamp > :lastUpdatedAfter"); + countQueryBuilder.append(" AND t.statusTimestamp > :lastUpdatedAfter"); + } + + // Apply pagination cursor using keyset pagination for composite sort (timestamp DESC, id ASC) + // PageToken format: "timestamp_millis:taskId" (e.g., "1699999999000:task-123") if (params.pageToken() != null && !params.pageToken().isEmpty()) { - queryBuilder.append(" AND t.id > :pageToken"); + String[] tokenParts = params.pageToken().split(":", 2); + if (tokenParts.length == 2) { + // Keyset pagination: get tasks where timestamp < tokenTimestamp OR (timestamp = tokenTimestamp AND id > tokenId) + // All tasks have timestamps (TaskStatus canonical constructor ensures this) + queryBuilder.append(" AND (t.statusTimestamp < :tokenTimestamp OR (t.statusTimestamp = :tokenTimestamp AND t.id > :tokenId))"); + } else { + // Legacy ID-only pageToken format is not supported with timestamp-based sorting + // Throw error to prevent incorrect pagination results + throw new io.a2a.spec.InvalidParamsError(null, "Invalid pageToken format: expected 'timestamp:id'", null); + } } - // Sort by task ID for consistent pagination - queryBuilder.append(" ORDER BY t.id"); + // Sort by status timestamp descending (most recent first), then by ID for stable ordering + queryBuilder.append(" ORDER BY t.statusTimestamp DESC, t.id ASC"); // Create and configure the main query TypedQuery query = em.createQuery(queryBuilder.toString(), JpaTask.class); @@ -245,8 +261,28 @@ public ListTasksResult list(ListTasksParams params) { if (params.status() != null) { query.setParameter("state", params.status().asString()); } + if (params.lastUpdatedAfter() != null) { + query.setParameter("lastUpdatedAfter", params.lastUpdatedAfter()); + } if (params.pageToken() != null && !params.pageToken().isEmpty()) { - query.setParameter("pageToken", params.pageToken()); + String[] tokenParts = params.pageToken().split(":", 2); + if (tokenParts.length == 2) { + // Parse keyset pagination parameters + try { + long timestampMillis = Long.parseLong(tokenParts[0]); + String tokenId = tokenParts[1]; + + // All tasks have timestamps (TaskStatus canonical constructor ensures this) + Instant tokenTimestamp = Instant.ofEpochMilli(timestampMillis); + query.setParameter("tokenTimestamp", tokenTimestamp); + query.setParameter("tokenId", tokenId); + } catch (NumberFormatException e) { + // Malformed timestamp in pageToken + throw new io.a2a.spec.InvalidParamsError(null, + "Invalid pageToken format: timestamp must be numeric milliseconds", null); + } + } + // Note: Legacy ID-only format already rejected in query building phase } // Apply page size limit (+1 to check for next page) @@ -270,6 +306,9 @@ public ListTasksResult list(ListTasksParams params) { if (params.status() != null) { countQuery.setParameter("state", params.status().asString()); } + if (params.lastUpdatedAfter() != null) { + countQuery.setParameter("lastUpdatedAfter", params.lastUpdatedAfter()); + } int totalSize = countQuery.getSingleResult().intValue(); // Deserialize tasks from JSON @@ -283,10 +322,14 @@ public ListTasksResult list(ListTasksParams params) { } } - // Determine next page token (ID of last task if there are more results) + // Determine next page token (timestamp:ID of last task if there are more results) + // Format: "timestamp_millis:taskId" for keyset pagination String nextPageToken = null; if (hasMore && !tasks.isEmpty()) { - nextPageToken = tasks.get(tasks.size() - 1).getId(); + Task lastTask = tasks.get(tasks.size() - 1); + // All tasks have timestamps (TaskStatus canonical constructor ensures this) + long timestampMillis = lastTask.getStatus().timestamp().toInstant().toEpochMilli(); + nextPageToken = timestampMillis + ":" + lastTask.getId(); } // Apply post-processing transformations (history limiting, artifact removal) diff --git a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaTask.java b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaTask.java index ebdb8f2a4..9f38dee41 100644 --- a/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaTask.java +++ b/extras/task-store-database-jpa/src/main/java/io/a2a/extras/taskstore/database/jpa/JpaTask.java @@ -25,6 +25,9 @@ public class JpaTask { @Column(name = "state") private String state; + @Column(name = "status_timestamp") + private Instant statusTimestamp; + @Column(name = "task_data", columnDefinition = "TEXT", nullable = false) private String taskJson; @@ -67,6 +70,14 @@ public void setState(String state) { this.state = state; } + public Instant getStatusTimestamp() { + return statusTimestamp; + } + + public void setStatusTimestamp(Instant statusTimestamp) { + this.statusTimestamp = statusTimestamp; + } + public String getTaskJson() { return taskJson; } @@ -123,7 +134,7 @@ static JpaTask createFromTask(Task task) throws JsonProcessingException { } /** - * Updates denormalized fields (contextId, state) from the task object. + * Updates denormalized fields (contextId, state, statusTimestamp) from the task object. * These fields are duplicated from the JSON to enable efficient querying. * * @param task the task to extract fields from @@ -133,8 +144,14 @@ private void updateDenormalizedFields(Task task) { if (task.getStatus() != null) { io.a2a.spec.TaskState taskState = task.getStatus().state(); this.state = (taskState != null) ? taskState.asString() : null; + // Extract status timestamp for efficient querying and sorting + // Truncate to milliseconds for keyset pagination consistency (pageToken uses millis) + this.statusTimestamp = (task.getStatus().timestamp() != null) + ? task.getStatus().timestamp().toInstant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS) + : null; } else { this.state = null; + this.statusTimestamp = null; } } diff --git a/extras/task-store-database-jpa/src/test/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStoreTest.java b/extras/task-store-database-jpa/src/test/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStoreTest.java index 792bf8c0f..a7cb8d79d 100644 --- a/extras/task-store-database-jpa/src/test/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStoreTest.java +++ b/extras/task-store-database-jpa/src/test/java/io/a2a/extras/taskstore/database/jpa/JpaDatabaseTaskStoreTest.java @@ -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 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,26 +692,72 @@ 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"); + } + } + + @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 @@ -600,7 +765,7 @@ public void testListTasksOrderingById() { 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(); diff --git a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java index 75c34f247..88f2eac61 100644 --- a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java @@ -128,6 +128,7 @@ public void listTasks(RoutingContext rc) { String pageSizeStr = rc.request().params().get("pageSize"); String pageToken = rc.request().params().get("pageToken"); String historyLengthStr = rc.request().params().get("historyLength"); + String lastUpdatedAfter = rc.request().params().get("lastUpdatedAfter"); String includeArtifactsStr = rc.request().params().get("includeArtifacts"); // Parse optional parameters @@ -143,11 +144,11 @@ public void listTasks(RoutingContext rc) { Boolean includeArtifacts = null; if (includeArtifactsStr != null && !includeArtifactsStr.isEmpty()) { - includeArtifacts = Boolean.parseBoolean(includeArtifactsStr); + includeArtifacts = Boolean.valueOf(includeArtifactsStr); } response = jsonRestHandler.listTasks(contextId, statusStr, pageSize, pageToken, - historyLength, includeArtifacts, context); + historyLength, lastUpdatedAfter, includeArtifacts, context); } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("Invalid number format in parameters")); } catch (IllegalArgumentException e) { diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java index 56efc70ce..0671ce883 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java @@ -5,8 +5,11 @@ import static io.a2a.server.util.async.AsyncUtils.processor; import static java.util.concurrent.TimeUnit.*; +import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -43,6 +46,7 @@ import io.a2a.spec.EventKind; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.InternalError; +import io.a2a.spec.InvalidParamsError; import io.a2a.spec.JSONRPCError; import io.a2a.spec.ListTaskPushNotificationConfigParams; import io.a2a.spec.ListTasksParams; @@ -165,8 +169,21 @@ private static Task limitTaskHistory(Task task, int historyLength) { @Override public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) throws JSONRPCError { - LOGGER.debug("onListTasks with contextId={}, status={}, pageSize={}, pageToken={}", - params.contextId(), params.status(), params.pageSize(), params.pageToken()); + LOGGER.debug("onListTasks with contextId={}, status={}, pageSize={}, pageToken={}, lastUpdatedAfter={}", + params.contextId(), params.status(), params.pageSize(), params.pageToken(), params.lastUpdatedAfter()); + + // Validate lastUpdatedAfter timestamp if provided + if (params.lastUpdatedAfter() != null) { + // Check if timestamp is in the future (optional validation per spec) + Instant now = Instant.now(); + if (params.lastUpdatedAfter().isAfter(now)) { + Map errorData = new HashMap<>(); + errorData.put("parameter", "lastUpdatedAfter"); + errorData.put("reason", "Timestamp cannot be in the future"); + throw new InvalidParamsError(null, "Invalid params", errorData); + } + } + ListTasksResult result = taskStore.list(params); LOGGER.debug("Found {} tasks (total: {})", result.pageSize(), result.totalSize()); return result; diff --git a/server-common/src/main/java/io/a2a/server/tasks/InMemoryTaskStore.java b/server-common/src/main/java/io/a2a/server/tasks/InMemoryTaskStore.java index 0fd604089..ba50c6cd6 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/InMemoryTaskStore.java +++ b/server-common/src/main/java/io/a2a/server/tasks/InMemoryTaskStore.java @@ -38,23 +38,22 @@ public void delete(String taskId) { @Override public ListTasksResult list(ListTasksParams params) { - Stream taskStream = tasks.values().stream(); - - // Apply filters - if (params.contextId() != null) { - taskStream = taskStream.filter(task -> params.contextId().equals(task.getContextId())); - } - if (params.status() != null) { - taskStream = taskStream.filter(task -> - task.getStatus() != null && params.status().equals(task.getStatus().state()) - ); - } - // Note: lastUpdatedAfter filtering not implemented in InMemoryTaskStore - // as Task doesn't have a lastUpdated timestamp field - - // Sort by task ID for consistent pagination - List allFilteredTasks = taskStream - .sorted(Comparator.comparing(Task::getId)) + // Filter and sort tasks in a single stream pipeline + List allFilteredTasks = tasks.values().stream() + .filter(task -> params.contextId() == null || params.contextId().equals(task.getContextId())) + .filter(task -> params.status() == null || + (task.getStatus() != null && params.status().equals(task.getStatus().state()))) + .filter(task -> params.lastUpdatedAfter() == null || + (task.getStatus() != null && + task.getStatus().timestamp() != null && + task.getStatus().timestamp().toInstant().isAfter(params.lastUpdatedAfter()))) + .sorted(Comparator.comparing( + (Task t) -> (t.getStatus() != null && t.getStatus().timestamp() != null) + // Truncate to milliseconds for consistency with pageToken precision + ? t.getStatus().timestamp().toInstant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS) + : null, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Task::getId)) .toList(); int totalSize = allFilteredTasks.size(); @@ -63,31 +62,63 @@ public ListTasksResult list(ListTasksParams params) { int pageSize = params.getEffectivePageSize(); int startIndex = 0; - // Handle page token (simple cursor: last task ID from previous page) + // Handle page token using keyset pagination (format: "timestamp_millis:taskId") + // Use binary search to efficiently find the first task after the pageToken position (O(log N)) if (params.pageToken() != null && !params.pageToken().isEmpty()) { - // Use binary search since list is sorted by task ID (O(log N) vs O(N)) - int index = Collections.binarySearch(allFilteredTasks, null, - (t1, t2) -> { - // Handle null key comparisons (binarySearch passes null as one argument) - if (t1 == null && t2 == null) return 0; - if (t1 == null) return params.pageToken().compareTo(t2.getId()); - if (t2 == null) return t1.getId().compareTo(params.pageToken()); - return t1.getId().compareTo(t2.getId()); - }); - if (index >= 0) { - startIndex = index + 1; + String[] tokenParts = params.pageToken().split(":", 2); + if (tokenParts.length == 2) { + try { + long tokenTimestampMillis = Long.parseLong(tokenParts[0]); + java.time.Instant tokenTimestamp = java.time.Instant.ofEpochMilli(tokenTimestampMillis); + String tokenId = tokenParts[1]; + + // Binary search for first task where: timestamp < tokenTimestamp OR (timestamp == tokenTimestamp AND id > tokenId) + // Since list is sorted (timestamp DESC, id ASC), we search for the insertion point + int left = 0; + int right = allFilteredTasks.size(); + + while (left < right) { + int mid = left + (right - left) / 2; + Task task = allFilteredTasks.get(mid); + + // All tasks have timestamps (TaskStatus canonical constructor ensures this) + // Truncate to milliseconds for consistency with pageToken precision + java.time.Instant taskTimestamp = task.getStatus().timestamp().toInstant() + .truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + int timestampCompare = taskTimestamp.compareTo(tokenTimestamp); + + if (timestampCompare < 0 || (timestampCompare == 0 && task.getId().compareTo(tokenId) > 0)) { + // This task is after the token, search left half + right = mid; + } else { + // This task is before or equal to token, search right half + left = mid + 1; + } + } + startIndex = left; + } catch (NumberFormatException e) { + // Malformed timestamp in pageToken + throw new io.a2a.spec.InvalidParamsError(null, + "Invalid pageToken format: timestamp must be numeric milliseconds", null); + } + } else { + // Legacy ID-only pageToken format is not supported with timestamp-based sorting + // Throw error to prevent incorrect pagination results + throw new io.a2a.spec.InvalidParamsError(null, "Invalid pageToken format: expected 'timestamp:id'", null); } - // If not found (index < 0), startIndex remains 0 (start from beginning) } // Get the page of tasks int endIndex = Math.min(startIndex + pageSize, allFilteredTasks.size()); List pageTasks = allFilteredTasks.subList(startIndex, endIndex); - // Determine next page token + // Determine next page token (format: "timestamp_millis:taskId") String nextPageToken = null; if (endIndex < allFilteredTasks.size()) { - nextPageToken = allFilteredTasks.get(endIndex - 1).getId(); + Task lastTask = allFilteredTasks.get(endIndex - 1); + // All tasks have timestamps (TaskStatus canonical constructor ensures this) + long timestampMillis = lastTask.getStatus().timestamp().toInstant().toEpochMilli(); + nextPageToken = timestampMillis + ":" + lastTask.getId(); } // Transform tasks: limit history and optionally remove artifacts diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index 398a85710..105831262 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -11,7 +11,11 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.Flow; import io.a2a.server.PublicAgentCard; @@ -182,7 +186,8 @@ public HTTPRestResponse getTask(String taskId, int historyLength, ServerCallCont public HTTPRestResponse listTasks(@Nullable String contextId, @Nullable String status, @Nullable Integer pageSize, @Nullable String pageToken, - @Nullable Integer historyLength, @Nullable Boolean includeArtifacts, + @Nullable Integer historyLength, @Nullable String lastUpdatedAfter, + @Nullable Boolean includeArtifacts, ServerCallContext context) { try { // Build params @@ -202,6 +207,16 @@ public HTTPRestResponse listTasks(@Nullable String contextId, @Nullable String s if (historyLength != null) { paramsBuilder.historyLength(historyLength); } + if (lastUpdatedAfter != null) { + try { + paramsBuilder.lastUpdatedAfter(Instant.parse(lastUpdatedAfter)); + } catch (DateTimeParseException e) { + Map errorData = new HashMap<>(); + errorData.put("parameter", "lastUpdatedAfter"); + errorData.put("reason", "Must be valid ISO-8601 timestamp"); + throw new InvalidParamsError(null, "Invalid params", errorData); + } + } if (includeArtifacts != null) { paramsBuilder.includeArtifacts(includeArtifacts); }