diff --git a/src/constants.ts b/src/constants.ts index d84b820e..65935508 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,4 +5,9 @@ /** * The well-known path for the agent card */ -export const AGENT_CARD_PATH = ".well-known/agent-card.json"; \ No newline at end of file +export const AGENT_CARD_PATH = ".well-known/agent-card.json"; + +/** + * Default value of page size if undefined + */ +export const DEFAULT_PAGE_SIZE = 50; \ No newline at end of file diff --git a/src/server/request_handler/a2a_request_handler.ts b/src/server/request_handler/a2a_request_handler.ts index e7c4cba9..0353bac9 100644 --- a/src/server/request_handler/a2a_request_handler.ts +++ b/src/server/request_handler/a2a_request_handler.ts @@ -11,6 +11,8 @@ import { GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, DeleteTaskPushNotificationConfigParams, + ListTasksParams, + ListTasksResult, } from "../../types.js"; export interface A2ARequestHandler { @@ -61,4 +63,9 @@ export interface A2ARequestHandler { void, undefined >; + + listTasks( + params: ListTasksParams + ): Promise; + } \ No newline at end of file diff --git a/src/server/request_handler/default_request_handler.ts b/src/server/request_handler/default_request_handler.ts index 7070032c..843f89a0 100644 --- a/src/server/request_handler/default_request_handler.ts +++ b/src/server/request_handler/default_request_handler.ts @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs -import { Message, AgentCard, PushNotificationConfig, Task, MessageSendParams, TaskState, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig, DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams } from "../../types.js"; +import { Message, AgentCard, PushNotificationConfig, Task, MessageSendParams, TaskState, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig, DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, ListTasksParams, ListTasksResult } from "../../types.js"; import { AgentExecutor } from "../agent_execution/agent_executor.js"; import { RequestContext } from "../agent_execution/request_context.js"; import { A2AError } from "../error.js"; @@ -13,6 +13,8 @@ import { A2ARequestHandler } from "./a2a_request_handler.js"; import { InMemoryPushNotificationStore, PushNotificationStore } from '../push_notification/push_notification_store.js'; import { PushNotificationSender } from '../push_notification/push_notification_sender.js'; import { DefaultPushNotificationSender } from '../push_notification/default_push_notification_sender.js'; +import { DEFAULT_PAGE_SIZE } from '../../constants.js'; +import { isValidUnixTimestampMs } from '../utils.js'; const terminalStates: TaskState[] = ["completed", "failed", "canceled", "rejected"]; @@ -338,6 +340,15 @@ export class DefaultRequestHandler implements A2ARequestHandler { return task; } + async listTasks( + params: ListTasksParams + ): Promise { + if (!this.paramsTasksListAreValid(params)) { + throw A2AError.invalidParams(`Invalid method parameters.`); + } + return await this.taskStore.list(params); + } + async cancelTask(params: TaskIdParams): Promise { const task = await this.taskStore.load(params.id); if (!task) { @@ -566,4 +577,25 @@ export class DefaultRequestHandler implements A2ARequestHandler { // Send push notification in the background. this.pushNotificationSender?.send(task); } + + // Check if the params for the TasksList function are valid + private paramsTasksListAreValid(params: ListTasksParams): boolean { + if(params.pageSize !== undefined && (params.pageSize > 100 || params.pageSize < 1)) { + return false; + } + if(params.pageToken !== undefined && Buffer.from(params.pageToken, 'base64').toString('base64') !== params.pageToken){ + return false; + } + if(params.historyLength !== undefined && params.historyLength<0){ + return false; + } + if(params.lastUpdatedAfter !== undefined && !isValidUnixTimestampMs(params.lastUpdatedAfter)){ + return false; + } + const terminalStates: string[] = ["completed", "failed", "canceled", "rejected"]; + if(params.status !== undefined && !terminalStates.includes(params.status)){ + return false; + } + return true; + } } diff --git a/src/server/store.ts b/src/server/store.ts index 6177604c..c7471c54 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -1,12 +1,13 @@ -import fs from "fs/promises"; +import fs, { lutimes } from "fs/promises"; import path from "path"; -import {Task} from "../types.js"; +import {ListTasksParams, ListTasksResult, Task} from "../types.js"; import { A2AError } from "./error.js"; import { getCurrentTimestamp, isArtifactUpdate, isTaskStatusUpdate, } from "./utils.js"; +import { DEFAULT_PAGE_SIZE } from "../constants.js"; /** * Simplified interface for task storage providers. @@ -27,6 +28,15 @@ export interface TaskStore { * @returns A promise resolving to an object containing the Task, or undefined if not found. */ load(taskId: string): Promise; + + /** + * Retrieves a paginated and filtered list of tasks from the store. + * + * @param params An object containing criteria for filtering, sorting, and pagination. + * @returns A promise resolving to a `ListTasksResult` object, which includes the filtered and paginated tasks, + * the total number of tasks matching the criteria, the actual page size, and a token for the next page (if available). + */ + list(params: ListTasksParams): Promise; } // ======================== @@ -47,4 +57,67 @@ export class InMemoryTaskStore implements TaskStore { // Store copies to prevent internal mutation if caller reuses objects this.store.set(task.id, {...task}); } + + async list(params: ListTasksParams): Promise { + // Returns the list of saved tasks + const lastUpdatedAfterDate = params.lastUpdatedAfter ? new Date(params.lastUpdatedAfter) : undefined; + const pageTokenDate = params.pageToken ? new Date(Buffer.from(params.pageToken, 'base64').toString('utf-8')) : undefined; + const filteredTasks = Array.from(this.store.values()) + // Apply filters + .filter(task => !params.contextId || task.contextId === params.contextId) + .filter(task => !params.status || task.status.state === params.status) + .filter(task => { + if (!params.lastUpdatedAfter) return true; + if (!task.status.timestamp) return false; // Tasks without timestamp don't match 'lastUpdatedAfter' + return new Date(task.status.timestamp) > lastUpdatedAfterDate; + }) + .filter(task => { + if (!params.pageToken) return true; + if (!task.status.timestamp) return false; // Tasks without timestamp don't match 'pageToken' + // pageToken is a timestamp, so we want tasks older than the pageToken + return new Date(task.status.timestamp) < pageTokenDate; + }) + + // Sort by timestamp in descending order (most recently updated tasks first) + filteredTasks.sort((t1, t2) => { + const ts1 = t1.status.timestamp ? new Date(t1.status.timestamp).getTime() : 0; + const ts2 = t2.status.timestamp ? new Date(t2.status.timestamp).getTime() : 0; + return ts2 - ts1; + }); + + // Apply pagination + let paginatedTasks = filteredTasks.slice(0, params.pageSize ?? DEFAULT_PAGE_SIZE); + let nextPageToken = ''; + if (filteredTasks.length > paginatedTasks.length) { + const lastTaskOnPage = paginatedTasks[paginatedTasks.length - 1]; + if (lastTaskOnPage && lastTaskOnPage.status.timestamp) { + nextPageToken = lastTaskOnPage.status.timestamp; + } + } + + paginatedTasks = paginatedTasks.map(task => { + // Make a copy of the tasks to avoid modification of original stored object + const newTask = { + ...task, + history: task.history ? [...task.history] : undefined, + artifacts: task.artifacts ? [...task.artifacts] : undefined, + }; + + const historyLength = params.historyLength ?? 0; + newTask.history = historyLength > 0 ? newTask.history?.slice(-historyLength) : []; + + if (!params.includeArtifacts && newTask.artifacts){ + newTask.artifacts = []; + } + return newTask; + }) + + return { + tasks: paginatedTasks, + totalSize: filteredTasks.length, + pageSize: paginatedTasks.length, + nextPageToken: Buffer.from(nextPageToken).toString('base64'), // Convert to base64 + }; + } + } diff --git a/src/server/transports/jsonrpc_transport_handler.ts b/src/server/transports/jsonrpc_transport_handler.ts index 95b5f4c8..b2857809 100644 --- a/src/server/transports/jsonrpc_transport_handler.ts +++ b/src/server/transports/jsonrpc_transport_handler.ts @@ -1,4 +1,4 @@ -import { JSONRPCErrorResponse, MessageSendParams, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig, A2ARequest, JSONRPCResponse, DeleteTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams } from "../../types.js"; +import { JSONRPCErrorResponse, MessageSendParams, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig, A2ARequest, JSONRPCResponse, DeleteTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, ListTasksParams, TaskState } from "../../types.js"; import { A2AError } from "../error.js"; import { A2ARequestHandler } from "../request_handler/a2a_request_handler.js"; @@ -101,6 +101,9 @@ export class JsonRpcTransportHandler { case 'tasks/get': result = await this.requestHandler.getTask(rpcRequest.params); break; + case 'tasks/list': + result = await this.requestHandler.listTasks(rpcRequest.params); + break; case 'tasks/cancel': result = await this.requestHandler.cancelTask(rpcRequest.params); break; diff --git a/src/server/utils.ts b/src/server/utils.ts index d39946a5..344d903a 100644 --- a/src/server/utils.ts +++ b/src/server/utils.ts @@ -38,3 +38,19 @@ export function isArtifactUpdate( // Check if it has 'parts' return isObject(update) && "parts" in update; } + +/** + * Checks if a given number is a valid Unix timestamp in milliseconds. + * A valid timestamp is a positive integer representing milliseconds since the Unix epoch. + * @param timestamp The number to validate. + * @returns True if the number is a valid Unix timestamp in milliseconds, false otherwise. + */ +export function isValidUnixTimestampMs(timestamp: number): boolean { + if (typeof timestamp !== 'number' || !Number.isInteger(timestamp)) { + return false; + } + if (timestamp <= 0) { + return false; + } + return true; +} diff --git a/src/types.ts b/src/types.ts index bbcf065a..b963cc49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export type A2ARequest = | SendMessageRequest | SendStreamingMessageRequest | GetTaskRequest + | ListTasksRequest | CancelTaskRequest | SetTaskPushNotificationConfigRequest | GetTaskPushNotificationConfigRequest @@ -112,6 +113,7 @@ export type JSONRPCResponse = | SendStreamingMessageSuccessResponse | GetTaskSuccessResponse | CancelTaskSuccessResponse + | ListTasksSuccessResponse | SetTaskPushNotificationConfigSuccessResponse | GetTaskPushNotificationConfigSuccessResponse | ListTaskPushNotificationConfigSuccessResponse @@ -126,6 +128,13 @@ export type JSONRPCResponse = export type ListTaskPushNotificationConfigResponse = | JSONRPCErrorResponse | ListTaskPushNotificationConfigSuccessResponse; +/** + * JSON-RPC response for the 'tasks/list' method. + * + * This interface was referenced by `MySchema`'s JSON-Schema + * via the `definition` "ListTasksResponse". + */ +export type ListTasksResponse = JSONRPCErrorResponse | ListTasksSuccessResponse; /** * Represents a JSON-RPC response for the `message/send` method. * @@ -510,7 +519,7 @@ export interface MessageSendConfiguration { export interface PushNotificationConfig { authentication?: PushNotificationAuthenticationInfo; /** - * A unique ID for the push notification configuration, set by the client + * A unique identifier (e.g. UUID) for the push notification configuration, set by the client * to support multiple notification callbacks. */ id?: string; @@ -541,7 +550,7 @@ export interface PushNotificationAuthenticationInfo { */ export interface Message { /** - * The context identifier for this message, used to group related interactions. + * The context ID for this message, used to group related interactions. */ contextId?: string; /** @@ -576,7 +585,7 @@ export interface Message { */ role: "agent" | "user"; /** - * The identifier of the task this message is part of. Can be omitted for the first message of a new task. + * The ID of the task this message is part of. Can be omitted for the first message of a new task. */ taskId?: string; } @@ -753,7 +762,7 @@ export interface TaskQueryParams { */ historyLength?: number; /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -763,6 +772,76 @@ export interface TaskQueryParams { [k: string]: unknown; }; } +/** + * JSON-RPC request model for the 'tasks/list' method. + * + * This interface was referenced by `MySchema`'s JSON-Schema + * via the `definition` "ListTasksRequest". + */ +export interface ListTasksRequest { + /** + * A unique identifier established by the client. It must be a String, a Number, or null. + * The server must reply with the same value in the response. This property is omitted for notifications. + */ + id: string | number; + /** + * The version of the JSON-RPC protocol. MUST be exactly "2.0". + */ + jsonrpc: "2.0"; + /** + * A String containing the name of the method to be invoked. + */ + method: "tasks/list"; + params?: ListTasksParams; +} +/** + * A Structured value that holds the parameter values to be used during the invocation of the method. + */ +export interface ListTasksParams { + /** + * Filter tasks by context ID to get tasks from a specific conversation or session. + */ + contextId?: string; + /** + * Number of recent messages to include in each task's history. Must be non-negative. Defaults to 0 if not specified. + */ + historyLength?: number; + /** + * Whether to include artifacts in the returned tasks. Defaults to false to reduce payload size. + */ + includeArtifacts?: boolean; + /** + * Filter tasks updated after this timestamp (milliseconds since epoch). Only tasks with a last updated time greater than or equal to this value will be returned. + */ + lastUpdatedAfter?: number; + /** + * Request-specific metadata. + */ + metadata?: { + [k: string]: unknown; + }; + /** + * Maximum number of tasks to return. Must be between 1 and 100. Defaults to 50 if not specified. + */ + pageSize?: number; + /** + * Token for pagination. Use the nextPageToken from a previous ListTasksResult response. + */ + pageToken?: string; + /** + * Filter tasks by their current status state. + */ + status?: + | "submitted" + | "working" + | "input-required" + | "completed" + | "canceled" + | "failed" + | "rejected" + | "auth-required" + | "unknown"; +} /** * Represents a JSON-RPC request for the `tasks/cancel` method. * @@ -789,7 +868,7 @@ export interface CancelTaskRequest { */ export interface TaskIdParams { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -826,7 +905,7 @@ export interface SetTaskPushNotificationConfigRequest { export interface TaskPushNotificationConfig { pushNotificationConfig: PushNotificationConfig1; /** - * The ID of the task. + * The unique identifier (e.g. UUID) of the task. */ taskId: string; } @@ -836,7 +915,7 @@ export interface TaskPushNotificationConfig { export interface PushNotificationConfig1 { authentication?: PushNotificationAuthenticationInfo; /** - * A unique ID for the push notification configuration, set by the client + * A unique identifier (e.g. UUID) for the push notification configuration, set by the client * to support multiple notification callbacks. */ id?: string; @@ -881,7 +960,7 @@ export interface GetTaskPushNotificationConfigRequest { */ export interface TaskIdParams1 { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -899,7 +978,7 @@ export interface TaskIdParams1 { */ export interface GetTaskPushNotificationConfigParams { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -939,7 +1018,7 @@ export interface TaskResubscriptionRequest { */ export interface TaskIdParams2 { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -975,7 +1054,7 @@ export interface ListTaskPushNotificationConfigRequest { */ export interface ListTaskPushNotificationConfigParams { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -1011,7 +1090,7 @@ export interface DeleteTaskPushNotificationConfigRequest { */ export interface DeleteTaskPushNotificationConfigParams { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -1552,7 +1631,7 @@ export interface AgentProvider1 { */ export interface Artifact { /** - * A unique identifier for the artifact within the scope of the task. + * A unique identifier (e.g. UUID) for the artifact within the scope of the task. */ artifactId: string; /** @@ -1690,7 +1769,7 @@ export interface Task { */ artifacts?: Artifact[]; /** - * A server-generated identifier for maintaining context across multiple related tasks or interactions. + * A server-generated unique identifier (e.g. UUID) for maintaining context across multiple related tasks or interactions. */ contextId: string; /** @@ -1698,7 +1777,7 @@ export interface Task { */ history?: Message1[]; /** - * A unique identifier for the task, generated by the server for a new task. + * A unique identifier (e.g. UUID) for the task, generated by the server for a new task. */ id: string; /** @@ -1721,7 +1800,7 @@ export interface Task { */ export interface Message1 { /** - * The context identifier for this message, used to group related interactions. + * The context ID for this message, used to group related interactions. */ contextId?: string; /** @@ -1756,7 +1835,7 @@ export interface Message1 { */ role: "agent" | "user"; /** - * The identifier of the task this message is part of. Can be omitted for the first message of a new task. + * The ID of the task this message is part of. Can be omitted for the first message of a new task. */ taskId?: string; } @@ -1788,7 +1867,7 @@ export interface TaskStatus { */ export interface Message2 { /** - * The context identifier for this message, used to group related interactions. + * The context ID for this message, used to group related interactions. */ contextId?: string; /** @@ -1823,7 +1902,7 @@ export interface Message2 { */ role: "agent" | "user"; /** - * The identifier of the task this message is part of. Can be omitted for the first message of a new task. + * The ID of the task this message is part of. Can be omitted for the first message of a new task. */ taskId?: string; } @@ -1858,7 +1937,7 @@ export interface ClientCredentialsOAuthFlow1 { */ export interface DeleteTaskPushNotificationConfigParams1 { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -2048,7 +2127,7 @@ export interface GetTaskPushNotificationConfigSuccessResponse { export interface TaskPushNotificationConfig1 { pushNotificationConfig: PushNotificationConfig1; /** - * The ID of the task. + * The unique identifier (e.g. UUID) of the task. */ taskId: string; } @@ -2078,7 +2157,7 @@ export interface Task1 { */ artifacts?: Artifact[]; /** - * A server-generated identifier for maintaining context across multiple related tasks or interactions. + * A server-generated unique identifier (e.g. UUID) for maintaining context across multiple related tasks or interactions. */ contextId: string; /** @@ -2086,7 +2165,7 @@ export interface Task1 { */ history?: Message1[]; /** - * A unique identifier for the task, generated by the server for a new task. + * A unique identifier (e.g. UUID) for the task, generated by the server for a new task. */ id: string; /** @@ -2200,7 +2279,7 @@ export interface Task2 { */ artifacts?: Artifact[]; /** - * A server-generated identifier for maintaining context across multiple related tasks or interactions. + * A server-generated unique identifier (e.g. UUID) for maintaining context across multiple related tasks or interactions. */ contextId: string; /** @@ -2208,7 +2287,7 @@ export interface Task2 { */ history?: Message1[]; /** - * A unique identifier for the task, generated by the server for a new task. + * A unique identifier (e.g. UUID) for the task, generated by the server for a new task. */ id: string; /** @@ -2340,7 +2419,7 @@ export interface TaskArtifactUpdateEvent { */ export interface Artifact1 { /** - * A unique identifier for the artifact within the scope of the task. + * A unique identifier (e.g. UUID) for the artifact within the scope of the task. */ artifactId: string; /** @@ -2366,6 +2445,44 @@ export interface Artifact1 { */ parts: Part[]; } +/** + * JSON-RPC success response model for the 'tasks/list' method. + * + * This interface was referenced by `MySchema`'s JSON-Schema + * via the `definition` "ListTasksSuccessResponse". + */ +export interface ListTasksSuccessResponse { + /** + * The identifier established by the client. + */ + id: string | number | null; + /** + * The version of the JSON-RPC protocol. MUST be exactly "2.0". + */ + jsonrpc: "2.0"; + result: ListTasksResult; +} +/** + * The result object on success. + */ +export interface ListTasksResult { + /** + * Token for retrieving the next page. Empty string if no more results. + */ + nextPageToken: string; + /** + * Maximum number of tasks returned in this response. + */ + pageSize: number; + /** + * Array of tasks matching the specified criteria. + */ + tasks: Task2[]; + /** + * Total number of tasks available (before pagination). + */ + totalSize: number; +} /** * Represents a successful JSON-RPC response for the `tasks/pushNotificationConfig/set` method. * @@ -2389,7 +2506,7 @@ export interface SetTaskPushNotificationConfigSuccessResponse { export interface TaskPushNotificationConfig2 { pushNotificationConfig: PushNotificationConfig1; /** - * The ID of the task. + * The unique identifier (e.g. UUID) of the task. */ taskId: string; } @@ -2422,7 +2539,7 @@ export interface ListTaskPushNotificationConfigSuccessResponse { export interface TaskPushNotificationConfig3 { pushNotificationConfig: PushNotificationConfig1; /** - * The ID of the task. + * The unique identifier (e.g. UUID) of the task. */ taskId: string; } @@ -2456,7 +2573,7 @@ export interface JSONRPCSuccessResponse { */ export interface ListTaskPushNotificationConfigParams1 { /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** @@ -2466,6 +2583,81 @@ export interface ListTaskPushNotificationConfigParams1 { [k: string]: unknown; }; } +/** + * Parameters for listing tasks with optional filtering criteria. + * + * This interface was referenced by `MySchema`'s JSON-Schema + * via the `definition` "ListTasksParams". + */ +export interface ListTasksParams1 { + /** + * Filter tasks by context ID to get tasks from a specific conversation or session. + */ + contextId?: string; + /** + * Number of recent messages to include in each task's history. Must be non-negative. Defaults to 0 if not specified. + */ + historyLength?: number; + /** + * Whether to include artifacts in the returned tasks. Defaults to false to reduce payload size. + */ + includeArtifacts?: boolean; + /** + * Filter tasks updated after this timestamp (milliseconds since epoch). Only tasks with a last updated time greater than or equal to this value will be returned. + */ + lastUpdatedAfter?: number; + /** + * Request-specific metadata. + */ + metadata?: { + [k: string]: unknown; + }; + /** + * Maximum number of tasks to return. Must be between 1 and 100. Defaults to 50 if not specified. + */ + pageSize?: number; + /** + * Token for pagination. Use the nextPageToken from a previous ListTasksResult response. + */ + pageToken?: string; + /** + * Filter tasks by their current status state. + */ + status?: + | "submitted" + | "working" + | "input-required" + | "completed" + | "canceled" + | "failed" + | "rejected" + | "auth-required" + | "unknown"; +} +/** + * Result object for tasks/list method containing an array of tasks and pagination information. + * + * This interface was referenced by `MySchema`'s JSON-Schema + * via the `definition` "ListTasksResult". + */ +export interface ListTasksResult1 { + /** + * Token for retrieving the next page. Empty string if no more results. + */ + nextPageToken: string; + /** + * Maximum number of tasks returned in this response. + */ + pageSize: number; + /** + * Array of tasks matching the specified criteria. + */ + tasks: Task2[]; + /** + * Total number of tasks available (before pagination). + */ + totalSize: number; +} /** * Defines configuration options for a `message/send` or `message/stream` request. * @@ -2578,7 +2770,7 @@ export interface PushNotificationAuthenticationInfo1 { export interface PushNotificationConfig2 { authentication?: PushNotificationAuthenticationInfo; /** - * A unique ID for the push notification configuration, set by the client + * A unique identifier (e.g. UUID) for the push notification configuration, set by the client * to support multiple notification callbacks. */ id?: string; @@ -2615,7 +2807,7 @@ export interface TaskQueryParams1 { */ historyLength?: number; /** - * The unique identifier of the task. + * The unique identifier (e.g. UUID) of the task. */ id: string; /** diff --git a/test/server/default_request_handler.spec.ts b/test/server/default_request_handler.spec.ts index d28d41ec..06f3df06 100644 --- a/test/server/default_request_handler.spec.ts +++ b/test/server/default_request_handler.spec.ts @@ -4,7 +4,7 @@ import sinon, { SinonStub, SinonFakeTimers } from 'sinon'; import { AgentExecutor } from '../../src/server/agent_execution/agent_executor.js'; import { RequestContext, ExecutionEventBus, TaskStore, InMemoryTaskStore, DefaultRequestHandler, ExecutionEventQueue, A2AError, InMemoryPushNotificationStore, PushNotificationStore, PushNotificationSender } from '../../src/server/index.js'; -import { AgentCard, Artifact, DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, Message, MessageSendParams, PushNotificationConfig, Task, TaskIdParams, TaskPushNotificationConfig, TaskState, TaskStatusUpdateEvent } from '../../src/index.js'; +import { AgentCard, Artifact, DeleteTaskPushNotificationConfigParams, GetTaskPushNotificationConfigParams, ListTaskPushNotificationConfigParams, ListTasksParams, Message, MessageSendParams, PushNotificationConfig, Task, TaskIdParams, TaskPushNotificationConfig, TaskState, TaskStatusUpdateEvent } from '../../src/index.js'; import { DefaultExecutionEventBusManager, ExecutionEventBusManager } from '../../src/server/events/execution_event_bus_manager.js'; import { A2ARequestHandler } from '../../src/server/request_handler/a2a_request_handler.js'; import { MockAgentExecutor, CancellableMockAgentExecutor, fakeTaskExecute, FailingCancellableMockAgentExecutor } from './mocks/agent-executor.mock.js'; @@ -1229,4 +1229,408 @@ describe('DefaultRequestHandler as A2ARequestHandler', () => { const queue = new ExecutionEventQueue(fakeBus); expect(queue).to.be.instanceOf(ExecutionEventQueue); }); + + describe('validation of tasks/list api', () => { + describe('tasks/list params validation', () => { + let listTasksStub: SinonStub; + + beforeEach(() => { + // Stub the actual list method on the mockTaskStore + listTasksStub = sinon.stub(mockTaskStore, 'list').resolves({ + tasks: [], + totalSize: 0, + pageSize: 0, + nextPageToken: '' + }); + }); + + afterEach(() => { + listTasksStub.restore(); // Restore the stub after each test + }); + + // Test cases for pageSize + it('should return an invalid params error for pageSize less than 1', async () => { + const params: ListTasksParams = { pageSize: -1 }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should return an invalid params error for pageSize greater than 100', async () => { + const params: ListTasksParams = { pageSize: 101 }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should allow valid pageSize (1 to 100)', async () => { + const params: ListTasksParams = { pageSize: 50 }; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Test cases for pageToken + it('should return an invalid params error for invalid base64 pageToken', async () => { + const params: ListTasksParams = { pageToken: 'not-base64!' }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should allow valid base64 pageToken', async () => { + const validBase64Token = Buffer.from('some-timestamp').toString('base64'); + const params: ListTasksParams = { pageToken: validBase64Token }; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Test cases for historyLength + it('should return an invalid params error for negative historyLength', async () => { + const params: ListTasksParams = { historyLength: -1 }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should allow valid non-negative historyLength', async () => { + const params: ListTasksParams = { historyLength: 0 }; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Test cases for lastUpdatedAfter + it('should return an invalid params error for invalid lastUpdatedAfter (negative)', async () => { + const params: ListTasksParams = { lastUpdatedAfter: -1000 }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should return an invalid params error for invalid lastUpdatedAfter (float)', async () => { + const params: ListTasksParams = { lastUpdatedAfter: 12345.67 }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should return an invalid params error for invalid lastUpdatedAfter (non-number)', async () => { + const params: ListTasksParams = { lastUpdatedAfter: 'not-a-number' as any }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should allow valid lastUpdatedAfter (positive integer)', async () => { + const params: ListTasksParams = { lastUpdatedAfter: 1678886400000 }; // March 15, 2023 12:00:00 AM UTC + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Test cases for status + it('should return an invalid params error for invalid status string', async () => { + const params: ListTasksParams = { status: 'running' as any }; + let catchedError: A2AError | undefined; + try { + await handler.listTasks(params); + } catch (error: any) { + catchedError = error; + } finally { + expect(catchedError).to.be.instanceOf(A2AError); + expect(catchedError.code).to.equal(-32602); // Invalid Params + expect(catchedError.message).to.equal('Invalid method parameters.'); + expect(listTasksStub.notCalled).to.be.true; + } + }); + + it('should allow valid status (e.g., "completed")', async () => { + const params: ListTasksParams = { status: 'completed' }; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Combined valid case + it('should allow a valid tasks/list request with multiple valid parameters', async () => { + const validBase64Token = Buffer.from('another-timestamp').toString('base64'); + const params: ListTasksParams = { pageSize: 20, pageToken: validBase64Token, historyLength: 5, lastUpdatedAfter: 1678886400000, status: 'failed' }; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + + // Valid case with no params + it('should allow a valid tasks/list request with no parameters', async () => { + const params: ListTasksParams = {}; + await handler.listTasks(params); + expect(listTasksStub.calledOnceWith(params)).to.be.true; + }); + }); + + describe('tasks/list core method validation', () => { + const task1: Task = { + id: 'task-1', contextId: 'ctx-a', kind: 'task', + status: { state: 'completed', timestamp: '2023-01-01T10:00:00.000Z' }, + history: [{ messageId: 'msg-1', role: 'user', parts: [{ kind: 'text', text: 'h1' }], kind: 'message' }], + artifacts: [{ artifactId: 'art-1', parts: [{ kind: 'text', text: 'a1' }] }] + }; + const task2: Task = { + id: 'task-2', contextId: 'ctx-a', kind: 'task', + status: { state: 'working', timestamp: '2023-01-01T11:00:00.000Z' }, + history: [{ messageId: 'msg-2', role: 'user', parts: [{ kind: 'text', text: 'h2' }], kind: 'message' }], + artifacts: [{ artifactId: 'art-2', parts: [{ kind: 'text', text: 'a2' }] }] + }; + const task3: Task = { + id: 'task-3', contextId: 'ctx-b', kind: 'task', + status: { state: 'failed', timestamp: '2023-01-01T12:00:00.000Z' }, + history: [{ messageId: 'msg-3', role: 'user', parts: [{ kind: 'text', text: 'h3' }], kind: 'message' }], + artifacts: [{ artifactId: 'art-3', parts: [{ kind: 'text', text: 'a3' }] }] + }; + const task4: Task = { + id: 'task-4', contextId: 'ctx-a', kind: 'task', + status: { state: 'completed', timestamp: '2023-01-01T13:00:00.000Z' }, + history: [{ messageId: 'msg-4', role: 'user', parts: [{ kind: 'text', text: 'h4' }], kind: 'message' }], + artifacts: [{ artifactId: 'art-4', parts: [{ kind: 'text', text: 'a4' }] }] + }; + const task5: Task = { // Task with no timestamp for testing edge cases + id: 'task-5', contextId: 'ctx-c', kind: 'task', + status: { state: 'working' }, // No timestamp + history: [{ messageId: 'msg-5', role: 'user', parts: [{ kind: 'text', text: 'h5' }], kind: 'message' }], + artifacts: [{ artifactId: 'art-5', parts: [{ kind: 'text', text: 'a5' }] }] + }; + + beforeEach(async () => { + // Re-initialize mockTaskStore and handler for each test + mockTaskStore = new InMemoryTaskStore(); + handler = new DefaultRequestHandler( + testAgentCard, + mockTaskStore, + mockAgentExecutor, + executionEventBusManager, + ); + + // Populate the store with test tasks + await mockTaskStore.save(task1); + await mockTaskStore.save(task2); + await mockTaskStore.save(task3); + await mockTaskStore.save(task4); + await mockTaskStore.save(task5); // Add task with no timestamp + }); + + it('should return all tasks sorted by timestamp descending when no parameters are provided', async () => { + const params: ListTasksParams = {}; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(5); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-3', 'task-2', 'task-1', 'task-5']); + expect(result.totalSize).to.equal(5); + expect(result.pageSize).to.equal(5); + expect(result.nextPageToken).to.be.empty; + }); + + it('should filter tasks by contextId', async () => { + const params: ListTasksParams = { contextId: 'ctx-a' }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(3); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-2', 'task-1']); + expect(result.totalSize).to.equal(3); + }); + + it('should filter tasks by status', async () => { + const params: ListTasksParams = { status: 'completed' }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(2); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-1']); + expect(result.totalSize).to.equal(2); + }); + + it('should filter tasks by lastUpdatedAfter', async () => { + const timestamp = new Date('2023-01-01T11:30:00.000Z').getTime(); // Between task2 and task3 + const params: ListTasksParams = { lastUpdatedAfter: timestamp }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(2); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-3']); + expect(result.totalSize).to.equal(2); + }); + + it('should filter tasks by pageToken', async () => { + // pageToken is base64 encoded timestamp, so we want tasks *older* than this timestamp + const pageTokenTimestamp = '2023-01-01T12:00:00.000Z'; // task3's timestamp + const encodedPageToken = Buffer.from(pageTokenTimestamp).toString('base64'); + const params: ListTasksParams = { pageToken: encodedPageToken }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(2); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-2', 'task-1']); // Should be tasks older than task3 + expect(result.totalSize).to.equal(2); + }); + + it('should apply pageSize limit and generate nextPageToken', async () => { + const params: ListTasksParams = { pageSize: 2 }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(2); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-3']); + expect(result.totalSize).to.equal(5); // Total tasks in store + expect(result.pageSize).to.equal(2); + const expectedNextPageToken = Buffer.from(task3.status.timestamp!).toString('base64'); + expect(result.nextPageToken).to.equal(expectedNextPageToken); + }); + + it('should return empty nextPageToken if all tasks fit on one page', async () => { + const params: ListTasksParams = { pageSize: 5 }; // Exactly all tasks + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(5); + expect(result.nextPageToken).to.be.empty; + }); + + it('should limit historyLength in returned tasks', async () => { + const params: ListTasksParams = { historyLength: 0 }; + const result = await handler.listTasks(params); + expect(result.tasks[0].history).to.have.lengthOf(0); + + const params2: ListTasksParams = { historyLength: 1 }; + const result2 = await handler.listTasks(params2); + expect(result2.tasks[0].history).to.have.lengthOf(1); + }); + + it('should remove artifacts if includeArtifacts is false', async () => { + const params: ListTasksParams = { includeArtifacts: false }; + const result = await handler.listTasks(params); + + expect(result.tasks[0].artifacts).to.have.lengthOf(0); + }); + + it('should include artifacts if includeArtifacts is true', async () => { + const params: ListTasksParams = { includeArtifacts: true }; + const result = await handler.listTasks(params); + + expect(result.tasks[0].artifacts).to.have.lengthOf(1); + expect(result.tasks[0].artifacts![0].artifactId).to.equal('art-4'); + }); + + it('should handle combined filters and pagination', async () => { + const params: ListTasksParams = { + contextId: 'ctx-a', + status: 'completed', + lastUpdatedAfter: new Date('2023-01-01T09:00:00.000Z').getTime(), // All ctx-a completed tasks are after this + pageSize: 1, + historyLength: 0, + includeArtifacts: false, + }; + const result = await handler.listTasks(params); + + expect(result.tasks).to.have.lengthOf(1); + expect(result.tasks[0].id).to.equal('task-4'); + expect(result.tasks[0].history).to.have.lengthOf(0); + expect(result.tasks[0].artifacts).to.have.lengthOf(0); + expect(result.totalSize).to.equal(2); // task-4 and task-1 match filters + expect(result.pageSize).to.equal(1); + const expectedNextPageToken = Buffer.from(task4.status.timestamp!).toString('base64'); + expect(result.nextPageToken).to.equal(expectedNextPageToken); + }); + + it('should handle empty task store', async () => { + mockTaskStore = new InMemoryTaskStore(); // Empty store + handler = new DefaultRequestHandler( + testAgentCard, + mockTaskStore, + mockAgentExecutor, + executionEventBusManager, + ); + const params: ListTasksParams = {}; + const result = await handler.listTasks(params); + + expect(result.tasks).to.be.empty; + expect(result.totalSize).to.equal(0); + expect(result.pageSize).to.equal(0); + expect(result.nextPageToken).to.be.empty; + }); + + it('should handle tasks with no timestamp when filtering by lastUpdatedAfter', async () => { + const params: ListTasksParams = { lastUpdatedAfter: new Date('2023-01-01T09:00:00.000Z').getTime() }; + const result = await handler.listTasks(params); + // task5 has no timestamp, so it should be excluded from results when lastUpdatedAfter is present + expect(result.tasks.map(t => t.id)).to.not.include('task-5'); + expect(result.tasks).to.have.lengthOf(4); + }); + + it('should handle tasks with no timestamp when filtering by pageToken', async () => { + const pageTokenTimestamp = '2023-01-01T12:00:00.000Z'; + const encodedPageToken = Buffer.from(pageTokenTimestamp).toString('base64'); + const params: ListTasksParams = { pageToken: encodedPageToken }; + const result = await handler.listTasks(params); + // task5 has no timestamp, so it should be excluded from results when pageToken is present + expect(result.tasks.map(t => t.id)).to.not.include('task-5'); + expect(result.tasks).to.have.lengthOf(2); + }); + + it('should handle tasks with no timestamp when no timestamp-based filters are present', async () => { + // If no timestamp-based filters, task5 should be included, but its sort position is undefined + // The current sort logic `ts2 - ts1` will treat `undefined` timestamps as `0`. + // So task5 will appear at the end of the sorted list. + const params: ListTasksParams = { pageSize: 5 }; // Get all tasks + const result = await handler.listTasks(params); + expect(result.tasks).to.have.lengthOf(5); + expect(result.tasks.map(t => t.id)).to.deep.equal(['task-4', 'task-3', 'task-2', 'task-1', 'task-5']); + }); + }); + }); }); diff --git a/test/server/jsonrpc_transport_handler.spec.ts b/test/server/jsonrpc_transport_handler.spec.ts index ac6ca1b4..d462da36 100644 --- a/test/server/jsonrpc_transport_handler.spec.ts +++ b/test/server/jsonrpc_transport_handler.spec.ts @@ -22,6 +22,7 @@ describe('JsonRpcTransportHandler', () => { getTaskPushNotificationConfig: sinon.stub(), listTaskPushNotificationConfigs: sinon.stub(), deleteTaskPushNotificationConfig: sinon.stub(), + listTasks: sinon.stub(), resubscribe: sinon.stub(), }; transportHandler = new JsonRpcTransportHandler(mockRequestHandler);