diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapper.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapper.java index cf0327569..fb9ae7bff 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapper.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapper.java @@ -15,6 +15,10 @@ */ package io.agentscope.core.tool.mcp; +import io.agentscope.core.tool.mcp.task.DefaultTaskManager; +import io.agentscope.core.tool.mcp.task.ListTasksResult; +import io.agentscope.core.tool.mcp.task.Task; +import io.agentscope.core.tool.mcp.task.TaskManager; import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; @@ -28,37 +32,66 @@ * This implementation delegates to {@link McpAsyncClient} and provides * reactive operations that return Mono types. * - *

Example usage: + *

+ * Task Support Status: + *

+ * + *

+ * Example usage: + * *

{@code
  * McpAsyncClient client = ... // created via McpClient.async()
  * McpAsyncClientWrapper wrapper = new McpAsyncClientWrapper("my-mcp", client);
  * wrapper.initialize()
  *     .then(wrapper.callTool("tool_name", Map.of("arg1", "value1")))
  *     .subscribe(result -> System.out.println(result));
+ *
+ * // Task manager is available but operations will throw UnsupportedOperationException
+ * TaskManager taskManager = wrapper.getTaskManager();
+ * // taskManager.getTask("task-id").subscribe(...); // Will throw UnsupportedOperationException
  * }
+ * + * @see McpClientWrapper#getTaskManager() + * @see io.agentscope.core.tool.mcp.task.TaskManager */ public class McpAsyncClientWrapper extends McpClientWrapper { private static final Logger logger = LoggerFactory.getLogger(McpAsyncClientWrapper.class); private final McpAsyncClient client; + private final TaskManager taskManager; /** * Constructs a new asynchronous MCP client wrapper. * - * @param name unique identifier for this client + * @param name unique identifier for this client * @param client the underlying async MCP client */ public McpAsyncClientWrapper(String name, McpAsyncClient client) { super(name); this.client = client; + this.taskManager = createTaskManager(); } /** * Initializes the async MCP client connection and caches available tools. * - *

This method connects to the MCP server, discovers available tools, and caches them for - * later use. If already initialized, this method returns immediately without re-initializing. + *

+ * This method connects to the MCP server, discovers available tools, and caches + * them for + * later use. If already initialized, this method returns immediately without + * re-initializing. * * @return a Mono that completes when initialization is finished */ @@ -95,7 +128,9 @@ public Mono initialize() { /** * Lists all tools available from the MCP server. * - *

This method queries the MCP server for its current list of tools. The client must be + *

+ * This method queries the MCP server for its current list of tools. The client + * must be * initialized before calling this method. * * @return a Mono emitting the list of available tools @@ -114,10 +149,12 @@ public Mono> listTools() { /** * Invokes a tool on the MCP server asynchronously. * - *

This method sends a tool call request to the MCP server and returns the result + *

+ * This method sends a tool call request to the MCP server and returns the + * result * asynchronously. The client must be initialized before calling this method. * - * @param toolName the name of the tool to call + * @param toolName the name of the tool to call * @param arguments the arguments to pass to the tool * @return a Mono emitting the tool call result (may contain error information) * @throws IllegalStateException if the client is not initialized @@ -153,11 +190,73 @@ public Mono callTool(String toolName, Map + * This method creates a DefaultTaskManager with task operations that delegate + * to the underlying MCP client. Note that task support depends on the MCP SDK + * version and server capabilities. + * + * @return a new TaskManager instance + */ + private TaskManager createTaskManager() { + DefaultTaskManager.TaskOperations operations = + new DefaultTaskManager.TaskOperations() { + @Override + public Mono getTask(String taskId) { + // TODO: Implement when MCP SDK supports tasks/get + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono getTaskResult(String taskId) { + // TODO: Implement when MCP SDK supports tasks/result + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono listTasks(String cursor) { + // TODO: Implement when MCP SDK supports tasks/list + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono cancelTask(String taskId) { + // TODO: Implement when MCP SDK supports tasks/cancel + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + }; + + return new DefaultTaskManager(name, operations); + } + /** * Closes the MCP client connection and releases all resources. * - *

This method attempts to close the client gracefully, falling back to forceful closure if - * graceful closure fails. This method is idempotent and can be called multiple times safely. + *

+ * This method attempts to close the client gracefully, falling back to forceful + * closure if + * graceful closure fails. This method is idempotent and can be called multiple + * times safely. */ @Override public void close() { diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientWrapper.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientWrapper.java index 449deda74..7292930e5 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientWrapper.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientWrapper.java @@ -15,6 +15,7 @@ */ package io.agentscope.core.tool.mcp; +import io.agentscope.core.tool.mcp.task.TaskManager; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; import java.util.Map; @@ -24,14 +25,17 @@ /** * Abstract wrapper for MCP (Model Context Protocol) clients. * This class manages the lifecycle of MCP client connections and provides - * a unified interface for both asynchronous and synchronous client implementations. + * a unified interface for both asynchronous and synchronous client + * implementations. * - *

The wrapper handles: + *

+ * The wrapper handles: *

    - *
  • Client initialization and connection management
  • - *
  • Tool discovery and caching
  • - *
  • Tool invocation through the MCP protocol
  • - *
  • Resource cleanup on close
  • + *
  • Client initialization and connection management
  • + *
  • Tool discovery and caching
  • + *
  • Tool invocation through the MCP protocol
  • + *
  • Task management for long-running operations
  • + *
  • Resource cleanup on close
  • *
* * @see McpAsyncClientWrapper @@ -94,7 +98,7 @@ public boolean isInitialized() { /** * Invokes a tool on the MCP server. * - * @param toolName the name of the tool to call + * @param toolName the name of the tool to call * @param arguments the arguments to pass to the tool * @return a Mono emitting the tool call result */ @@ -111,6 +115,18 @@ public McpSchema.Tool getCachedTool(String toolName) { return cachedTools.get(toolName); } + /** + * Gets the task manager for this MCP client. + * + *

+ * The task manager provides methods for managing long-running operations, + * including retrieving task status, getting results, listing tasks, and + * cancelling tasks. + * + * @return the task manager instance + */ + public abstract TaskManager getTaskManager(); + /** * Closes this MCP client and releases all resources. * This method is idempotent and can be called multiple times safely. diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpSyncClientWrapper.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpSyncClientWrapper.java index bbd0e2186..7574fa968 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpSyncClientWrapper.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpSyncClientWrapper.java @@ -15,6 +15,10 @@ */ package io.agentscope.core.tool.mcp; +import io.agentscope.core.tool.mcp.task.DefaultTaskManager; +import io.agentscope.core.tool.mcp.task.ListTasksResult; +import io.agentscope.core.tool.mcp.task.Task; +import io.agentscope.core.tool.mcp.task.TaskManager; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; @@ -25,41 +29,72 @@ import reactor.core.scheduler.Schedulers; /** - * Wrapper for synchronous MCP clients that converts blocking calls to reactive Mono types. - * This implementation delegates to {@link McpSyncClient} and wraps blocking operations + * Wrapper for synchronous MCP clients that converts blocking calls to reactive + * Mono types. + * This implementation delegates to {@link McpSyncClient} and wraps blocking + * operations * in Reactor's boundedElastic scheduler to avoid blocking the event loop. * - *

Example usage: + *

+ * Task Support Status: + *

    + *
  • Task management infrastructure is available via + * {@link #getTaskManager()}
  • + *
  • ⚠️ Current Limitation: Task operations will throw + * {@link UnsupportedOperationException} because the underlying MCP SDK + * (version 0.17.0) does not yet provide native task support
  • + *
  • The task infrastructure is ready and will be automatically enabled + * when a future MCP SDK version adds task support
  • + *
  • For custom task implementations, you can extend this class and override + * {@link #createTaskManager()} to provide your own + * {@link io.agentscope.core.tool.mcp.task.DefaultTaskManager.TaskOperations}
  • + *
+ * + *

+ * Example usage: + * *

{@code
  * McpSyncClient client = ... // created via McpClient.sync()
  * McpSyncClientWrapper wrapper = new McpSyncClientWrapper("my-mcp", client);
  * wrapper.initialize()
  *     .then(wrapper.callTool("tool_name", Map.of("arg1", "value1")))
  *     .subscribe(result -> System.out.println(result));
+ *
+ * // Task manager is available but operations will throw UnsupportedOperationException
+ * TaskManager taskManager = wrapper.getTaskManager();
+ * // taskManager.getTask("task-id").subscribe(...); // Will throw UnsupportedOperationException
  * }
+ * + * @see McpClientWrapper#getTaskManager() + * @see io.agentscope.core.tool.mcp.task.TaskManager */ public class McpSyncClientWrapper extends McpClientWrapper { private static final Logger logger = LoggerFactory.getLogger(McpSyncClientWrapper.class); private final McpSyncClient client; + private final TaskManager taskManager; /** * Constructs a new synchronous MCP client wrapper. * - * @param name unique identifier for this client + * @param name unique identifier for this client * @param client the underlying sync MCP client */ public McpSyncClientWrapper(String name, McpSyncClient client) { super(name); this.client = client; + this.taskManager = createTaskManager(); } /** * Initializes the sync MCP client connection and caches available tools. * - *

This method wraps the blocking synchronous client operations in a reactive Mono that runs - * on the boundedElastic scheduler to avoid blocking the event loop. If already initialized, + *

+ * This method wraps the blocking synchronous client operations in a reactive + * Mono that runs + * on the boundedElastic scheduler to avoid blocking the event loop. If already + * initialized, * this method returns immediately without re-initializing. * * @return a Mono that completes when initialization is finished @@ -101,7 +136,9 @@ public Mono initialize() { /** * Lists all tools available from the MCP server. * - *

This method wraps the blocking synchronous listTools call in a reactive Mono. The client + *

+ * This method wraps the blocking synchronous listTools call in a reactive Mono. + * The client * must be initialized before calling this method. * * @return a Mono emitting the list of available tools @@ -119,12 +156,16 @@ public Mono> listTools() { } /** - * Invokes a tool on the MCP server, wrapping the blocking call in a reactive Mono. + * Invokes a tool on the MCP server, wrapping the blocking call in a reactive + * Mono. * - *

This method wraps the blocking synchronous callTool operation in a Mono that runs on the - * boundedElastic scheduler. The client must be initialized before calling this method. + *

+ * This method wraps the blocking synchronous callTool operation in a Mono that + * runs on the + * boundedElastic scheduler. The client must be initialized before calling this + * method. * - * @param toolName the name of the tool to call + * @param toolName the name of the tool to call * @param arguments the arguments to pass to the tool * @return a Mono emitting the tool call result (may contain error information) * @throws IllegalStateException if the client is not initialized @@ -164,11 +205,73 @@ public Mono callTool(String toolName, Map + * This method creates a DefaultTaskManager with task operations that delegate + * to the underlying MCP client. Note that task support depends on the MCP SDK + * version and server capabilities. + * + * @return a new TaskManager instance + */ + private TaskManager createTaskManager() { + DefaultTaskManager.TaskOperations operations = + new DefaultTaskManager.TaskOperations() { + @Override + public Mono getTask(String taskId) { + // TODO: Implement when MCP SDK supports tasks/get + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono getTaskResult(String taskId) { + // TODO: Implement when MCP SDK supports tasks/result + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono listTasks(String cursor) { + // TODO: Implement when MCP SDK supports tasks/list + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + + @Override + public Mono cancelTask(String taskId) { + // TODO: Implement when MCP SDK supports tasks/cancel + return Mono.error( + new UnsupportedOperationException( + "Task operations not yet supported by MCP SDK")); + } + }; + + return new DefaultTaskManager(name, operations); + } + /** * Closes the MCP client connection and releases all resources. * - *

This method attempts to close the client gracefully, falling back to forceful closure if - * graceful closure fails. This method is idempotent and can be called multiple times safely. + *

+ * This method attempts to close the client gracefully, falling back to forceful + * closure if + * graceful closure fails. This method is idempotent and can be called multiple + * times safely. */ @Override public void close() { diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/DefaultTaskManager.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/DefaultTaskManager.java new file mode 100644 index 000000000..e9c937eab --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/DefaultTaskManager.java @@ -0,0 +1,289 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Default implementation of TaskManager for MCP clients. + * + *

+ * This implementation provides task management capabilities by delegating + * to the underlying MCP client's task-related methods. It maintains a cache + * of task status listeners and handles task status notifications. + * + *

+ * Note: This is a basic implementation that assumes the MCP SDK provides + * the necessary task-related methods. If the SDK doesn't support tasks + * natively, + * this implementation will need to be extended to handle task operations + * through + * custom protocol messages. + */ +public class DefaultTaskManager implements TaskManager { + + private static final Logger logger = LoggerFactory.getLogger(DefaultTaskManager.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final String clientName; + private final TaskOperations taskOperations; + private final List listeners = new CopyOnWriteArrayList<>(); + private final Map taskCache = new ConcurrentHashMap<>(); + + /** + * Constructs a new DefaultTaskManager. + * + * @param clientName the name of the MCP client + * @param taskOperations the task operations provider + */ + public DefaultTaskManager(String clientName, TaskOperations taskOperations) { + this.clientName = clientName; + this.taskOperations = taskOperations; + } + + @Override + public Mono getTask(String taskId) { + logger.debug("Getting task '{}' from client '{}'", taskId, clientName); + + return taskOperations + .getTask(taskId) + .doOnNext( + task -> { + taskCache.put(taskId, task); + notifyListeners(task); + }) + .doOnError( + e -> logger.error("Failed to get task '{}': {}", taskId, e.getMessage())); + } + + @Override + public Mono getTaskResult(String taskId, Class resultType) { + logger.debug("Getting result for task '{}' from client '{}'", taskId, clientName); + + return taskOperations + .getTaskResult(taskId) + .map( + result -> { + try { + // Convert the result to the expected type + if (resultType.isInstance(result)) { + return resultType.cast(result); + } + // Try JSON conversion if direct cast fails + JsonNode jsonNode = objectMapper.valueToTree(result); + return objectMapper.treeToValue(jsonNode, resultType); + } catch (Exception e) { + throw new RuntimeException( + "Failed to convert task result to " + resultType.getName(), + e); + } + }) + .doOnError( + e -> + logger.error( + "Failed to get result for task '{}': {}", + taskId, + e.getMessage())); + } + + @Override + public Mono listTasks(String cursor) { + logger.debug("Listing tasks from client '{}' with cursor '{}'", clientName, cursor); + + return taskOperations + .listTasks(cursor) + .doOnNext( + result -> { + // Cache all tasks + result.getTasks() + .forEach(task -> taskCache.put(task.getTaskId(), task)); + }) + .doOnError(e -> logger.error("Failed to list tasks: {}", e.getMessage())); + } + + @Override + public Mono cancelTask(String taskId) { + logger.debug("Cancelling task '{}' on client '{}'", taskId, clientName); + + return taskOperations + .cancelTask(taskId) + .doOnNext( + task -> { + taskCache.put(taskId, task); + notifyListeners(task); + }) + .doOnError( + e -> + logger.error( + "Failed to cancel task '{}': {}", taskId, e.getMessage())); + } + + @Override + public void registerTaskStatusListener(TaskStatusListener listener) { + if (listener != null && !listeners.contains(listener)) { + listeners.add(listener); + logger.debug("Registered task status listener for client '{}'", clientName); + } + } + + @Override + public void unregisterTaskStatusListener(TaskStatusListener listener) { + if (listener != null && listeners.remove(listener)) { + logger.debug("Unregistered task status listener for client '{}'", clientName); + } + } + + /** + * Handles incoming task status notifications. + * + *

+ * This method should be called by the MCP client when it receives + * a notifications/tasks/status message from the server. + * + * @param task the updated task information + */ + public void handleTaskStatusNotification(Task task) { + logger.debug( + "Received task status notification for task '{}': {}", + task.getTaskId(), + task.getStatus()); + taskCache.put(task.getTaskId(), task); + notifyListeners(task); + } + + /** + * Notifies all registered listeners of a task status change. + * + * @param task the updated task + */ + private void notifyListeners(Task task) { + for (TaskStatusListener listener : listeners) { + try { + listener.onTaskStatusChanged(task); + } catch (Exception e) { + logger.error("Error notifying task status listener", e); + } + } + } + + /** + * Gets a cached task by ID. + * + * @param taskId the task ID + * @return the cached task, or null if not found + */ + public Task getCachedTask(String taskId) { + return taskCache.get(taskId); + } + + /** + * Gets all cached tasks. + * + * @return a list of all cached tasks + */ + public List getAllCachedTasks() { + return new ArrayList<>(taskCache.values()); + } + + /** + * Clears the task cache. + */ + public void clearCache() { + taskCache.clear(); + logger.debug("Cleared task cache for client '{}'", clientName); + } + + /** + * Interface for task operations that must be provided by the MCP client. + */ + public interface TaskOperations { + /** + * Gets a task by ID. + * + * @param taskId the task ID + * @return a Mono emitting the task + */ + Mono getTask(String taskId); + + /** + * Gets the result of a task. + * + * @param taskId the task ID + * @return a Mono emitting the task result + */ + Mono getTaskResult(String taskId); + + /** + * Lists tasks with optional pagination. + * + * @param cursor optional cursor for pagination + * @return a Mono emitting the list result + */ + Mono listTasks(String cursor); + + /** + * Cancels a task. + * + * @param taskId the task ID + * @return a Mono emitting the updated task + */ + Mono cancelTask(String taskId); + } + + /** + * Creates a Task from a raw JSON response. + * + *

+ * This utility method helps convert MCP protocol responses into Task objects. + * + * @param taskId the task ID + * @param status the status string + * @param statusMessage optional status message + * @param createdAt creation timestamp string (ISO 8601) + * @param lastUpdatedAt last update timestamp string (ISO 8601) + * @param ttl time-to-live in milliseconds + * @param pollInterval poll interval in milliseconds + * @return a new Task instance + */ + public static Task createTask( + String taskId, + String status, + String statusMessage, + String createdAt, + String lastUpdatedAt, + Long ttl, + Long pollInterval) { + return Task.builder() + .taskId(taskId) + .status(TaskStatus.fromValue(status)) + .statusMessage(statusMessage) + .createdAt(Instant.parse(createdAt)) + .lastUpdatedAt(Instant.parse(lastUpdatedAt)) + .ttl(ttl) + .pollInterval(pollInterval) + .build(); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/ListTasksResult.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/ListTasksResult.java new file mode 100644 index 000000000..34c0dc98e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/ListTasksResult.java @@ -0,0 +1,99 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Result of listing tasks from an MCP server. + * + *

+ * This class encapsulates the response from a tasks/list request, + * which includes a list of tasks and an optional cursor for pagination. + * + * @see Task + * @see MCP + * Tasks Specification + */ +public class ListTasksResult { + + private final List tasks; + private final String nextCursor; + + /** + * Constructs a new ListTasksResult. + * + * @param tasks the list of tasks + * @param nextCursor optional cursor for retrieving the next page of results + */ + public ListTasksResult(List tasks, String nextCursor) { + this.tasks = + Collections.unmodifiableList(Objects.requireNonNull(tasks, "tasks cannot be null")); + this.nextCursor = nextCursor; + } + + /** + * Gets an unmodifiable view of the tasks list. + * + *

+ * The returned list cannot be modified. Any attempt to modify it will throw + * {@link UnsupportedOperationException}. + * + * @return an unmodifiable list of tasks + */ + public List getTasks() { + return tasks; + } + + /** + * Gets the cursor for the next page of results. + * + * @return the next cursor, or null if there are no more results + */ + public String getNextCursor() { + return nextCursor; + } + + /** + * Checks if there are more results available. + * + * @return true if a next cursor is present, false otherwise + */ + public boolean hasMore() { + return nextCursor != null && !nextCursor.isEmpty(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ListTasksResult that = (ListTasksResult) o; + return Objects.equals(tasks, that.tasks) && Objects.equals(nextCursor, that.nextCursor); + } + + @Override + public int hashCode() { + return Objects.hash(tasks, nextCursor); + } + + @Override + public String toString() { + return "ListTasksResult{" + "tasks=" + tasks + ", nextCursor='" + nextCursor + '\'' + '}'; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/Task.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/Task.java new file mode 100644 index 000000000..7aa8a456d --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/Task.java @@ -0,0 +1,263 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import java.time.Instant; +import java.util.Objects; + +/** + * Represents a task in the MCP (Model Context Protocol) system. + * + *

+ * A task encapsulates the state and metadata of a long-running operation + * that may not complete immediately. Tasks allow for asynchronous execution + * with status tracking and result retrieval. + * + *

+ * Key properties: + *

    + *
  • taskId - Unique identifier for the task
  • + *
  • status - Current state of the task execution
  • + *
  • statusMessage - Optional human-readable message describing the current + * state
  • + *
  • createdAt - ISO 8601 timestamp when the task was created
  • + *
  • lastUpdatedAt - ISO 8601 timestamp when the task status was last + * updated
  • + *
  • ttl - Time in milliseconds from creation before task may be deleted
  • + *
  • pollInterval - Suggested time in milliseconds between status checks
  • + *
+ * + * @see TaskStatus + * @see MCP + * Tasks Specification + */ +public class Task { + + private final String taskId; + private final TaskStatus status; + private final String statusMessage; + private final Instant createdAt; + private final Instant lastUpdatedAt; + private final Long ttl; + private final Long pollInterval; + + /** + * Constructs a new Task instance. + * + * @param taskId unique identifier for the task + * @param status current state of the task execution + * @param statusMessage optional human-readable message describing the current + * state + * @param createdAt timestamp when the task was created + * @param lastUpdatedAt timestamp when the task status was last updated + * @param ttl time in milliseconds from creation before task may be + * deleted + * @param pollInterval suggested time in milliseconds between status checks + */ + public Task( + String taskId, + TaskStatus status, + String statusMessage, + Instant createdAt, + Instant lastUpdatedAt, + Long ttl, + Long pollInterval) { + this.taskId = Objects.requireNonNull(taskId, "taskId cannot be null"); + this.status = Objects.requireNonNull(status, "status cannot be null"); + this.statusMessage = statusMessage; + this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null"); + this.lastUpdatedAt = Objects.requireNonNull(lastUpdatedAt, "lastUpdatedAt cannot be null"); + this.ttl = ttl; + this.pollInterval = pollInterval; + } + + /** + * Gets the unique identifier for this task. + * + * @return the task ID + */ + public String getTaskId() { + return taskId; + } + + /** + * Gets the current status of this task. + * + * @return the task status + */ + public TaskStatus getStatus() { + return status; + } + + /** + * Gets the optional human-readable status message. + * + * @return the status message, or null if not provided + */ + public String getStatusMessage() { + return statusMessage; + } + + /** + * Gets the timestamp when this task was created. + * + * @return the creation timestamp + */ + public Instant getCreatedAt() { + return createdAt; + } + + /** + * Gets the timestamp when this task was last updated. + * + * @return the last update timestamp + */ + public Instant getLastUpdatedAt() { + return lastUpdatedAt; + } + + /** + * Gets the time-to-live in milliseconds from creation. + * + * @return the TTL in milliseconds, or null if not specified + */ + public Long getTtl() { + return ttl; + } + + /** + * Gets the suggested polling interval in milliseconds. + * + * @return the poll interval in milliseconds, or null if not specified + */ + public Long getPollInterval() { + return pollInterval; + } + + /** + * Checks if this task is in a terminal state. + * + * @return true if the task has completed, failed, or been cancelled + */ + public boolean isTerminal() { + return status.isTerminal(); + } + + /** + * Creates a builder for constructing Task instances. + * + * @return a new TaskBuilder + */ + public static TaskBuilder builder() { + return new TaskBuilder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Task task = (Task) o; + return Objects.equals(taskId, task.taskId) + && status == task.status + && Objects.equals(statusMessage, task.statusMessage) + && Objects.equals(createdAt, task.createdAt) + && Objects.equals(lastUpdatedAt, task.lastUpdatedAt) + && Objects.equals(ttl, task.ttl) + && Objects.equals(pollInterval, task.pollInterval); + } + + @Override + public int hashCode() { + return Objects.hash( + taskId, status, statusMessage, createdAt, lastUpdatedAt, ttl, pollInterval); + } + + @Override + public String toString() { + return "Task{" + + "taskId='" + + taskId + + '\'' + + ", status=" + + status + + ", statusMessage='" + + statusMessage + + '\'' + + ", createdAt=" + + createdAt + + ", lastUpdatedAt=" + + lastUpdatedAt + + ", ttl=" + + ttl + + ", pollInterval=" + + pollInterval + + '}'; + } + + /** + * Builder for creating Task instances. + */ + public static class TaskBuilder { + private String taskId; + private TaskStatus status; + private String statusMessage; + private Instant createdAt; + private Instant lastUpdatedAt; + private Long ttl; + private Long pollInterval; + + public TaskBuilder taskId(String taskId) { + this.taskId = taskId; + return this; + } + + public TaskBuilder status(TaskStatus status) { + this.status = status; + return this; + } + + public TaskBuilder statusMessage(String statusMessage) { + this.statusMessage = statusMessage; + return this; + } + + public TaskBuilder createdAt(Instant createdAt) { + this.createdAt = createdAt; + return this; + } + + public TaskBuilder lastUpdatedAt(Instant lastUpdatedAt) { + this.lastUpdatedAt = lastUpdatedAt; + return this; + } + + public TaskBuilder ttl(Long ttl) { + this.ttl = ttl; + return this; + } + + public TaskBuilder pollInterval(Long pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + public Task build() { + return new Task( + taskId, status, statusMessage, createdAt, lastUpdatedAt, ttl, pollInterval); + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskManager.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskManager.java new file mode 100644 index 000000000..16a5ac1a2 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskManager.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import reactor.core.publisher.Mono; + +/** + * Interface for managing MCP tasks. + * + *

+ * This interface provides methods for interacting with tasks in the MCP + * protocol, + * including retrieving task status, getting results, listing tasks, and + * cancelling tasks. + * + *

+ * All methods return Mono types for reactive programming support, allowing for + * asynchronous and non-blocking operations. + * + * @see Task + * @see TaskStatus + * @see MCP + * Tasks Specification + */ +public interface TaskManager { + + /** + * Gets the current status and metadata of a task. + * + *

+ * This method queries the server for the current state of a task. + * It can be called repeatedly to poll for task completion. + * + * @param taskId the unique identifier of the task + * @return a Mono emitting the task information + */ + Mono getTask(String taskId); + + /** + * Retrieves the result of a completed task. + * + *

+ * This method should be called when a task reaches a terminal state + * (completed, failed, or cancelled) or when the task status is input_required. + * + *

+ * The result type depends on the original request that created the task: + *

    + *
  • For tool calls: returns CallToolResult
  • + *
  • For sampling requests: returns appropriate sampling result
  • + *
+ * + * @param taskId the unique identifier of the task + * @param resultType the expected result type class + * @param the type of the result + * @return a Mono emitting the task result + */ + Mono getTaskResult(String taskId, Class resultType); + + /** + * Lists all tasks known to the server. + * + *

+ * This method supports pagination through the cursor parameter. + * If the result contains a nextCursor, it can be used to retrieve + * the next page of results. + * + * @param cursor optional cursor for pagination (null for first page) + * @return a Mono emitting the list of tasks with pagination info + */ + Mono listTasks(String cursor); + + /** + * Cancels a running task. + * + *

+ * This method requests cancellation of a task. The server will attempt + * to stop the task execution and transition it to the CANCELLED state. + * + *

+ * Note that cancellation may not be immediate, and some tasks may not + * be cancellable depending on their current state. + * + * @param taskId the unique identifier of the task to cancel + * @return a Mono emitting the updated task information after cancellation + */ + Mono cancelTask(String taskId); + + /** + * Registers a listener for task status notifications. + * + *

+ * When registered, the listener will be notified whenever a task's + * status changes. This allows for event-driven task monitoring instead + * of polling. + * + * @param listener the task status listener + */ + void registerTaskStatusListener(TaskStatusListener listener); + + /** + * Unregisters a previously registered task status listener. + * + * @param listener the task status listener to remove + */ + void unregisterTaskStatusListener(TaskStatusListener listener); + + /** + * Listener interface for task status change notifications. + */ + @FunctionalInterface + interface TaskStatusListener { + /** + * Called when a task's status changes. + * + * @param task the updated task information + */ + void onTaskStatusChanged(Task task); + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskParameters.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskParameters.java new file mode 100644 index 000000000..27bfb24f4 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskParameters.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import java.util.Objects; + +/** + * Parameters for creating a task in the MCP protocol. + * + *

+ * These parameters can be included in requests (such as tool calls or sampling + * requests) + * to indicate that the operation should be executed as a task rather than + * returning + * results immediately. + * + *

+ * When task parameters are included in a request, the server will: + *

    + *
  • Accept the request and immediately return a CreateTaskResult
  • + *
  • Execute the operation asynchronously
  • + *
  • Make results available later through tasks/result
  • + *
+ * + * @see MCP + * Tasks Specification + */ +public class TaskParameters { + + private final Long ttl; + + /** + * Constructs task parameters with the specified TTL. + * + * @param ttl requested duration in milliseconds to retain task from creation + */ + public TaskParameters(Long ttl) { + this.ttl = ttl; + } + + /** + * Gets the requested time-to-live for the task. + * + * @return the TTL in milliseconds, or null if not specified + */ + public Long getTtl() { + return ttl; + } + + /** + * Creates task parameters with the specified TTL. + * + * @param ttl requested duration in milliseconds to retain task from creation + * @return new TaskParameters instance + */ + public static TaskParameters withTtl(Long ttl) { + return new TaskParameters(ttl); + } + + /** + * Creates task parameters with default settings (no TTL specified). + * + * @return new TaskParameters instance with defaults + */ + public static TaskParameters defaults() { + return new TaskParameters(null); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TaskParameters that = (TaskParameters) o; + return Objects.equals(ttl, that.ttl); + } + + @Override + public int hashCode() { + return Objects.hash(ttl); + } + + @Override + public String toString() { + return "TaskParameters{" + "ttl=" + ttl + '}'; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskStatus.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskStatus.java new file mode 100644 index 000000000..cd2ce7452 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/task/TaskStatus.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +/** + * Enumeration of possible task statuses in the MCP protocol. + * + *

+ * Task statuses represent the current state of a task's execution lifecycle: + *

    + *
  • {@link #WORKING} - The task is currently being processed
  • + *
  • {@link #INPUT_REQUIRED} - The task needs input from the requestor
  • + *
  • {@link #COMPLETED} - The task completed successfully
  • + *
  • {@link #FAILED} - The task failed to complete
  • + *
  • {@link #CANCELLED} - The task was cancelled before completion
  • + *
+ * + * @see MCP + * Tasks Specification + */ +public enum TaskStatus { + /** + * The request is currently being processed. + */ + WORKING("working"), + + /** + * The receiver needs input from the requestor. + * The requestor should call tasks/result to receive input requests, + * even though the task has not reached a terminal state. + */ + INPUT_REQUIRED("input_required"), + + /** + * The request completed successfully and results are available. + */ + COMPLETED("completed"), + + /** + * The associated request did not complete successfully. + * For tool calls specifically, this includes cases where the tool call + * result has isError set to true. + */ + FAILED("failed"), + + /** + * The request was cancelled before completion. + */ + CANCELLED("cancelled"); + + private final String value; + + TaskStatus(String value) { + this.value = value; + } + + /** + * Gets the string value of this status as used in the MCP protocol. + * + * @return the protocol string value + */ + public String getValue() { + return value; + } + + /** + * Parses a string value into a TaskStatus enum. + * + * @param value the string value to parse + * @return the corresponding TaskStatus + * @throws IllegalArgumentException if the value doesn't match any status + */ + public static TaskStatus fromValue(String value) { + for (TaskStatus status : values()) { + if (status.value.equals(value)) { + return status; + } + } + throw new IllegalArgumentException("Unknown task status: " + value); + } + + /** + * Checks if this status represents a terminal state. + * Terminal states are: COMPLETED, FAILED, and CANCELLED. + * + * @return true if this is a terminal status, false otherwise + */ + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == CANCELLED; + } + + @Override + public String toString() { + return value; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapperTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapperTest.java index 7167e65b3..fac566025 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapperTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpAsyncClientWrapperTest.java @@ -34,6 +34,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; class McpAsyncClientWrapperTest { @@ -228,4 +229,49 @@ private void setupSuccessfulInitialization() { when(mockClient.initialize()).thenReturn(Mono.just(initResult)); when(mockClient.listTools()).thenReturn(Mono.just(toolsResult)); } + + // TaskManager tests + @Test + void testGetTaskManager() { + assertNotNull(wrapper.getTaskManager()); + } + + @Test + void testGetTaskManager_ReturnsSameInstance() { + var taskManager1 = wrapper.getTaskManager(); + var taskManager2 = wrapper.getTaskManager(); + assertEquals(taskManager1, taskManager2); + } + + @Test + void testTaskManager_GetTask_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.getTask("task-123")) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_GetTaskResult_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.getTaskResult("task-123", Object.class)) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_ListTasks_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.listTasks(null)) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_CancelTask_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.cancelTask("task-123")) + .expectError(UnsupportedOperationException.class) + .verify(); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpClientWrapperTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpClientWrapperTest.java index a849d6eb4..2952d034b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpClientWrapperTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpClientWrapperTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.tool.mcp.task.TaskManager; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; import java.util.Map; @@ -234,6 +235,12 @@ public Mono callTool( .build()); } + @Override + public TaskManager getTaskManager() { + // Return null for test purposes + return null; + } + @Override public void close() { initialized = false; diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpSyncClientWrapperTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpSyncClientWrapperTest.java index 74b38eb7d..14ae07874 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpSyncClientWrapperTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpSyncClientWrapperTest.java @@ -35,6 +35,7 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; class McpSyncClientWrapperTest { @@ -348,4 +349,49 @@ private void setupSuccessfulInitialization() { when(mockClient.initialize()).thenReturn(initResult); when(mockClient.listTools()).thenReturn(toolsResult); } + + // TaskManager tests + @Test + void testGetTaskManager() { + assertNotNull(wrapper.getTaskManager()); + } + + @Test + void testGetTaskManager_ReturnsSameInstance() { + var taskManager1 = wrapper.getTaskManager(); + var taskManager2 = wrapper.getTaskManager(); + assertEquals(taskManager1, taskManager2); + } + + @Test + void testTaskManager_GetTask_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.getTask("task-123")) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_GetTaskResult_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.getTaskResult("task-123", Object.class)) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_ListTasks_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.listTasks(null)) + .expectError(UnsupportedOperationException.class) + .verify(); + } + + @Test + void testTaskManager_CancelTask_ThrowsUnsupportedOperation() { + var taskManager = wrapper.getTaskManager(); + StepVerifier.create(taskManager.cancelTask("task-123")) + .expectError(UnsupportedOperationException.class) + .verify(); + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/DefaultTaskManagerTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/DefaultTaskManagerTest.java new file mode 100644 index 000000000..e86eea87e --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/DefaultTaskManagerTest.java @@ -0,0 +1,411 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class DefaultTaskManagerTest { + + private DefaultTaskManager taskManager; + private DefaultTaskManager.TaskOperations mockOperations; + + private Task createTask(String taskId, TaskStatus status) { + return Task.builder() + .taskId(taskId) + .status(status) + .statusMessage("Test message") + .createdAt(Instant.now()) + .lastUpdatedAt(Instant.now()) + .ttl(60000L) + .pollInterval(5000L) + .build(); + } + + @BeforeEach + void setUp() { + mockOperations = mock(DefaultTaskManager.TaskOperations.class); + taskManager = new DefaultTaskManager("test-client", mockOperations); + } + + @Test + void testGetTask_Success() { + Task expectedTask = createTask("task-123", TaskStatus.WORKING); + when(mockOperations.getTask("task-123")).thenReturn(Mono.just(expectedTask)); + + StepVerifier.create(taskManager.getTask("task-123")) + .expectNext(expectedTask) + .verifyComplete(); + + verify(mockOperations).getTask("task-123"); + } + + @Test + void testGetTask_CachesResult() { + Task task = createTask("task-123", TaskStatus.WORKING); + when(mockOperations.getTask("task-123")).thenReturn(Mono.just(task)); + + // First call + taskManager.getTask("task-123").block(); + + // Verify task is cached + assertEquals(task, taskManager.getCachedTask("task-123")); + } + + @Test + void testGetTask_Error() { + when(mockOperations.getTask("task-123")) + .thenReturn(Mono.error(new RuntimeException("Task not found"))); + + StepVerifier.create(taskManager.getTask("task-123")) + .expectErrorMessage("Task not found") + .verify(); + } + + @Test + void testGetTaskResult_Success() { + Map resultData = new HashMap<>(); + resultData.put("status", "success"); + resultData.put("data", "test data"); + + when(mockOperations.getTaskResult("task-123")).thenReturn(Mono.just(resultData)); + + StepVerifier.create(taskManager.getTaskResult("task-123", Map.class)) + .expectNext(resultData) + .verifyComplete(); + } + + @Test + void testGetTaskResult_TypeConversion() { + // Test JSON conversion + Map resultData = new HashMap<>(); + resultData.put("message", "Hello"); + + when(mockOperations.getTaskResult("task-123")).thenReturn(Mono.just(resultData)); + + StepVerifier.create(taskManager.getTaskResult("task-123", Map.class)) + .assertNext( + result -> { + assertNotNull(result); + assertEquals("Hello", result.get("message")); + }) + .verifyComplete(); + } + + @Test + void testGetTaskResult_ConversionError() { + when(mockOperations.getTaskResult("task-123")).thenReturn(Mono.just("invalid-object")); + + StepVerifier.create(taskManager.getTaskResult("task-123", Integer.class)) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testListTasks_Success() { + List tasks = + Arrays.asList( + createTask("task-1", TaskStatus.WORKING), + createTask("task-2", TaskStatus.COMPLETED)); + ListTasksResult result = new ListTasksResult(tasks, "next-cursor"); + + when(mockOperations.listTasks(null)).thenReturn(Mono.just(result)); + + StepVerifier.create(taskManager.listTasks(null)) + .assertNext( + r -> { + assertEquals(2, r.getTasks().size()); + assertEquals("next-cursor", r.getNextCursor()); + }) + .verifyComplete(); + } + + @Test + void testListTasks_CachesAllTasks() { + List tasks = + Arrays.asList( + createTask("task-1", TaskStatus.WORKING), + createTask("task-2", TaskStatus.COMPLETED)); + ListTasksResult result = new ListTasksResult(tasks, null); + + when(mockOperations.listTasks(null)).thenReturn(Mono.just(result)); + + taskManager.listTasks(null).block(); + + // Verify both tasks are cached + assertNotNull(taskManager.getCachedTask("task-1")); + assertNotNull(taskManager.getCachedTask("task-2")); + } + + @Test + void testListTasks_WithCursor() { + List tasks = Arrays.asList(createTask("task-3", TaskStatus.WORKING)); + ListTasksResult result = new ListTasksResult(tasks, null); + + when(mockOperations.listTasks("cursor-123")).thenReturn(Mono.just(result)); + + StepVerifier.create(taskManager.listTasks("cursor-123")) + .assertNext(r -> assertEquals(1, r.getTasks().size())) + .verifyComplete(); + + verify(mockOperations).listTasks("cursor-123"); + } + + @Test + void testCancelTask_Success() { + Task cancelledTask = createTask("task-123", TaskStatus.CANCELLED); + when(mockOperations.cancelTask("task-123")).thenReturn(Mono.just(cancelledTask)); + + StepVerifier.create(taskManager.cancelTask("task-123")) + .assertNext(task -> assertEquals(TaskStatus.CANCELLED, task.getStatus())) + .verifyComplete(); + } + + @Test + void testCancelTask_UpdatesCache() { + Task cancelledTask = createTask("task-123", TaskStatus.CANCELLED); + when(mockOperations.cancelTask("task-123")).thenReturn(Mono.just(cancelledTask)); + + taskManager.cancelTask("task-123").block(); + + Task cachedTask = taskManager.getCachedTask("task-123"); + assertNotNull(cachedTask); + assertEquals(TaskStatus.CANCELLED, cachedTask.getStatus()); + } + + @Test + void testRegisterTaskStatusListener() { + AtomicInteger callCount = new AtomicInteger(0); + TaskManager.TaskStatusListener listener = task -> callCount.incrementAndGet(); + + taskManager.registerTaskStatusListener(listener); + + // Trigger a notification + Task task = createTask("task-123", TaskStatus.COMPLETED); + taskManager.handleTaskStatusNotification(task); + + assertEquals(1, callCount.get()); + } + + @Test + void testRegisterTaskStatusListener_MultipleListeners() { + AtomicInteger callCount1 = new AtomicInteger(0); + AtomicInteger callCount2 = new AtomicInteger(0); + + TaskManager.TaskStatusListener listener1 = task -> callCount1.incrementAndGet(); + TaskManager.TaskStatusListener listener2 = task -> callCount2.incrementAndGet(); + + taskManager.registerTaskStatusListener(listener1); + taskManager.registerTaskStatusListener(listener2); + + Task task = createTask("task-123", TaskStatus.COMPLETED); + taskManager.handleTaskStatusNotification(task); + + assertEquals(1, callCount1.get()); + assertEquals(1, callCount2.get()); + } + + @Test + void testRegisterTaskStatusListener_NullListener() { + // Should not throw exception + taskManager.registerTaskStatusListener(null); + + Task task = createTask("task-123", TaskStatus.COMPLETED); + taskManager.handleTaskStatusNotification(task); + } + + @Test + void testRegisterTaskStatusListener_DuplicateListener() { + AtomicInteger callCount = new AtomicInteger(0); + TaskManager.TaskStatusListener listener = task -> callCount.incrementAndGet(); + + taskManager.registerTaskStatusListener(listener); + taskManager.registerTaskStatusListener(listener); // Register twice + + Task task = createTask("task-123", TaskStatus.COMPLETED); + taskManager.handleTaskStatusNotification(task); + + // Should only be called once (no duplicates) + assertEquals(1, callCount.get()); + } + + @Test + void testUnregisterTaskStatusListener() { + AtomicInteger callCount = new AtomicInteger(0); + TaskManager.TaskStatusListener listener = task -> callCount.incrementAndGet(); + + taskManager.registerTaskStatusListener(listener); + taskManager.unregisterTaskStatusListener(listener); + + Task task = createTask("task-123", TaskStatus.COMPLETED); + taskManager.handleTaskStatusNotification(task); + + assertEquals(0, callCount.get()); + } + + @Test + void testUnregisterTaskStatusListener_NotRegistered() { + TaskManager.TaskStatusListener listener = task -> {}; + + // Should not throw exception + taskManager.unregisterTaskStatusListener(listener); + } + + @Test + void testHandleTaskStatusNotification_UpdatesCache() { + Task task = createTask("task-123", TaskStatus.COMPLETED); + + taskManager.handleTaskStatusNotification(task); + + assertEquals(task, taskManager.getCachedTask("task-123")); + } + + @Test + void testHandleTaskStatusNotification_NotifiesListeners() { + AtomicInteger callCount = new AtomicInteger(0); + TaskManager.TaskStatusListener listener = task -> callCount.incrementAndGet(); + + taskManager.registerTaskStatusListener(listener); + + Task task1 = createTask("task-1", TaskStatus.WORKING); + Task task2 = createTask("task-2", TaskStatus.COMPLETED); + + taskManager.handleTaskStatusNotification(task1); + taskManager.handleTaskStatusNotification(task2); + + assertEquals(2, callCount.get()); + } + + @Test + void testHandleTaskStatusNotification_ListenerException() { + TaskManager.TaskStatusListener faultyListener = + task -> { + throw new RuntimeException("Listener error"); + }; + + AtomicInteger callCount = new AtomicInteger(0); + TaskManager.TaskStatusListener goodListener = task -> callCount.incrementAndGet(); + + taskManager.registerTaskStatusListener(faultyListener); + taskManager.registerTaskStatusListener(goodListener); + + Task task = createTask("task-123", TaskStatus.COMPLETED); + + // Should not throw exception, and good listener should still be called + taskManager.handleTaskStatusNotification(task); + + assertEquals(1, callCount.get()); + } + + @Test + void testGetCachedTask_NotFound() { + assertNull(taskManager.getCachedTask("non-existent")); + } + + @Test + void testGetAllCachedTasks() { + Task task1 = createTask("task-1", TaskStatus.WORKING); + Task task2 = createTask("task-2", TaskStatus.COMPLETED); + + taskManager.handleTaskStatusNotification(task1); + taskManager.handleTaskStatusNotification(task2); + + List allTasks = taskManager.getAllCachedTasks(); + + assertEquals(2, allTasks.size()); + assertTrue(allTasks.contains(task1)); + assertTrue(allTasks.contains(task2)); + } + + @Test + void testClearCache() { + Task task = createTask("task-123", TaskStatus.WORKING); + taskManager.handleTaskStatusNotification(task); + + assertNotNull(taskManager.getCachedTask("task-123")); + + taskManager.clearCache(); + + assertNull(taskManager.getCachedTask("task-123")); + assertTrue(taskManager.getAllCachedTasks().isEmpty()); + } + + @Test + void testCreateTask_UtilityMethod() { + Task task = + DefaultTaskManager.createTask( + "task-123", + "working", + "Processing", + "2025-12-17T10:00:00Z", + "2025-12-17T10:05:00Z", + 60000L, + 5000L); + + assertEquals("task-123", task.getTaskId()); + assertEquals(TaskStatus.WORKING, task.getStatus()); + assertEquals("Processing", task.getStatusMessage()); + assertEquals(60000L, task.getTtl()); + assertEquals(5000L, task.getPollInterval()); + } + + @Test + void testCreateTask_InvalidStatus() { + assertThrows( + IllegalArgumentException.class, + () -> + DefaultTaskManager.createTask( + "task-123", + "invalid-status", + null, + "2025-12-17T10:00:00Z", + "2025-12-17T10:05:00Z", + null, + null)); + } + + @Test + void testCreateTask_InvalidTimestamp() { + assertThrows( + Exception.class, + () -> + DefaultTaskManager.createTask( + "task-123", + "working", + null, + "invalid-timestamp", + "2025-12-17T10:05:00Z", + null, + null)); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/ListTasksResultTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/ListTasksResultTest.java new file mode 100644 index 000000000..452505a5c --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/ListTasksResultTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ListTasksResultTest { + + private Task createTask(String taskId, TaskStatus status) { + return Task.builder() + .taskId(taskId) + .status(status) + .createdAt(Instant.now()) + .lastUpdatedAt(Instant.now()) + .build(); + } + + @Test + void testConstructor_WithTasksAndCursor() { + List tasks = + Arrays.asList( + createTask("task-1", TaskStatus.WORKING), + createTask("task-2", TaskStatus.COMPLETED)); + String cursor = "next-page-cursor"; + + ListTasksResult result = new ListTasksResult(tasks, cursor); + + assertEquals(tasks, result.getTasks()); + assertEquals(cursor, result.getNextCursor()); + } + + @Test + void testConstructor_WithTasksNoCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + + ListTasksResult result = new ListTasksResult(tasks, null); + + assertEquals(tasks, result.getTasks()); + assertNull(result.getNextCursor()); + } + + @Test + void testConstructor_EmptyTasks() { + List tasks = Collections.emptyList(); + + ListTasksResult result = new ListTasksResult(tasks, null); + + assertTrue(result.getTasks().isEmpty()); + assertNull(result.getNextCursor()); + } + + @Test + void testConstructor_NullTasks() { + assertThrows(NullPointerException.class, () -> new ListTasksResult(null, "cursor")); + } + + @Test + void testHasMore_WithCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result = new ListTasksResult(tasks, "next-cursor"); + + assertTrue(result.hasMore()); + } + + @Test + void testHasMore_NullCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result = new ListTasksResult(tasks, null); + + assertFalse(result.hasMore()); + } + + @Test + void testHasMore_EmptyCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result = new ListTasksResult(tasks, ""); + + assertFalse(result.hasMore()); + } + + @Test + void testEquals_SameResults() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result1 = new ListTasksResult(tasks, "cursor"); + ListTasksResult result2 = new ListTasksResult(tasks, "cursor"); + + assertEquals(result1, result2); + assertEquals(result1.hashCode(), result2.hashCode()); + } + + @Test + void testEquals_DifferentCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result1 = new ListTasksResult(tasks, "cursor1"); + ListTasksResult result2 = new ListTasksResult(tasks, "cursor2"); + + assertNotEquals(result1, result2); + } + + @Test + void testEquals_DifferentTasks() { + ListTasksResult result1 = + new ListTasksResult( + Arrays.asList(createTask("task-1", TaskStatus.WORKING)), "cursor"); + ListTasksResult result2 = + new ListTasksResult( + Arrays.asList(createTask("task-2", TaskStatus.COMPLETED)), "cursor"); + + assertNotEquals(result1, result2); + } + + @Test + void testToString_ContainsTasksAndCursor() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + String cursor = "next-cursor"; + ListTasksResult result = new ListTasksResult(tasks, cursor); + + String toString = result.toString(); + assertTrue(toString.contains("tasks")); + assertTrue(toString.contains(cursor)); + } + + @Test + void testGetTasks_ReturnsUnmodifiableList() { + List tasks = Arrays.asList(createTask("task-1", TaskStatus.WORKING)); + ListTasksResult result = new ListTasksResult(tasks, null); + + List returnedTasks = result.getTasks(); + + // Verify the list contains the same elements + assertEquals(tasks, returnedTasks); + + // Verify the list is unmodifiable + assertThrows( + UnsupportedOperationException.class, + () -> { + returnedTasks.add(createTask("task-2", TaskStatus.COMPLETED)); + }); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskParametersTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskParametersTest.java new file mode 100644 index 000000000..968d36bb6 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskParametersTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TaskParametersTest { + + @Test + void testConstructor_WithTtl() { + Long ttl = 60000L; + TaskParameters params = new TaskParameters(ttl); + + assertEquals(ttl, params.getTtl()); + } + + @Test + void testConstructor_NullTtl() { + TaskParameters params = new TaskParameters(null); + + assertNull(params.getTtl()); + } + + @Test + void testWithTtl_FactoryMethod() { + Long ttl = 30000L; + TaskParameters params = TaskParameters.withTtl(ttl); + + assertEquals(ttl, params.getTtl()); + } + + @Test + void testDefaults_FactoryMethod() { + TaskParameters params = TaskParameters.defaults(); + + assertNull(params.getTtl()); + } + + @Test + void testEquals_SameParameters() { + TaskParameters params1 = TaskParameters.withTtl(60000L); + TaskParameters params2 = TaskParameters.withTtl(60000L); + + assertEquals(params1, params2); + assertEquals(params1.hashCode(), params2.hashCode()); + } + + @Test + void testEquals_DifferentTtl() { + TaskParameters params1 = TaskParameters.withTtl(60000L); + TaskParameters params2 = TaskParameters.withTtl(30000L); + + assertNotEquals(params1, params2); + } + + @Test + void testEquals_BothNull() { + TaskParameters params1 = TaskParameters.defaults(); + TaskParameters params2 = TaskParameters.defaults(); + + assertEquals(params1, params2); + assertEquals(params1.hashCode(), params2.hashCode()); + } + + @Test + void testEquals_OneNullOneTtl() { + TaskParameters params1 = TaskParameters.defaults(); + TaskParameters params2 = TaskParameters.withTtl(60000L); + + assertNotEquals(params1, params2); + } + + @Test + void testToString_WithTtl() { + TaskParameters params = TaskParameters.withTtl(60000L); + String toString = params.toString(); + + assertTrue(toString.contains("60000")); + } + + @Test + void testToString_NullTtl() { + TaskParameters params = TaskParameters.defaults(); + String toString = params.toString(); + + assertTrue(toString.contains("null")); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskStatusTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskStatusTest.java new file mode 100644 index 000000000..a26acd454 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskStatusTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TaskStatusTest { + + @Test + void testGetValue() { + assertEquals("working", TaskStatus.WORKING.getValue()); + assertEquals("input_required", TaskStatus.INPUT_REQUIRED.getValue()); + assertEquals("completed", TaskStatus.COMPLETED.getValue()); + assertEquals("failed", TaskStatus.FAILED.getValue()); + assertEquals("cancelled", TaskStatus.CANCELLED.getValue()); + } + + @Test + void testFromValue_ValidValues() { + assertEquals(TaskStatus.WORKING, TaskStatus.fromValue("working")); + assertEquals(TaskStatus.INPUT_REQUIRED, TaskStatus.fromValue("input_required")); + assertEquals(TaskStatus.COMPLETED, TaskStatus.fromValue("completed")); + assertEquals(TaskStatus.FAILED, TaskStatus.fromValue("failed")); + assertEquals(TaskStatus.CANCELLED, TaskStatus.fromValue("cancelled")); + } + + @Test + void testFromValue_InvalidValue() { + assertThrows( + IllegalArgumentException.class, + () -> TaskStatus.fromValue("invalid_status"), + "Should throw IllegalArgumentException for unknown status"); + } + + @Test + void testFromValue_NullValue() { + // When value is null, the for loop doesn't match anything and throws + // IllegalArgumentException + assertThrows( + IllegalArgumentException.class, + () -> TaskStatus.fromValue(null), + "Should throw IllegalArgumentException for null value"); + } + + @Test + void testIsTerminal_TerminalStates() { + assertTrue(TaskStatus.COMPLETED.isTerminal(), "COMPLETED should be terminal"); + assertTrue(TaskStatus.FAILED.isTerminal(), "FAILED should be terminal"); + assertTrue(TaskStatus.CANCELLED.isTerminal(), "CANCELLED should be terminal"); + } + + @Test + void testIsTerminal_NonTerminalStates() { + assertFalse(TaskStatus.WORKING.isTerminal(), "WORKING should not be terminal"); + assertFalse( + TaskStatus.INPUT_REQUIRED.isTerminal(), "INPUT_REQUIRED should not be terminal"); + } + + @Test + void testToString() { + assertEquals("working", TaskStatus.WORKING.toString()); + assertEquals("input_required", TaskStatus.INPUT_REQUIRED.toString()); + assertEquals("completed", TaskStatus.COMPLETED.toString()); + assertEquals("failed", TaskStatus.FAILED.toString()); + assertEquals("cancelled", TaskStatus.CANCELLED.toString()); + } + + @Test + void testAllValuesHaveUniqueStrings() { + TaskStatus[] values = TaskStatus.values(); + for (int i = 0; i < values.length; i++) { + for (int j = i + 1; j < values.length; j++) { + assertNotEquals( + values[i].getValue(), + values[j].getValue(), + "Each status should have a unique value"); + } + } + } + + @Test + void testRoundTrip() { + // Test that fromValue(getValue()) returns the same enum + for (TaskStatus status : TaskStatus.values()) { + assertEquals(status, TaskStatus.fromValue(status.getValue())); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskTest.java new file mode 100644 index 000000000..09bfbfa40 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/task/TaskTest.java @@ -0,0 +1,279 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.tool.mcp.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class TaskTest { + + private static final String TASK_ID = "test-task-123"; + private static final TaskStatus STATUS = TaskStatus.WORKING; + private static final String STATUS_MESSAGE = "Processing request"; + private static final Instant CREATED_AT = Instant.parse("2025-12-17T10:00:00Z"); + private static final Instant UPDATED_AT = Instant.parse("2025-12-17T10:05:00Z"); + private static final Long TTL = 60000L; + private static final Long POLL_INTERVAL = 5000L; + + @Test + void testBuilder_AllFields() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(STATUS) + .statusMessage(STATUS_MESSAGE) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .ttl(TTL) + .pollInterval(POLL_INTERVAL) + .build(); + + assertEquals(TASK_ID, task.getTaskId()); + assertEquals(STATUS, task.getStatus()); + assertEquals(STATUS_MESSAGE, task.getStatusMessage()); + assertEquals(CREATED_AT, task.getCreatedAt()); + assertEquals(UPDATED_AT, task.getLastUpdatedAt()); + assertEquals(TTL, task.getTtl()); + assertEquals(POLL_INTERVAL, task.getPollInterval()); + } + + @Test + void testBuilder_MinimalFields() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(STATUS) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertEquals(TASK_ID, task.getTaskId()); + assertEquals(STATUS, task.getStatus()); + assertNull(task.getStatusMessage()); + assertEquals(CREATED_AT, task.getCreatedAt()); + assertEquals(UPDATED_AT, task.getLastUpdatedAt()); + assertNull(task.getTtl()); + assertNull(task.getPollInterval()); + } + + @Test + void testConstructor_NullTaskId() { + assertThrows( + NullPointerException.class, + () -> + new Task( + null, + STATUS, + STATUS_MESSAGE, + CREATED_AT, + UPDATED_AT, + TTL, + POLL_INTERVAL)); + } + + @Test + void testConstructor_NullStatus() { + assertThrows( + NullPointerException.class, + () -> + new Task( + TASK_ID, + null, + STATUS_MESSAGE, + CREATED_AT, + UPDATED_AT, + TTL, + POLL_INTERVAL)); + } + + @Test + void testConstructor_NullCreatedAt() { + assertThrows( + NullPointerException.class, + () -> + new Task( + TASK_ID, + STATUS, + STATUS_MESSAGE, + null, + UPDATED_AT, + TTL, + POLL_INTERVAL)); + } + + @Test + void testConstructor_NullLastUpdatedAt() { + assertThrows( + NullPointerException.class, + () -> + new Task( + TASK_ID, + STATUS, + STATUS_MESSAGE, + CREATED_AT, + null, + TTL, + POLL_INTERVAL)); + } + + @Test + void testIsTerminal_CompletedTask() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.COMPLETED) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertTrue(task.isTerminal()); + } + + @Test + void testIsTerminal_FailedTask() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.FAILED) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertTrue(task.isTerminal()); + } + + @Test + void testIsTerminal_CancelledTask() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.CANCELLED) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertTrue(task.isTerminal()); + } + + @Test + void testIsTerminal_WorkingTask() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.WORKING) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertFalse(task.isTerminal()); + } + + @Test + void testEquals_SameTasks() { + Task task1 = + Task.builder() + .taskId(TASK_ID) + .status(STATUS) + .statusMessage(STATUS_MESSAGE) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .ttl(TTL) + .pollInterval(POLL_INTERVAL) + .build(); + + Task task2 = + Task.builder() + .taskId(TASK_ID) + .status(STATUS) + .statusMessage(STATUS_MESSAGE) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .ttl(TTL) + .pollInterval(POLL_INTERVAL) + .build(); + + assertEquals(task1, task2); + assertEquals(task1.hashCode(), task2.hashCode()); + } + + @Test + void testEquals_DifferentTaskId() { + Task task1 = + Task.builder() + .taskId("task-1") + .status(STATUS) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + Task task2 = + Task.builder() + .taskId("task-2") + .status(STATUS) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertNotEquals(task1, task2); + } + + @Test + void testEquals_DifferentStatus() { + Task task1 = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.WORKING) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + Task task2 = + Task.builder() + .taskId(TASK_ID) + .status(TaskStatus.COMPLETED) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .build(); + + assertNotEquals(task1, task2); + } + + @Test + void testToString_ContainsAllFields() { + Task task = + Task.builder() + .taskId(TASK_ID) + .status(STATUS) + .statusMessage(STATUS_MESSAGE) + .createdAt(CREATED_AT) + .lastUpdatedAt(UPDATED_AT) + .ttl(TTL) + .pollInterval(POLL_INTERVAL) + .build(); + + String toString = task.toString(); + assertTrue(toString.contains(TASK_ID)); + assertTrue(toString.contains(STATUS.toString())); + assertTrue(toString.contains(STATUS_MESSAGE)); + } +} diff --git a/agentscope-dependencies-bom/pom.xml b/agentscope-dependencies-bom/pom.xml index 0cc958255..4bdc68ba8 100644 --- a/agentscope-dependencies-bom/pom.xml +++ b/agentscope-dependencies-bom/pom.xml @@ -134,7 +134,7 @@ io.modelcontextprotocol.sdk mcp - 0.14.1 + 0.17.0