diff --git a/.gitignore b/.gitignore index 3c230a01..b330f959 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ ScaffoldingReadMe.txt *~ CodeCoverage/ +# Copilot tracking artifacts +.copilot-tracking/ + # MSBuild Binary and Structured Log *.binlog diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..fd8d7ba2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "FileStoreDemo", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/samples/FileStoreDemo/bin/Debug/net10.0/FileStoreDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/samples/FileStoreDemo", + "console": "integratedTerminal", + "stopAtEntry": false, + "preLaunchTask": "build-filestoreDemo" + }, + { + "name": "AgentServer (EchoTasks)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll", + "args": ["--agent", "echotasks"], + "cwd": "${workspaceFolder}/samples/AgentServer", + "console": "integratedTerminal", + "stopAtEntry": false, + "preLaunchTask": "build-agentServer" + }, + { + "name": "AgentServer (SpecCompliance)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll", + "args": ["--agent", "speccompliance"], + "cwd": "${workspaceFolder}/samples/AgentServer", + "console": "integratedTerminal", + "stopAtEntry": false, + "preLaunchTask": "build-agentServer" + }, + { + "name": "AgentServer (FileStore)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll", + "args": ["--agent", "echotasks", "--store", "file", "--data-dir", "${workspaceFolder}/samples/AgentServer/a2a-data"], + "cwd": "${workspaceFolder}/samples/AgentServer", + "console": "integratedTerminal", + "stopAtEntry": false, + "preLaunchTask": "build-agentServer" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..2cbc89b0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-filestoreDemo", + "type": "shell", + "command": "dotnet", + "args": ["build", "${workspaceFolder}/samples/FileStoreDemo/FileStoreDemo.csproj"], + "group": "build", + "problemMatcher": ["$msCompile"] + }, + { + "label": "build-agentServer", + "type": "shell", + "command": "dotnet", + "args": ["build", "${workspaceFolder}/samples/AgentServer/AgentServer.csproj"], + "group": "build", + "problemMatcher": ["$msCompile"] + } + ] +} \ No newline at end of file diff --git a/A2A.slnx b/A2A.slnx index 8544520b..07be1f60 100644 --- a/A2A.slnx +++ b/A2A.slnx @@ -19,6 +19,7 @@ + @@ -31,5 +32,6 @@ + diff --git a/README.md b/README.md index 35c0c56e..de45bd34 100644 --- a/README.md +++ b/README.md @@ -1,207 +1,228 @@ -# A2A .NET SDK - -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![NuGet Version](https://img.shields.io/nuget/v/A2A.svg)](https://www.nuget.org/packages/A2A/) - -A .NET library that helps run agentic applications as A2AServers following the [Agent2Agent (A2A) Protocol](https://a2a-protocol.org). - -The A2A .NET SDK provides a robust implementation of the Agent2Agent (A2A) protocol, enabling seamless communication between AI agents and applications. This library offers both high-level abstractions and fine-grained control, making it easy to build A2A-compatible agents while maintaining flexibility for advanced use cases. - -Key features include: -- **Agent Capability Discovery**: Retrieve agent capabilities and metadata through agent cards -- **Message-based Communication**: Direct, stateless messaging with immediate responses -- **Task-based Communication**: Create and manage persistent, long-running agent tasks -- **Streaming Support**: Real-time communication using Server-Sent Events -- **ASP.NET Core Integration**: Built-in extensions for hosting A2A agents in web applications -- **Cross-platform Compatibility**: Supports .NET Standard 2.0 and .NET 8+ - -## Protocol Compatibility - -This library implements most of the features of protocol v0.2.6, however there are some scenarios that are not yet complete for full compatibility with this version. A complete list of outstanding compatibility items can be found at: [open compatibility items](https://github.com/a2aproject/a2a-dotnet/issues?q=is:issue%20is:open%20(label:v0.2.4%20OR%20label:v0.2.5%20OR%20label:v0.2.6)) - -## Installation - -### Core A2A Library - -```bash -dotnet add package A2A -``` - -### ASP.NET Core Extensions - -```bash -dotnet add package A2A.AspNetCore -``` - -## Overview -![alt text](https://github.com/a2aproject/a2a-dotnet/raw/main/overview.png) - -## Library: A2A -This library contains the core A2A protocol implementation. It includes the following key classes: - -### Client Classes -- **`A2AClient`**: Primary client for making A2A requests to agents. Supports both streaming and non-streaming communication, task management, and push notifications. -- **`A2ACardResolver`**: Resolves agent card information from A2A-compatible endpoints to discover agent capabilities and metadata. - -### Server Classes -- **`TaskManager`**: Manages the complete lifecycle of agent tasks including creation, updates, cancellation, and event streaming. Handles both message-based and task-based communication patterns. -- **`ITaskStore`**: An interface for abstracting the storage of tasks. -- **`InMemoryTaskStore`**: Simple in-memory implementation of `ITaskStore` suitable for development and testing scenarios. - -### Core Models -- **`AgentTask`**: Represents a task with its status, history, artifacts, and metadata. -- **`AgentCard`**: Contains agent metadata, capabilities, and endpoint information. -- **`Message`**: Represents messages exchanged between agents and clients. - -## Library: A2A.AspNetCore -This library provides ASP.NET Core integration for hosting A2A agents. It includes the following key classes: - -### Extension Methods -- **`A2ARouteBuilderExtensions`**: Provides `MapA2A()` and `MapHttpA2A()` extension methods for configuring A2A endpoints in ASP.NET Core applications. - -## Getting Started - -### 1. Create an Agent Server - -```csharp -using A2A; -using A2A.AspNetCore; - -var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); - -// Create and register your agent -var taskManager = new TaskManager(); -var agent = new EchoAgent(); -agent.Attach(taskManager); - -app.MapA2A(taskManager, "/echo"); -app.Run(); - -public class EchoAgent -{ - public void Attach(ITaskManager taskManager) - { - taskManager.OnMessageReceived = ProcessMessageAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; - } - - private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) - { - var text = messageSendParams.Message.Parts.OfType().First().Text; - return Task.FromResult(new Message - { - Role = MessageRole.Agent, - MessageId = Guid.NewGuid().ToString(), - ContextId = messageSendParams.Message.ContextId, - Parts = [new TextPart { Text = $"Echo: {text}" }] - }); - } - - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) - { - return Task.FromResult(new AgentCard - { - Name = "Echo Agent", - Description = "Echoes messages back to the user", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = new AgentCapabilities { Streaming = true } - }); - } -} -``` - -### 2. Connect with A2AClient - -```csharp -using A2A; - -// Discover agent and create client -var cardResolver = new A2ACardResolver(new Uri("http://localhost:5100/")); -var agentCard = await cardResolver.GetAgentCardAsync(); -var client = new A2AClient(new Uri(agentCard.Url)); - -// Send message -var response = await client.SendMessageAsync(new MessageSendParams -{ - Message = new AgentMessage - { - Role = MessageRole.User, - Parts = [new TextPart { Text = "Hello!" }] - } -}); -``` - -## Samples - -The repository includes several sample projects demonstrating different aspects of the A2A protocol implementation. Each sample includes its own README with detailed setup and usage instructions. - -### Agent Client Samples -**[`samples/AgentClient/`](samples/AgentClient/README.md)** - -Comprehensive collection of client-side samples showing how to interact with A2A agents: -- **Agent Capability Discovery**: Retrieve agent capabilities and metadata using agent cards -- **Message-based Communication**: Direct, stateless messaging with immediate responses -- **Task-based Communication**: Create and manage persistent agent tasks -- **Streaming Communication**: Real-time communication using Server-Sent Events - -### Agent Server Samples -**[`samples/AgentServer/`](samples/AgentServer/README.md)** - -Server-side examples demonstrating how to build A2A-compatible agents: -- **Echo Agent**: Simple agent that echoes messages back to clients -- **Echo Agent with Tasks**: Task-based version of the echo agent -- **Researcher Agent**: More complex agent with research capabilities -- **HTTP Test Suite**: Complete set of HTTP tests for all agent endpoints - -### Semantic Kernel Integration -**[`samples/SemanticKernelAgent/`](samples/SemanticKernelAgent/README.md)** - -Advanced sample showing integration with Microsoft Semantic Kernel: -- **Travel Planner Agent**: AI-powered travel planning agent -- **Semantic Kernel Integration**: Demonstrates how to wrap Semantic Kernel functionality in A2A protocol - -### Command Line Interface -**[`samples/A2ACli/`](samples/A2ACli/)** - -Command-line tool for interacting with A2A agents: -- Direct command-line access to A2A agents -- Useful for testing and automation scenarios - -### Quick Start with Client Samples - -1. **Clone and build the repository**: - ```bash - git clone https://github.com/a2aproject/a2a-dotnet.git - cd a2a-dotnet - dotnet build - ``` - -2. **Run the client samples**: - ```bash - cd samples/AgentClient - dotnet run - ``` - -For detailed instructions and advanced scenarios, see the individual README files linked above. - -## Further Reading - -To learn more about the A2A protocol, explore these additional resources: - -- **[A2A Protocol Documentation](https://a2a-protocol.org/latest/)** - The official documentation for the A2A protocol. -- **[A2A Protocol Specification](https://a2a-protocol.org/latest/specification/)** - The detailed technical specification of the protocol. -- **[A2A Topics](https://a2a-protocol.org/latest/topics/what-is-a2a/)** - An overview of key concepts and features of the A2A protocol. -- **[A2A Roadmap](https://a2a-protocol.org/latest/roadmap/)** - A look at the future development plans and upcoming features. - -## Acknowledgements - -This library builds upon [Darrel Miller's](https://github.com/darrelmiller) [sharpa2a](https://github.com/darrelmiller/sharpa2a) project. Thanks to Darrel and all the other contributors for the foundational work that helped shape this SDK. - -## License - -This project is licensed under the [Apache 2.0 License](LICENSE). - +# A2A .NET SDK + +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![NuGet Version](https://img.shields.io/nuget/v/A2A.svg)](https://www.nuget.org/packages/A2A/) + +A .NET library that helps run agentic applications as A2AServers following the [Agent2Agent (A2A) Protocol](https://a2a-protocol.org). + +The A2A .NET SDK provides a robust implementation of the Agent2Agent (A2A) protocol, enabling seamless communication between AI agents and applications. This library offers both high-level abstractions and fine-grained control, making it easy to build A2A-compatible agents while maintaining flexibility for advanced use cases. + +Key features include: +- **Agent Capability Discovery**: Retrieve agent capabilities and metadata through agent cards +- **Message-based Communication**: Direct, stateless messaging with immediate responses +- **Task-based Communication**: Create and manage persistent, long-running agent tasks +- **Streaming Support**: Real-time communication using Server-Sent Events +- **ASP.NET Core Integration**: Built-in extensions for hosting A2A agents in web applications +- **Cross-platform Compatibility**: Supports .NET 8+ + +## Protocol Compatibility + +This library implements the [A2A v1.0 specification](https://a2a-protocol.org). It provides full support for the JSON-RPC binding and HTTP+JSON REST binding, including streaming via Server-Sent Events. + +If you're upgrading from the v0.3 SDK, see the **[Migration Guide](docs/migration-guide-v1.md)** for a comprehensive list of breaking changes and before/after code examples. A backward-compatible `A2A.V0_3` NuGet package is available during the transition: + +```bash +dotnet add package A2A.V0_3 +``` + +## Installation + +### Core A2A Library + +```bash +dotnet add package A2A +``` + +### ASP.NET Core Extensions + +```bash +dotnet add package A2A.AspNetCore +``` + +## Overview +![alt text](https://github.com/a2aproject/a2a-dotnet/raw/main/overview.png) + +## Library: A2A +This library contains the core A2A protocol implementation. It includes the following key classes: + +### Client Classes +- **`A2AClient`**: Primary client for making A2A requests to agents. Supports both streaming and non-streaming communication, task management, and push notifications. +- **`A2ACardResolver`**: Resolves agent card information from A2A-compatible endpoints to discover agent capabilities and metadata. + +### Server Classes +- **`TaskManager`**: Manages the complete lifecycle of agent tasks including creation, updates, cancellation, and event streaming. Handles both message-based and task-based communication patterns. +- **`ITaskStore`**: An interface for abstracting the storage of tasks. +- **`InMemoryTaskStore`**: Simple in-memory implementation of `ITaskStore` suitable for development and testing scenarios. + +### Core Models +- **`AgentTask`**: Represents a task with its status, history, artifacts, and metadata. +- **`AgentCard`**: Contains agent metadata, capabilities, and endpoint information. +- **`Message`**: Represents messages exchanged between agents and clients. + +## Library: A2A.AspNetCore +This library provides ASP.NET Core integration for hosting A2A agents. It includes the following key classes: + +### Extension Methods +- **`A2ARouteBuilderExtensions`**: Provides `MapA2A()` and `MapHttpA2A()` extension methods for configuring A2A endpoints in ASP.NET Core applications. + +## Getting Started + +### 1. Create an Agent Server + +```csharp +using A2A; +using A2A.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var store = new InMemoryTaskStore(); +var taskManager = new TaskManager(store); + +taskManager.OnSendMessage = async (request, ct) => +{ + var text = request.Message.Parts.FirstOrDefault()?.Text ?? ""; + return new SendMessageResponse + { + Message = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.Agent, + Parts = [Part.FromText($"Echo: {text}")] + } + }; +}; + +var agentCard = new AgentCard +{ + Name = "Echo Agent", + Description = "Echoes messages back to the user", + Version = "1.0.0", + SupportedInterfaces = [new AgentInterface + { + Url = "http://localhost:5000/echo", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0" + }], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities { Streaming = false }, + Skills = [new AgentSkill + { + Id = "echo", + Name = "Echo", + Description = "Echoes back user messages", + Tags = ["echo"] + }], +}; + +app.MapA2A(taskManager, "/echo"); +app.MapWellKnownAgentCard(agentCard); +app.Run(); +``` + +### 2. Connect with A2AClient + +```csharp +using A2A; + +// Discover agent +var cardResolver = new A2ACardResolver(new Uri("http://localhost:5000/")); +var agentCard = await cardResolver.GetAgentCardAsync(); + +// Create client using agent's endpoint +var client = new A2AClient(new Uri(agentCard.SupportedInterfaces[0].Url)); + +// Send message +var response = await client.SendMessageAsync(new SendMessageRequest +{ + Message = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.User, + Parts = [Part.FromText("Hello!")] + } +}); + +// Handle response +switch (response.PayloadCase) +{ + case SendMessageResponseCase.Message: + Console.WriteLine(response.Message!.Parts[0].Text); + break; + case SendMessageResponseCase.Task: + Console.WriteLine($"Task created: {response.Task!.Id}"); + break; +} +``` + +## Samples + +The repository includes several sample projects demonstrating different aspects of the A2A protocol implementation. Each sample includes its own README with detailed setup and usage instructions. + +### Agent Client Samples +**[`samples/AgentClient/`](samples/AgentClient/README.md)** + +Comprehensive collection of client-side samples showing how to interact with A2A agents: +- **Agent Capability Discovery**: Retrieve agent capabilities and metadata using agent cards +- **Message-based Communication**: Direct, stateless messaging with immediate responses +- **Task-based Communication**: Create and manage persistent agent tasks +- **Streaming Communication**: Real-time communication using Server-Sent Events + +### Agent Server Samples +**[`samples/AgentServer/`](samples/AgentServer/README.md)** + +Server-side examples demonstrating how to build A2A-compatible agents: +- **Echo Agent**: Simple agent that echoes messages back to clients +- **Echo Agent with Tasks**: Task-based version of the echo agent +- **Researcher Agent**: More complex agent with research capabilities +- **HTTP Test Suite**: Complete set of HTTP tests for all agent endpoints + +### Semantic Kernel Integration +**[`samples/SemanticKernelAgent/`](samples/SemanticKernelAgent/README.md)** + +Advanced sample showing integration with Microsoft Semantic Kernel: +- **Travel Planner Agent**: AI-powered travel planning agent +- **Semantic Kernel Integration**: Demonstrates how to wrap Semantic Kernel functionality in A2A protocol + +### Command Line Interface +**[`samples/A2ACli/`](samples/A2ACli/)** + +Command-line tool for interacting with A2A agents: +- Direct command-line access to A2A agents +- Useful for testing and automation scenarios + +### Quick Start with Client Samples + +1. **Clone and build the repository**: + ```bash + git clone https://github.com/a2aproject/a2a-dotnet.git + cd a2a-dotnet + dotnet build + ``` + +2. **Run the client samples**: + ```bash + cd samples/AgentClient + dotnet run + ``` + +For detailed instructions and advanced scenarios, see the individual README files linked above. + +## Further Reading + +To learn more about the A2A protocol, explore these additional resources: + +- **[A2A Protocol Documentation](https://a2a-protocol.org/latest/)** - The official documentation for the A2A protocol. +- **[A2A Protocol Specification](https://a2a-protocol.org/latest/specification/)** - The detailed technical specification of the protocol. +- **[A2A Topics](https://a2a-protocol.org/latest/topics/what-is-a2a/)** - An overview of key concepts and features of the A2A protocol. +- **[A2A Roadmap](https://a2a-protocol.org/latest/roadmap/)** - A look at the future development plans and upcoming features. + +## Acknowledgements + +This library builds upon [Darrel Miller's](https://github.com/darrelmiller) [sharpa2a](https://github.com/darrelmiller/sharpa2a) project. Thanks to Darrel and all the other contributors for the foundational work that helped shape this SDK. + +## License + +This project is licensed under the [Apache 2.0 License](LICENSE). + diff --git a/docs/migration-guide-v1.md b/docs/migration-guide-v1.md new file mode 100644 index 00000000..a1ec2ed3 --- /dev/null +++ b/docs/migration-guide-v1.md @@ -0,0 +1,651 @@ +# A2A .NET SDK: Migration Guide — v0.3 to v1 + +This guide helps .NET developers upgrade from the A2A v0.3 SDK to v1. The v1 release adopts [ProtoJSON](https://protobuf.dev/programming-guides/proto3/#json) serialization conventions (SCREAMING_SNAKE_CASE enums, field-presence `oneof` patterns), replaces discriminator-based polymorphism with flat sealed types, and adds new operations like `ListTasks` and a REST API binding. + +> **Note**: The `A2A.V0_3` NuGet package remains available for backward compatibility during the transition period. It will be removed in a future release. + +## Migration Strategy + +Follow this 3-phase approach (from the [official A2A spec](https://google.github.io/A2A/)): + +### Phase 1: Compatibility Layer + +Add the `A2A.V0_3` package alongside `A2A` v1. Both can coexist in the same project using different namespaces (`A2A` for v1, `A2A.V0_3` for legacy). No code changes required — existing v0.3 code continues to work. + +```xml + + +``` + +### Phase 2: Dual Support + +Update your client and server code to use v1 types. Keep the `A2A.V0_3` package for any consumers that haven't migrated yet. All the changes described in this guide apply to this phase. + +### Phase 3: V1-Only + +Remove the `A2A.V0_3` dependency entirely. All consumers are on v1. + +```xml + + +``` + +--- + +## Part Model + +The biggest structural change. V0.3 used a discriminator hierarchy with `TextPart`, `FilePart`, and `DataPart` subclasses. V1 uses a single sealed `Part` class with field-presence (`oneof`). + +### V0.3 + +```csharp +using A2A.V0_3; + +// Creating parts +var textPart = new TextPart { Text = "Hello, world!" }; +var filePart = new FilePart +{ + File = new FileContent + { + Name = "report.pdf", + MimeType = "application/pdf", + Uri = "https://example.com/report.pdf" + } +}; +var dataPart = new DataPart +{ + Data = JsonDocument.Parse("""{"key": "value"}""").RootElement +}; + +// Consuming parts (check kind, then cast) +foreach (Part part in message.Parts) +{ + switch (part) + { + case TextPart tp: + Console.WriteLine(tp.Text); + break; + case FilePart fp: + Console.WriteLine(fp.File.Uri); + break; + case DataPart dp: + Console.WriteLine(dp.Data); + break; + } +} +``` + +### V1 + +```csharp +using A2A; + +// Creating parts — use factory methods +var textPart = Part.FromText("Hello, world!"); +var filePart = Part.FromUrl("https://example.com/report.pdf", + mediaType: "application/pdf", filename: "report.pdf"); +var dataPart = Part.FromData( + JsonDocument.Parse("""{"key": "value"}""").RootElement); + +// Consuming parts — use ContentCase enum +foreach (Part part in message.Parts) +{ + switch (part.ContentCase) + { + case PartContentCase.Text: + Console.WriteLine(part.Text); + break; + case PartContentCase.Url: + Console.WriteLine(part.Url); + break; + case PartContentCase.Raw: + // part.Raw is byte[] (base64 in JSON) + break; + case PartContentCase.Data: + Console.WriteLine(part.Data); + break; + } +} +``` + +**Key differences:** + +| Aspect | V0.3 | V1 | +|--------|------|-----| +| Type hierarchy | `TextPart`, `FilePart`, `DataPart` subclasses | Single sealed `Part` class | +| Content identification | `kind` discriminator + C# type casting | `ContentCase` computed enum | +| File content | Nested `FileContent` class with `Uri`/`Bytes` | Flat: `Part.Url` or `Part.Raw` | +| MIME type field | `MimeType` (on `FileContent`) | `MediaType` (on `Part`) | +| Factory methods | None (direct construction) | `Part.FromText()`, `Part.FromUrl()`, `Part.FromRaw()`, `Part.FromData()` | + +--- + +## Response Types + +V0.3 used `A2AResponse` with a `kind` discriminator. V1 splits into `SendMessageResponse` (for `SendMessage`) and `StreamResponse` (for streaming). + +### V0.3 + +```csharp +// SendMessage returned an A2AResponse with kind-based discrimination +A2AResponse response = await client.SendMessageAsync(sendParams); +switch (response) +{ + case AgentTask task: + Console.WriteLine($"Task: {task.Id}, State: {task.Status.State}"); + break; + case TaskStatusUpdateEvent update: + Console.WriteLine($"Status: {update.Status.State}"); + break; +} +``` + +### V1 + +```csharp +// SendMessage returns SendMessageResponse with named fields +SendMessageResponse response = await client.SendMessageAsync(request); +switch (response.PayloadCase) +{ + case SendMessageResponseCase.Task: + Console.WriteLine($"Task: {response.Task!.Id}, State: {response.Task.Status.State}"); + break; + case SendMessageResponseCase.Message: + Console.WriteLine($"Message from agent: {response.Message!.Parts[0].Text}"); + break; +} +``` + +### Streaming + +```csharp +// V0.3: IAsyncEnumerable with kind-based casting +await foreach (A2AEvent evt in client.SendStreamingAsync(params)) +{ + if (evt is TaskStatusUpdateEvent statusUpdate) { ... } + if (evt is TaskArtifactUpdateEvent artifactUpdate) { ... } +} + +// V1: IAsyncEnumerable with PayloadCase +await foreach (StreamResponse evt in client.SendStreamingMessageAsync(request)) +{ + switch (evt.PayloadCase) + { + case StreamResponseCase.StatusUpdate: + Console.WriteLine(evt.StatusUpdate!.Status.State); + break; + case StreamResponseCase.ArtifactUpdate: + Console.WriteLine(evt.ArtifactUpdate!.Artifact.ArtifactId); + break; + case StreamResponseCase.Task: + Console.WriteLine(evt.Task!.Id); + break; + case StreamResponseCase.Message: + Console.WriteLine(evt.Message!.Parts[0].Text); + break; + } +} +``` + +--- + +## SecurityScheme + +V0.3 used discriminator-based polymorphism. V1 uses field-presence on a flat sealed class. + +### V0.3 + +```csharp +// Creating security schemes +var apiKey = new SecurityScheme +{ + ApiKeySecurityScheme = new ApiKeySecurityScheme + { + Name = "X-API-Key", + In = "header" // Note: "In" property + } +}; +``` + +### V1 + +```csharp +// Creating security schemes — same structure, different field names +var apiKey = new SecurityScheme +{ + ApiKeySecurityScheme = new ApiKeySecurityScheme + { + Name = "X-API-Key", + Location = "header" // Renamed: "In" → "Location" + } +}; + +// Use SchemeCase for type identification +switch (scheme.SchemeCase) +{ + case SecuritySchemeCase.ApiKey: + Console.WriteLine(scheme.ApiKeySecurityScheme!.Location); + break; + case SecuritySchemeCase.OAuth2: + Console.WriteLine(scheme.OAuth2SecurityScheme!.Flows.FlowCase); + break; +} +``` + +**Key change:** `ApiKeySecurityScheme.In` is renamed to `ApiKeySecurityScheme.Location` (JSON property `"location"` to match proto). + +--- + +## Type Renames + +| V0.3 | V1 | Notes | +|------|-----|-------| +| `AgentMessage` | `Message` | Renamed | +| `AgentTaskStatus` | `TaskStatus` | Struct → sealed class | +| `MessageRole` | `Role` | Renamed | +| `TextPart` | `Part` | Unified (use `Part.FromText()`) | +| `FilePart` | `Part` | Unified (use `Part.FromUrl()` / `Part.FromRaw()`) | +| `DataPart` | `Part` | Unified (use `Part.FromData()`) | +| `A2AResponse` | `SendMessageResponse` / `StreamResponse` | Split by use case | +| `A2AEvent` | `StreamResponse` | Unified with response | +| `FileContent` | (removed) | Fields moved to `Part` directly | +| `PartKind` | `PartContentCase` | Computed enum | +| `A2AEventKind` | `StreamResponseCase` | Computed enum | + +--- + +## JSON Wire Format + +All JSON wire format changes follow the A2A v1 ProtoJSON conventions. + +### Enum Values + +```json +// V0.3 (kebab-case) +{ "state": "input-required" } +{ "role": "user" } + +// V1 (SCREAMING_SNAKE_CASE with type prefix) +{ "state": "TASK_STATE_INPUT_REQUIRED" } +{ "role": "ROLE_USER" } +``` + +**Full enum mapping:** + +| V0.3 | V1 | +|------|-----| +| `"submitted"` | `"TASK_STATE_SUBMITTED"` | +| `"working"` | `"TASK_STATE_WORKING"` | +| `"input-required"` | `"TASK_STATE_INPUT_REQUIRED"` | +| `"completed"` | `"TASK_STATE_COMPLETED"` | +| `"canceled"` | `"TASK_STATE_CANCELED"` | +| `"failed"` | `"TASK_STATE_FAILED"` | +| `"user"` | `"ROLE_USER"` | +| `"agent"` | `"ROLE_AGENT"` | + +**New v1 enum values** (not in v0.3): `TASK_STATE_REJECTED`, `TASK_STATE_AUTH_REQUIRED`, `TASK_STATE_UNSPECIFIED`. + +### Part Format + +```json +// V0.3 — kind discriminator +{"kind": "text", "text": "Hello"} +{"kind": "file", "file": {"uri": "https://...", "mimeType": "text/plain"}} + +// V1 — field-presence (no kind, flat structure) +{"text": "Hello"} +{"url": "https://...", "mediaType": "text/plain"} +``` + +### Response Format + +```json +// V0.3 — kind at root level +{ + "result": {"kind": "task", "id": "abc", "status": {"state": "working"}} +} + +// V1 — named wrapper +{ + "result": {"task": {"id": "abc", "status": {"state": "TASK_STATE_WORKING"}}} +} +``` + +### JSON-RPC Method Names + +| V0.3 | V1 | +|------|-----| +| `"message/send"` | `"SendMessage"` | +| `"message/stream"` | `"SendStreamingMessage"` | +| `"tasks/get"` | `"GetTask"` | +| `"tasks/cancel"` | `"CancelTask"` | +| `"tasks/pushNotificationConfig/set"` | `"CreateTaskPushNotificationConfig"` | +| `"tasks/pushNotificationConfig/get"` | `"GetTaskPushNotificationConfig"` | +| `"tasks/resubscribe"` | `"SubscribeToTask"` | +| N/A | `"ListTasks"` (new) | +| N/A | `"DeleteTaskPushNotificationConfig"` (new) | +| N/A | `"ListTaskPushNotificationConfig"` (new) | +| N/A | `"GetExtendedAgentCard"` (new) | + +--- + +## AgentCard Changes + +### V0.3 + +```csharp +var card = new AgentCard +{ + Name = "My Agent", + Description = "Does things.", + Url = "http://localhost:5000/agent", + // No Version field + // protocolVersion at top level + Skills = [new AgentSkill { Id = "skill1", Name = "Skill", Description = "Desc" }], +}; +``` + +### V1 + +```csharp +var card = new AgentCard +{ + Name = "My Agent", + Description = "Does things.", + Version = "1.0.0", // Required in v1 + SupportedInterfaces = + [ + new AgentInterface + { + Url = "http://localhost:5000/agent", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0" + } + ], + DefaultInputModes = ["text/plain"], // Required in v1 + DefaultOutputModes = ["text/plain"], // Required in v1 + Capabilities = new AgentCapabilities { Streaming = true }, // Required in v1 + Skills = + [ + new AgentSkill + { + Id = "skill1", + Name = "Skill", + Description = "Description", + Tags = ["example"] // Required in v1 + } + ], +}; +``` + +**Key differences:** + +| Field | V0.3 | V1 | +|-------|------|-----| +| `Url` | Top-level string | Removed (use `SupportedInterfaces[0].Url`) | +| `Version` | Not present | Required `string` | +| `SupportedInterfaces` | Not present | Required `AgentInterface[]` | +| `ProtocolVersion` | Top-level | In `AgentInterface.ProtocolVersion` | +| `Icons` | `List?` (complex type) | `string? IconUrl` | +| `DocumentationUrl` | Not present | Optional `string?` | +| `Capabilities` | Optional | Required (non-nullable) | +| `Skills` | Optional | Required (non-nullable) | +| `DefaultInputModes` | Optional | Required (non-nullable) | +| `DefaultOutputModes` | Optional | Required (non-nullable) | +| `AgentSkill.Tags` | Optional | Required (non-nullable) | + +--- + +## Client API Changes + +### Interface + +```csharp +// V0.3 +public interface IA2AClient : IDisposable { ... } + +// V1 — IDisposable removed from interface +public interface IA2AClient { ... } + +// A2AClient (concrete) still implements IDisposable directly: +public sealed class A2AClient : IA2AClient, IDisposable { ... } +``` + +### Parameter Types + +V0.3 used inline parameter classes. V1 uses named request objects: + +```csharp +// V0.3 +var sendParams = new MessageSendParams +{ + Message = new AgentMessage { Role = MessageRole.User, Parts = [new TextPart { Text = "Hi" }] } +}; +var response = await client.SendMessageAsync(sendParams); + +// V1 +var request = new SendMessageRequest +{ + Message = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.User, + Parts = [Part.FromText("Hi")] + } +}; +var response = await client.SendMessageAsync(request); +``` + +### New Operations + +V1 adds these operations not available in v0.3: + +```csharp +// List tasks with pagination and filtering +var listResult = await client.ListTasksAsync(new ListTasksRequest +{ + ContextId = "my-context", + PageSize = 10, + Status = TaskState.Working, +}); + +// Delete push notification config +await client.DeleteTaskPushNotificationConfigAsync( + new DeleteTaskPushNotificationConfigRequest { TaskId = "t1", Id = "config1" }); + +// Get extended agent card +var extCard = await client.GetExtendedAgentCardAsync( + new GetExtendedAgentCardRequest()); +``` + +--- + +## Server API + +V1 introduces `ITaskManager` for server implementations. Use `TaskManager` as a base or implement the interface directly. + +```csharp +// Register handlers +var taskManager = new TaskManager(store, logger); + +taskManager.OnSendMessage = async (request, ct) => +{ + // Process the message and return a response + var task = await store.CreateTaskAsync(...); + return new SendMessageResponse { Task = task }; +}; + +taskManager.OnCancelTask = async (request, ct) => +{ + var task = await store.GetTaskAsync(request.Id); + await store.UpdateStatusAsync(task.Id, new TaskStatus { State = TaskState.Canceled }); + return task; +}; + +// Map endpoints +app.MapA2A(taskManager, "/agent"); +app.MapWellKnownAgentCard(agentCard); + +// Optional: also map REST API +app.MapHttpA2A(taskManager, agentCard); +``` + +--- + +## New V1 Features + +### ListTasks + +Full pagination, filtering, and sorting support: + +```csharp +var result = await client.ListTasksAsync(new ListTasksRequest +{ + ContextId = "conversation-123", + Status = TaskState.Working, + PageSize = 10, + PageToken = nextPageToken, + HistoryLength = 5, + IncludeArtifacts = true, + StatusTimestampAfter = DateTimeOffset.UtcNow.AddHours(-1), +}); + +foreach (var task in result.Tasks) +{ + Console.WriteLine($"{task.Id}: {task.Status.State}"); +} + +// Pagination +if (!string.IsNullOrEmpty(result.NextPageToken)) +{ + // Fetch next page... +} +``` + +### REST API + +V1 adds an HTTP+JSON REST binding alongside JSON-RPC: + +```csharp +// Server-side: map both bindings +app.MapA2A(taskManager, "/agent"); // JSON-RPC +app.MapHttpA2A(taskManager, agentCard); // REST API + +// REST endpoints: +// GET /v1/card +// GET /v1/tasks/{id} +// POST /v1/tasks/{id}:cancel +// GET /v1/tasks +// POST /v1/message:send +// POST /v1/message:stream +// ... and more +``` + +### Version Negotiation + +The JSON-RPC binding supports version negotiation via the `A2A-Version` header: + +- Empty or missing → accepted (defaults to current) +- `"0.3"` → accepted +- `"1.0"` → accepted +- Any other value → `VersionNotSupported` error (-32009) + +### ContentCase / PayloadCase Enums + +All proto `oneof` types have computed case enums for safe switching: + +- `Part.ContentCase` → `PartContentCase { None, Text, Raw, Url, Data }` +- `SendMessageResponse.PayloadCase` → `SendMessageResponseCase { None, Task, Message }` +- `StreamResponse.PayloadCase` → `StreamResponseCase { None, Task, Message, StatusUpdate, ArtifactUpdate }` +- `SecurityScheme.SchemeCase` → `SecuritySchemeCase { None, ApiKey, HttpAuth, OAuth2, OpenIdConnect, Mtls }` +- `OAuthFlows.FlowCase` → `OAuthFlowCase { None, AuthorizationCode, ClientCredentials, ... }` + +### New Error Codes + +| Code | Name | When | +|------|------|------| +| -32006 | `InvalidAgentResponse` | Internal agent response error | +| -32007 | `ExtendedAgentCardNotConfigured` | Extended card not available | +| -32008 | `ExtensionSupportRequired` | Extension not supported | +| -32009 | `VersionNotSupported` | Invalid A2A-Version header | + +--- + +## Backward Compatibility + +During migration, you can use the `A2A.V0_3` NuGet package for backward compatibility: + +```csharp +// V0.3 namespace +using A2A.V0_3; + +// V1 namespace +using A2A; + +// Both can coexist — use fully qualified names when ambiguous +var v03Part = new A2A.V0_3.TextPart { Text = "hello" }; +var v1Part = A2A.Part.FromText("hello"); +``` + +The `A2A.V0_3` package is a standalone snapshot of the v0.3 SDK. It has no dependencies on the v1 package and can be used indefinitely during the transition period. + +--- + +## Task Store (Event Sourcing) + +The v1 SDK replaces the mutable `ITaskStore` with an append-only `ITaskEventStore` backed by event sourcing. This change eliminates race conditions, enables spec-compliant subscribe/resubscribe, and fixes artifact append semantics. + +### What Changed + +| Before | After | +|--------|-------| +| `ITaskStore` (5 mutation methods) | `ITaskEventStore` (append-only + projection queries) | +| `InMemoryTaskStore` | `InMemoryEventStore` | +| `A2AServer(handler, ITaskStore, ...)` | `A2AServer(handler, ITaskEventStore, ...)` | +| `services.TryAddSingleton()` | `services.TryAddSingleton()` | + +### Default Registration (No Changes Needed) + +If you use `AddA2AAgent()` for DI registration, no code changes are required. The default registration now uses `InMemoryEventStore`: + +```csharp +// This still works — InMemoryEventStore is registered automatically +builder.Services.AddA2AAgent(agentCard); +``` + +### Custom Task Store Migration + +If you had a custom `ITaskStore`, implement `ITaskEventStore` directly: + +```csharp +// Before +services.AddSingleton(new MyCustomTaskStore()); +services.AddSingleton(sp => + new A2AServer(handler, sp.GetRequiredService(), logger)); + +// After — implement ITaskEventStore +services.AddSingleton(new MyCustomEventStore()); +services.AddSingleton(sp => + new A2AServer(handler, sp.GetRequiredService(), logger)); +``` + +### Manual A2AServer Construction + +If you construct `A2AServer` directly: + +```csharp +// Before +var store = new InMemoryTaskStore(); +var server = new A2AServer(handler, store, logger); + +// After +var eventStore = new InMemoryEventStore(); +var server = new A2AServer(handler, eventStore, logger); +``` + +### Benefits + +- **Subscribe/resubscribe**: `SubscribeToTaskAsync` now delivers catch-up events then live events, completing on terminal state +- **No race conditions**: Append-only design eliminates read-modify-write races in artifact persistence +- **Correct artifact semantics**: `append=true` extends parts, `append=false` adds or replaces by artifact ID +- **History alignment**: Superseded status messages are moved to history (Python SDK alignment) diff --git a/docs/taskmanager-migration-guide.md b/docs/taskmanager-migration-guide.md new file mode 100644 index 00000000..28113fdc --- /dev/null +++ b/docs/taskmanager-migration-guide.md @@ -0,0 +1,424 @@ +# TaskManager Migration Guide: v0.3 to v1 + +This guide helps you migrate existing A2A agent implementations from the v0.3 +`TaskManager` callback patterns to the v1 API. + +## Overview of changes + +The v1 `TaskManager` is fundamentally simpler. Instead of four lifecycle +callbacks and automatic task management, you get one main callback where you +own the entire response. + +| Aspect | v0.3 | v1 | +|--------|------|-----| +| Callbacks | `OnMessageReceived`, `OnTaskCreated`, `OnTaskCancelled`, `OnTaskUpdated` | `OnSendMessage`, `OnSendStreamingMessage`, `OnCancelTask` | +| Task creation | Automatic (TaskManager creates tasks) | Manual (your code creates tasks via `ITaskStore`) | +| Status updates | `taskManager.UpdateStatusAsync(taskId, state, message)` | `store.UpdateStatusAsync(taskId, status)` directly | +| Artifacts | `taskManager.ReturnArtifactAsync(taskId, artifact)` | Set `task.Artifacts` before returning | +| Agent card | `OnAgentCardQuery` callback | `MapWellKnownAgentCard()` extension method | +| Constructor | `TaskManager(HttpClient?, ITaskStore?)` | `TaskManager(ITaskStore, ILogger)` | +| Return type | `A2AResponse` (polymorphic: cast to `AgentTask`, `TaskStatusUpdateEvent`, etc.) | `SendMessageResponse` (set `.Message` or `.Task`) | + +## Step-by-step migration + +### Step 1: Update constructor and wiring + +**v0.3:** + +```csharp +var taskManager = new TaskManager(); +agent.Attach(taskManager); +app.MapA2A(taskManager, "/agent"); +``` + +**v1:** + +```csharp +var store = new InMemoryTaskStore(); +var logger = loggerFactory.CreateLogger(); +var taskManager = new TaskManager(store, logger); +agent.Attach(taskManager, store); // pass store to agent +app.MapA2A(taskManager, "/agent"); +app.MapWellKnownAgentCard(agentCard); // agent card served separately +``` + +Key differences: +- `ITaskStore` and `ILogger` are required constructor parameters. +- Your agent receives the `ITaskStore` so it can read/write tasks directly. +- Agent card is no longer served via a callback; use `MapWellKnownAgentCard()`. + +### Step 2: Replace OnAgentCardQuery + +**v0.3:** + +```csharp +taskManager.OnAgentCardQuery = (url, ct) => + Task.FromResult(new AgentCard { Name = "My Agent", Url = url, ... }); +``` + +**v1:** + +```csharp +// In Program.cs — static registration +var card = new AgentCard +{ + Name = "My Agent", + Version = "1.0.0", + SupportedInterfaces = [new AgentInterface + { + Url = "http://localhost:5000/agent", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0" + }], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities { Streaming = false }, + Skills = [new AgentSkill { Id = "main", Name = "Main", Description = "...", Tags = ["main"] }], +}; +app.MapWellKnownAgentCard(card); +``` + +### Step 3: Migrate simple message-only agents + +If your v0.3 agent used `OnMessageReceived` and returned a direct response +without creating tasks, migration is straightforward. + +**v0.3:** + +```csharp +taskManager.OnMessageReceived = async (msgParams, ct) => +{ + var text = msgParams.Message.Parts.OfType().First().Text; + return new AgentMessage + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = msgParams.Message.ContextId, + Parts = [new TextPart { Text = $"Echo: {text}" }] + }; +}; +``` + +**v1:** + +```csharp +taskManager.OnSendMessage = (request, ct) => +{ + var text = request.Message.Parts.FirstOrDefault(p => p.Text is not null)?.Text ?? ""; + var response = new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString("N"), + ContextId = request.Message.ContextId, + Parts = [Part.FromText($"Echo: {text}")] + }; + return Task.FromResult(new SendMessageResponse { Message = response }); +}; +``` + +What changed: +- `MessageSendParams` → `SendMessageRequest` +- `AgentMessage` → `Message` +- `MessageRole.Agent` → `Role.Agent` +- `TextPart { Text = ... }` → `Part.FromText(...)` +- Return `SendMessageResponse { Message = ... }` instead of bare `AgentMessage` + +### Step 4: Migrate task-based agents + +If your v0.3 agent used the automatic task lifecycle (OnTaskCreated, OnTaskUpdated, +UpdateStatusAsync, ReturnArtifactAsync), you need to restructure. + +**v0.3:** + +```csharp +// Agent received callbacks at different lifecycle stages +taskManager.OnTaskCreated = async (task, ct) => +{ + // Start processing + await taskManager.UpdateStatusAsync(task.Id, TaskState.Working, null, false, ct); + + // Do work... + var result = await DoWorkAsync(task, ct); + + // Return artifact + await taskManager.ReturnArtifactAsync(task.Id, new Artifact + { + ArtifactId = "result", + Parts = [new TextPart { Text = result }] + }); + + // Mark complete + await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, null, true, ct); +}; + +taskManager.OnTaskCancelled = async (task, ct) => +{ + // Cleanup... +}; +``` + +**v1:** + +```csharp +public void Attach(TaskManager taskManager, ITaskStore store) +{ + _store = store; + taskManager.OnSendMessage = OnSendMessageAsync; + taskManager.OnCancelTask = OnCancelTaskAsync; +} + +private async Task OnSendMessageAsync( + SendMessageRequest request, CancellationToken ct) +{ + // You create the task yourself + var taskId = Guid.NewGuid().ToString("N"); + var contextId = request.Message.ContextId ?? Guid.NewGuid().ToString("N"); + + // Do work... + var result = await DoWorkAsync(request, ct); + + // Build the complete task with status, history, and artifacts + var task = new AgentTask + { + Id = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Completed, + Timestamp = DateTimeOffset.UtcNow, + }, + History = [request.Message], + Artifacts = + [ + new Artifact + { + ArtifactId = Guid.NewGuid().ToString("N"), + Parts = [Part.FromText(result)] + } + ], + }; + + // Persist + await _store!.SetTaskAsync(task, ct); + + // Return the task directly + return new SendMessageResponse { Task = task }; +} + +private async Task OnCancelTaskAsync( + CancelTaskRequest request, CancellationToken ct) +{ + var task = await _store!.GetTaskAsync(request.Id, ct) + ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound); + + return await _store.UpdateStatusAsync(task.Id, new TaskStatus + { + State = TaskState.Canceled, + Timestamp = DateTimeOffset.UtcNow, + }, ct); +} +``` + +What changed: +- No more `OnTaskCreated`/`OnTaskUpdated` — everything happens in `OnSendMessage` +- You create `AgentTask` objects directly instead of calling `taskManager.CreateTaskAsync()` +- You set artifacts on the task object instead of calling `taskManager.ReturnArtifactAsync()` +- You persist via `ITaskStore` directly instead of `taskManager.UpdateStatusAsync()` +- `OnCancelTask` replaces `OnTaskCancelled` (note: different signature) + +### Step 5: Migrate multi-turn / stateful agents + +For agents that maintain conversation state across multiple messages: + +**v0.3:** + +```csharp +taskManager.OnTaskUpdated = async (task, ct) => +{ + // TaskManager auto-appended the new message to task.History + var lastMessage = task.History!.Last(); + // Process the follow-up message... + await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed); +}; +``` + +**v1:** + +```csharp +private async Task OnSendMessageAsync( + SendMessageRequest request, CancellationToken ct) +{ + // Check if this references an existing task + if (!string.IsNullOrEmpty(request.Message.TaskId)) + { + var existing = await _store!.GetTaskAsync(request.Message.TaskId, ct); + if (existing is not null) + { + // Append the new user message + await _store.AppendHistoryAsync(existing.Id, request.Message, ct); + + // Process the follow-up... + var reply = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.Agent, + TaskId = existing.Id, + ContextId = existing.ContextId, + Parts = [Part.FromText("Processed your follow-up.")], + }; + await _store.AppendHistoryAsync(existing.Id, reply, ct); + + // Update status + await _store.UpdateStatusAsync(existing.Id, new TaskStatus + { + State = TaskState.Completed, + Timestamp = DateTimeOffset.UtcNow, + }, ct); + + var updated = await _store.GetTaskAsync(existing.Id, ct); + return new SendMessageResponse { Task = updated }; + } + } + + // New conversation — create initial task + var task = new AgentTask { /* ... */ }; + await _store!.SetTaskAsync(task, ct); + return new SendMessageResponse { Task = task }; +} +``` + +Key insight: In v0.3, `TaskManager` routed messages to +`OnTaskCreated` (new) or `OnTaskUpdated` (existing) for you. In v1, your +single `OnSendMessage` callback handles both cases — check +`request.Message.TaskId` to distinguish them. + +### Step 6: Migrate streaming agents + +**v0.3:** + +```csharp +// TaskManager handled streaming internally via TaskUpdateEventEnumerator +// Agent pushed events through taskManager methods: +taskManager.OnTaskCreated = async (task, ct) => +{ + await taskManager.UpdateStatusAsync(task.Id, TaskState.Working); + // ... do work ... + await taskManager.ReturnArtifactAsync(task.Id, artifact); + await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, null, true); + // TaskManager routed these to the SSE stream automatically +}; +``` + +**v1:** + +```csharp +taskManager.OnSendStreamingMessage = (request, ct) => +{ + return StreamWorkAsync(request, ct); +}; + +private async IAsyncEnumerable StreamWorkAsync( + SendMessageRequest request, + [EnumeratorCancellation] CancellationToken ct) +{ + var taskId = Guid.NewGuid().ToString("N"); + var contextId = request.Message.ContextId ?? Guid.NewGuid().ToString("N"); + + // Create and persist the initial task + var task = new AgentTask + { + Id = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Working, + Timestamp = DateTimeOffset.UtcNow, + }, + History = [request.Message], + }; + await _store!.SetTaskAsync(task, ct); + + // Emit status update + yield return new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus { State = TaskState.Working } + } + }; + + // Do work and emit artifact chunks + var artifact = new Artifact + { + ArtifactId = Guid.NewGuid().ToString("N"), + Parts = [Part.FromText("Partial result...")] + }; + yield return new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Artifact = artifact, + Append = true, + LastChunk = false + } + }; + + // Persist final state with artifacts + task.Status = new TaskStatus + { + State = TaskState.Completed, + Timestamp = DateTimeOffset.UtcNow, + }; + task.Artifacts = [artifact]; + await _store.SetTaskAsync(task, ct); + + // Emit final task + yield return new StreamResponse { Task = task }; +} +``` + +What changed: +- Instead of calling `UpdateStatusAsync`/`ReturnArtifactAsync` on + TaskManager, you `yield return` `StreamResponse` objects. +- Each `StreamResponse` uses field-presence: set `.StatusUpdate`, + `.ArtifactUpdate`, `.Message`, or `.Task`. +- You control the stream directly via `IAsyncEnumerable`. + +## Type mapping quick reference + +| v0.3 type | v1 type | +|-----------|---------| +| `MessageSendParams` | `SendMessageRequest` | +| `AgentMessage` | `Message` | +| `MessageRole` | `Role` | +| `TextPart` | `Part.FromText(...)` | +| `FilePart` + `FileContent` | `Part.FromUrl(...)` or `Part.FromRaw(...)` | +| `DataPart` | `Part.FromData(...)` | +| `A2AResponse` | `SendMessageResponse` | +| `A2AEvent` | `StreamResponse` | +| `AgentTaskStatus` | `TaskStatus` | +| `TaskIdParams` | `GetTaskRequest` or `CancelTaskRequest` | +| `TaskQueryParams` | `GetTaskRequest` | + +## Common migration issues + +1. **`using TaskStatus = A2A.TaskStatus;`** — Add this to files that use + `TaskStatus`, since `System.Threading.Tasks.TaskStatus` conflicts. + +2. **`Part` is no longer abstract** — Replace `switch (part) { case TextPart: + ... }` with `switch (part.ContentCase) { case PartContentCase.Text: ... }`. + +3. **AgentCard has new required fields** — `Version`, + `SupportedInterfaces`, `Capabilities`, `Skills`, + `DefaultInputModes`, `DefaultOutputModes` are all required in v1. + +4. **TaskManager constructor requires ITaskStore and ILogger** — Both are + mandatory. Use `InMemoryTaskStore` for simple cases. + +5. **No more `Final` flag on streaming** — v0.3 used + `UpdateStatusAsync(..., final: true)` to signal end of stream. In v1 the + stream ends when your `IAsyncEnumerable` completes. diff --git a/pr-subscribe-separation.md b/pr-subscribe-separation.md new file mode 100644 index 00000000..e52d08de --- /dev/null +++ b/pr-subscribe-separation.md @@ -0,0 +1,132 @@ +# A2A .NET SDK v1 Implementation + +## Summary + +Complete implementation of the A2A v1 specification for the .NET SDK, including protocol models, server-side architecture redesign, simple CRUD task store, atomic subscribe, observability, and backward compatibility with v0.3. + +**239 files changed, 19,753 insertions, 8,769 deletions** across 14 commits. + +## How to Review + +This PR is large but logically layered. Recommended review order by commit: + +| # | Commit | Focus Area | Key Files | +|---|--------|-----------|-----------| +| 1 | `d223872` | **v1 spec models + SDK refactor** | `src/A2A/Models/`, client, v0.3 compat | +| 2 | `3ba763c` | Review findings fixes | Tests, docs, cleanup | +| 3 | `9e11c78` | **Security hardening** | `A2AHttpProcessor.cs`, `A2AJsonRpcProcessor.cs` | +| 4 | `0ebe07a` | **TFM update** (net10.0+net8.0) | `.csproj` files, removed polyfills | +| 5 | `ad6e513` | **Server architecture redesign** | `A2AServer.cs`, `IAgentHandler.cs`, DI, observability | +| 6 | `2f8e41f` | REST handler tracing | AspNetCore processors | +| 7 | `9979994` | Stale task fix | `MaterializeResponseAsync`, `CancelTaskAsync` | +| 8 | `03328b2` | Event sourcing (intermediate) | `IEventStore.cs`, `InMemoryEventStore.cs` | +| 9 | `bb859a1` | FileEventStore sample (intermediate) | `samples/AgentServer/FileEventStore.cs` | +| 10 | `31a57ba` | FileStoreDemo sample | `samples/FileStoreDemo/` | +| 11 | `6cc23bb` | Subscribe separation (intermediate) | `IEventSubscriber.cs`, `ChannelEventNotifier.cs` | +| 12 | `a7df8a6` | **Replace ES with CRUD ITaskStore** | `ITaskStore.cs`, `InMemoryTaskStore.cs`, atomic subscribe | +| 13 | `4cf8a59`+`f0bd87e` | **Cleanup** | `TaskCreatedCount` fix, `ApplyEventAsync` rename | +| 14 | `752caf0` | **CancelTask metadata** (spec late change) | `CancelTaskRequest.cs` | + +> **Note:** Commits 8-11 introduce event sourcing then commit 12 replaces it with a simpler CRUD store. This was an intentional design evolution — the event-sourcing approach was analyzed against the spec, found to be unnecessary burden for store implementors, and replaced with a 4-method CRUD interface + atomic per-task locking. See the [design discussion](#8-store-architecture-evolution) below. + +## Major Changes + +### 1. A2A v1 Spec Models (`d223872`) + +- Fix 16 proto fidelity gaps across 46 model types +- Add `ContentCase`/`PayloadCase` computed enums for all `oneof` types +- Implement full `ListTasks` with pagination, sorting, filtering +- Port REST API (`MapHttpA2A`) with 11 endpoints and SSE streaming +- Add `A2A-Version` header negotiation to JSON-RPC processor +- Create `A2A.V0_3` backward compatibility project (65 files, 255 tests) +- Add `docs/migration-guide-v1.md` (12 sections, 21 breaking changes documented) + +### 2. Security Hardening (`9e11c78`) + +- Sanitize raw `Exception.Message` in 5 HTTP/JSON-RPC error responses to prevent leaking internal details (stack traces, file paths, DB errors) +- Sanitize `JsonException.Message` in `DeserializeAndValidate` to avoid exposing internal type/path details +- `A2AException.Message` still exposed (intentional, app-defined errors) +- Add error handling to SSE streaming (best-effort JSON-RPC error event on failure) + +### 3. Target Framework Update (`0ebe07a`) + +- `net9.0;net8.0;netstandard2.0` → `net10.0;net8.0` across all projects +- Conditionally exclude `System.Linq.AsyncEnumerable` and `System.Net.ServerSentEvents` for net10.0 (now in-box) +- Remove 8 polyfill files and all `#if NET` conditional compilation blocks + +### 4. Server Architecture Redesign (`ad6e513`) + +**BREAKING**: `ITaskManager`/`TaskManager` → `IA2ARequestHandler`/`A2AServer` + +- **`A2AServer`**: Lifecycle orchestrator with context resolution, terminal state guards, event persistence, history management, and try/finally safety net preventing deadlocks +- **`IAgentHandler`**: Easy-path agent contract (`ExecuteAsync` + default `CancelAsync`) +- **`AgentEventQueue`**: Channel-backed event stream with typed `Enqueue*` methods +- **`TaskUpdater`**: Lifecycle convenience helper for all 8 task states +- **`A2ADiagnostics`**: `ActivitySource('A2A')` + `Meter('A2A')` with 9 metric instruments +- **DI**: `AddA2AAgent()` one-line registration + `MapA2A(path)` endpoint mapping +- All sample agents rewritten: 67-144 lines → 20-97 lines + +### 5. Simple CRUD Task Store (`a7df8a6`) + +Replace event sourcing with a 4-method `ITaskStore` interface matching the Python SDK pattern: + +```csharp +public interface ITaskStore +{ + Task GetTaskAsync(string taskId, ...); + Task SaveTaskAsync(string taskId, AgentTask task, ...); + Task DeleteTaskAsync(string taskId, ...); + Task ListTasksAsync(ListTasksRequest request, ...); +} +``` + +- **`InMemoryTaskStore`**: `ConcurrentDictionary` with deep clone +- **`FileTaskStore`** (sample): File-backed store with task JSON files + context indexes +- **`TaskProjection.Apply`**: Used by `A2AServer` to mutate task state before saving +- **Atomic subscribe**: Per-task `SemaphoreSlim` in `ChannelEventNotifier` guarantees no events missed between task snapshot and channel registration + +### 6. CancelTask Metadata (`752caf0`) + +- Add `Metadata` property to `CancelTaskRequest` (spec proto field 3, late addition) +- Wire `request.Metadata` into `AgentContext` in `CancelTaskAsync` + +### 7. FileStoreDemo Sample (`31a57ba`) + +- Self-contained demo showing data recovery after server restart +- Demonstrates `ListTasksAsync` by context filter across restarts + +## 8. Store Architecture Evolution + +The PR went through an intentional design evolution: + +1. **Event sourcing** (commits 8-11): Introduced `IEventStore`/`ITaskEventStore` with 7 methods, versioned event logs, `ChannelEventSubscriber` with catch-up replay +2. **Analysis**: Studied the A2A spec — it requires mutable task state, not event logs. The Python SDK uses 3 methods. Event sourcing imposed unnecessary complexity on every custom store implementor. +3. **CRUD store** (commit 12): Replaced with `ITaskStore` (4 methods). Subscribe race condition solved via atomic per-task locking instead of store-level version tracking. Net result: -422 lines. + +## Bug Fixes + +- **Stale task objects** (`9979994`): Re-fetch task from store after consuming all events +- **TaskCreatedCount** (`4cf8a59`): Only counts when a genuinely new task is created (store has no existing task) +- **AutoPersistEvents removed** (`f0bd87e`): Was a footgun — server doesn't function without applying events. Renamed `PersistEventAsync` → `ApplyEventAsync` +- **Bounded channel deadlock** (`ad6e513`): Run handler concurrently with consumer + +## Breaking Changes + +| Change | Commit | Mitigation | +|--------|--------|------------| +| `ITaskManager` → `IA2ARequestHandler` | `ad6e513` | Rename + update constructor | +| `TaskManager` → `A2AServer` | `ad6e513` | Use `AddA2AAgent()` DI helper | +| `ITaskStore` (new interface) | `a7df8a6` | 4 simple CRUD methods | +| TFM: netstandard2.0/net9.0 dropped | `0ebe07a` | Target net8.0 or net10.0 | +| `A2AServer` constructor requires `ChannelEventNotifier` | `a7df8a6` | Use DI (automatic) | +| `AutoPersistEvents` removed | `f0bd87e` | No longer configurable | + +## Validation + +| Check | Result | +|-------|--------| +| `dotnet build` | 0 errors, 0 warnings | +| `dotnet test` (net8.0 + net10.0) | 1,168 tests pass, 0 failures | +| TCK mandatory tests | 76/76 pass | +| FileStoreDemo data recovery | Works correctly | +| v0.3 backward compatibility | 262 tests pass per TFM | diff --git a/samples/A2ACli/A2ACli.csproj b/samples/A2ACli/A2ACli.csproj index 30ec74f9..d62564f4 100644 --- a/samples/A2ACli/A2ACli.csproj +++ b/samples/A2ACli/A2ACli.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 A2A A2A.Cli 12 diff --git a/samples/A2ACli/Host/A2ACli.cs b/samples/A2ACli/Host/A2ACli.cs index d9c6a4f9..07f28ae5 100644 --- a/samples/A2ACli/Host/A2ACli.cs +++ b/samples/A2ACli/Host/A2ACli.cs @@ -106,7 +106,7 @@ private static async Task RunCliAsync( int notificationReceiverPort = notificationReceiverUri.Port; // Create A2A client - var client = new A2AClient(new Uri(card.Url)); + var client = new A2AClient(new Uri(card.SupportedInterfaces.First().Url)); // Create or use provided session ID string sessionId = session == "0" ? Guid.NewGuid().ToString("N") : session; @@ -131,7 +131,7 @@ private static async Task RunCliAsync( if (history && continueLoop) { Console.WriteLine("========= history ======== "); - var taskResponse = await client.GetTaskAsync(taskId, cancellationToken); + var taskResponse = await client.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken); // Display history in a way similar to the Python version if (taskResponse.History != null) @@ -141,9 +141,9 @@ private static async Task RunCliAsync( s_indentOptions)); } taskResponse?.History? - .SelectMany(artifact => artifact.Parts.OfType()) + .SelectMany(msg => msg.Parts.Where(p => p.Text is not null)) .ToList() - .ForEach(textPart => Console.WriteLine(textPart.Text)); + .ForEach(part => Console.WriteLine(part.Text)); } } } @@ -179,15 +179,13 @@ private static async Task CompleteTaskAsync( } // Create message with text part - var message = new AgentMessage + var message = new Message { - Role = MessageRole.User, + Role = Role.User, + MessageId = Guid.NewGuid().ToString("N"), Parts = [ - new TextPart - { - Text = prompt - } + Part.FromText(prompt) ] }; @@ -199,16 +197,9 @@ private static async Task CompleteTaskAsync( try { byte[] fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken); - string fileContent = Convert.ToBase64String(fileBytes); string fileName = Path.GetFileName(filePath); - message.Parts.Add(new FilePart - { - File = new FileContent(fileContent) - { - Name = fileName, - } - }); + message.Parts.Add(Part.FromRaw(fileBytes, filename: fileName)); } catch (Exception ex) { @@ -217,7 +208,7 @@ private static async Task CompleteTaskAsync( } // Create payload for the task - var payload = new MessageSendParams() + var payload = new SendMessageRequest() { Configuration = new() { @@ -229,12 +220,12 @@ private static async Task CompleteTaskAsync( // Add push notification configuration if enabled if (usePushNotifications) { - payload.Configuration.PushNotification = new PushNotificationConfig + payload.Configuration.PushNotificationConfig = new PushNotificationConfig { Url = $"http://{notificationReceiverHost}:{notificationReceiverPort}/notify", - Authentication = new PushNotificationAuthenticationInfo + Authentication = new AuthenticationInfo { - Schemes = ["bearer"] + Scheme = "bearer" } }; } @@ -251,21 +242,22 @@ private static async Task CompleteTaskAsync( Console.WriteLine($"Send task payload => {JsonSerializer.Serialize(payload, jsonOptions)}"); if (streaming) { - await foreach (var result in client.SendMessageStreamingAsync(payload, cancellationToken)) + await foreach (var result in client.SendStreamingMessageAsync(payload, cancellationToken)) { Console.WriteLine($"Stream event => {JsonSerializer.Serialize(result, jsonOptions)}"); } - var taskResult = await client.GetTaskAsync(taskId, cancellationToken); + var taskResult = await client.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken); } else { - agentTask = await client.SendMessageAsync(payload, cancellationToken) as AgentTask; + var response = await client.SendMessageAsync(payload, cancellationToken); + agentTask = response.Task; Console.WriteLine($"\n{JsonSerializer.Serialize(agentTask, jsonOptions)}"); agentTask?.Artifacts? - .SelectMany(artifact => artifact.Parts.OfType()) + .SelectMany(artifact => artifact.Parts.Where(p => p.Text is not null)) .ToList() - .ForEach(textPart => Console.WriteLine(textPart.Text)); + .ForEach(part => Console.WriteLine(part.Text)); } // If the task requires more input, continue the interaction diff --git a/samples/AgentClient/AgentClient.csproj b/samples/AgentClient/AgentClient.csproj index 446db224..434fa1e9 100644 --- a/samples/AgentClient/AgentClient.csproj +++ b/samples/AgentClient/AgentClient.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable $(NoWarn);CA1869;RCS1141 enable diff --git a/samples/AgentClient/Samples/GetAgentDetailsSample.cs b/samples/AgentClient/Samples/GetAgentDetailsSample.cs index 72b04ea3..fb703d1b 100644 --- a/samples/AgentClient/Samples/GetAgentDetailsSample.cs +++ b/samples/AgentClient/Samples/GetAgentDetailsSample.cs @@ -32,6 +32,11 @@ namespace AgentClient.Samples; /// internal sealed class GetAgentDetailsSample { + private static readonly JsonSerializerOptions s_indentedOptions = new(A2AJsonUtilities.DefaultOptions) + { + WriteIndented = true + }; + /// /// Demonstrates how to retrieve agent details using the . /// @@ -59,9 +64,6 @@ public static async Task RunAsync() // 4. Display agent details Console.WriteLine("\nAgent card details:"); - Console.WriteLine(JsonSerializer.Serialize(agentCard, new JsonSerializerOptions(A2AJsonUtilities.DefaultOptions) - { - WriteIndented = true - })); + Console.WriteLine(JsonSerializer.Serialize(agentCard, s_indentedOptions)); } } diff --git a/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs b/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs index ea9a8c7c..45485100 100644 --- a/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs +++ b/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs @@ -1,5 +1,4 @@ using A2A; -using System.Net.ServerSentEvents; namespace AgentClient.Samples; @@ -57,41 +56,36 @@ public static async Task RunAsync() AgentCard echoAgentCard = await cardResolver.GetAgentCardAsync(); // 2. Create an A2A client to communicate with the agent using url from the agent card - A2AClient agentClient = new(new Uri(echoAgentCard.Url)); + A2AClient agentClient = new(new Uri(echoAgentCard.SupportedInterfaces[0].Url)); // 3. Create a message to send to the agent - AgentMessage userMessage = new() + Message userMessage = new() { - Role = MessageRole.User, + Role = Role.User, MessageId = Guid.NewGuid().ToString(), - Parts = [ - new TextPart - { - Text = "Hello from the message-based communication sample! Please echo this message." - } - ] + Parts = [Part.FromText("Hello from the message-based communication sample! Please echo this message.")] }; // 4. Send the message using non-streaming API await SendMessageAsync(agentClient, userMessage); // 5. Send the message using streaming API - await SendMessageStreamingAsync(agentClient, userMessage); + await SendStreamingMessageAsync(agentClient, userMessage); } /// /// Demonstrates non-streaming message communication with an A2A agent. /// - private static async Task SendMessageAsync(A2AClient agentClient, AgentMessage userMessage) + private static async Task SendMessageAsync(A2AClient agentClient, Message userMessage) { Console.WriteLine("\nNon-Streaming Message Communication"); - Console.WriteLine($" Sending message via non-streaming API: {((TextPart)userMessage.Parts[0]).Text}"); + Console.WriteLine($" Sending message via non-streaming API: {userMessage.Parts[0].Text}"); // Send the message and get the response - AgentMessage agentResponse = (AgentMessage)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage }); + SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage }); // Display the response - Console.WriteLine($" Received complete response from agent: {((TextPart)agentResponse.Parts[0]).Text}"); + Console.WriteLine($" Received complete response from agent: {response.Message!.Parts[0].Text}"); } /// @@ -100,18 +94,19 @@ private static async Task SendMessageAsync(A2AClient agentClient, AgentMessage u /// The A2A client for communicating with the agent. /// The message to send to the agent. /// A task representing the asynchronous operation. - private static async Task SendMessageStreamingAsync(A2AClient agentClient, AgentMessage userMessage) + private static async Task SendStreamingMessageAsync(A2AClient agentClient, Message userMessage) { Console.WriteLine("\nStreaming Message Communication"); - Console.WriteLine($" Sending message via streaming API: {((TextPart)userMessage.Parts[0]).Text}"); + Console.WriteLine($" Sending message via streaming API: {userMessage.Parts[0].Text}"); // Send the message and get the response as a stream - await foreach (SseItem sseItem in agentClient.SendMessageStreamingAsync(new MessageSendParams { Message = userMessage })) + await foreach (StreamResponse streamResponse in agentClient.SendStreamingMessageAsync(new SendMessageRequest { Message = userMessage })) { - AgentMessage agentResponse = (AgentMessage)sseItem.Data; - // Display each part of the response as it arrives - Console.WriteLine($" Received streaming response chunk: {((TextPart)agentResponse.Parts[0]).Text}"); + if (streamResponse.Message is { } message) + { + Console.WriteLine($" Received streaming response chunk: {message.Parts[0].Text}"); + } } } } \ No newline at end of file diff --git a/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs b/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs index 0b3d3acb..1bffef29 100644 --- a/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs +++ b/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs @@ -58,7 +58,7 @@ public static async Task RunAsync() AgentCard echoAgentCard = await cardResolver.GetAgentCardAsync(); // 3. Create an A2A client to communicate with the echotasks agent using the URL from the agent card - A2AClient agentClient = new(new Uri(echoAgentCard.Url)); + A2AClient agentClient = new(new Uri(echoAgentCard.SupportedInterfaces[0].Url)); // 4. Demo a short-lived task await DemoShortLivedTaskAsync(agentClient); @@ -74,15 +74,16 @@ private static async Task DemoShortLivedTaskAsync(A2AClient agentClient) { Console.WriteLine("\nShort-lived Task"); - AgentMessage userMessage = new() + Message userMessage = new() { - Parts = [new TextPart { Text = "Hello from a short-lived task sample!" }], - Role = MessageRole.User + Parts = [Part.FromText("Hello from a short-lived task sample!")], + Role = Role.User, + MessageId = Guid.NewGuid().ToString("N") }; - Console.WriteLine($" Sending message to the agent: {((TextPart)userMessage.Parts[0]).Text}"); - AgentTask agentResponse = (AgentTask)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage }); - DisplayTaskDetails(agentResponse); + Console.WriteLine($" Sending message to the agent: {userMessage.Parts[0].Text}"); + SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage }); + DisplayTaskDetails(response.Task!); } /// @@ -92,10 +93,11 @@ private static async Task DemoLongRunningTaskAsync(A2AClient agentClient) { Console.WriteLine("\nLong-running Task"); - AgentMessage userMessage = new() + Message userMessage = new() { - Parts = [new TextPart { Text = "Hello from a long-running task sample!" }], - Role = MessageRole.User, + Parts = [Part.FromText("Hello from a long-running task sample!")], + Role = Role.User, + MessageId = Guid.NewGuid().ToString("N"), Metadata = new Dictionary { // Tweaking the agent behavior to simulate a long-running task; @@ -105,18 +107,19 @@ private static async Task DemoLongRunningTaskAsync(A2AClient agentClient) }; // 1. Create a new task by sending the message to the agent - Console.WriteLine($" Sending message to the agent: {((TextPart)userMessage.Parts[0]).Text}"); - AgentTask agentResponse = (AgentTask)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage }); + Console.WriteLine($" Sending message to the agent: {userMessage.Parts[0].Text}"); + SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage }); + AgentTask agentResponse = response.Task!; DisplayTaskDetails(agentResponse); // 2. Retrieve the task Console.WriteLine($"\n Retrieving the task by ID: {agentResponse.Id}"); - agentResponse = await agentClient.GetTaskAsync(agentResponse.Id); + agentResponse = await agentClient.GetTaskAsync(new GetTaskRequest { Id = agentResponse.Id }); DisplayTaskDetails(agentResponse); // 3. Cancel the task Console.WriteLine($"\n Cancel the task with ID: {agentResponse.Id}"); - AgentTask cancelledTask = await agentClient.CancelTaskAsync(new TaskIdParams { Id = agentResponse.Id }); + AgentTask cancelledTask = await agentClient.CancelTaskAsync(new CancelTaskRequest { Id = agentResponse.Id }); DisplayTaskDetails(cancelledTask); } @@ -125,6 +128,6 @@ private static void DisplayTaskDetails(AgentTask agentResponse) Console.WriteLine(" Received task details:"); Console.WriteLine($" ID: {agentResponse.Id}"); Console.WriteLine($" Status: {agentResponse.Status.State}"); - Console.WriteLine($" Artifact: {(agentResponse.Artifacts?[0].Parts?[0] as TextPart)?.Text}"); + Console.WriteLine($" Artifact: {agentResponse.Artifacts?[0].Parts?[0].Text}"); } } diff --git a/samples/AgentServer/AgentServer.csproj b/samples/AgentServer/AgentServer.csproj index 63f6d317..af19b4ca 100644 --- a/samples/AgentServer/AgentServer.csproj +++ b/samples/AgentServer/AgentServer.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/samples/AgentServer/EchoAgent.cs b/samples/AgentServer/EchoAgent.cs index 6729ef93..663ebedc 100644 --- a/samples/AgentServer/EchoAgent.cs +++ b/samples/AgentServer/EchoAgent.cs @@ -1,62 +1,46 @@ -using A2A; - -namespace AgentServer; - -public class EchoAgent -{ - public void Attach(ITaskManager taskManager) - { - taskManager.OnMessageReceived = ProcessMessageAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; - } - - private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - // Process the message - var messageText = messageSendParams.Message.Parts.OfType().First().Text; - - // Create and return an artifact - var message = new AgentMessage() - { - Role = MessageRole.Agent, - MessageId = Guid.NewGuid().ToString(), - ContextId = messageSendParams.Message.ContextId, - Parts = [new TextPart() { - Text = $"Echo: {messageText}" - }] - }; - - return Task.FromResult(message); - } - - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "Echo Agent", - Description = "Agent which will echo every message it receives.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); - } +using A2A; + +namespace AgentServer; + +public sealed class EchoAgent : IAgentHandler +{ + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var responder = new MessageResponder(eventQueue, context.ContextId); + await responder.ReplyAsync($"Echo: {context.UserText}", cancellationToken); + } + + public static AgentCard GetAgentCard(string agentUrl) => + new() + { + Name = "Echo Agent", + Description = "Agent which will echo every message it receives.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface + { + Url = agentUrl, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + }, + Skills = + [ + new AgentSkill + { + Id = "echo", + Name = "Echo", + Description = "Echoes back the user message.", + Tags = ["echo", "test"], + } + ], + }; } \ No newline at end of file diff --git a/samples/AgentServer/EchoAgentWithTasks.cs b/samples/AgentServer/EchoAgentWithTasks.cs index 2e83bfcd..872adb7a 100644 --- a/samples/AgentServer/EchoAgentWithTasks.cs +++ b/samples/AgentServer/EchoAgentWithTasks.cs @@ -1,82 +1,83 @@ -using A2A; -using System.Text.Json; - -namespace AgentServer; - -public class EchoAgentWithTasks -{ - private ITaskManager? _taskManager; - - public void Attach(ITaskManager taskManager) - { - _taskManager = taskManager; - taskManager.OnTaskCreated = ProcessMessageAsync; - taskManager.OnTaskUpdated = ProcessMessageAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; - } - - private async Task ProcessMessageAsync(AgentTask task, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Process the message - var lastMessage = task.History!.Last(); - var messageText = lastMessage.Parts.OfType().First().Text; - - // Check for target-state metadata to determine task behavior - TaskState targetState = GetTargetStateFromMetadata(lastMessage.Metadata) ?? TaskState.Completed; - - // This is a short-lived task - complete it immediately - await _taskManager!.ReturnArtifactAsync(task.Id, new Artifact() - { - Parts = [new TextPart() { - Text = $"Echo: {messageText}" - }] - }, cancellationToken); - - await _taskManager!.UpdateStatusAsync( - task.Id, - status: targetState, - final: targetState is TaskState.Completed or TaskState.Canceled or TaskState.Failed or TaskState.Rejected, - cancellationToken: cancellationToken); - } - - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "Echo Agent", - Description = "Agent which will echo every message it receives.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); - } - - private static TaskState? GetTargetStateFromMetadata(Dictionary? metadata) - { - if (metadata?.TryGetValue("task-target-state", out var targetStateElement) == true) - { - if (Enum.TryParse(targetStateElement.GetString(), true, out var state)) - { - return state; - } - } - - return null; - } +using A2A; +using System.Text.Json; + +namespace AgentServer; + +public sealed class EchoAgentWithTasks : IAgentHandler +{ + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var targetState = GetTargetStateFromMetadata(context.Message.Metadata); + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + + await updater.SubmitAsync(cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText($"Echo: {context.UserText}")], cancellationToken: cancellationToken); + + // Transition to the target state (defaults to Completed) + switch (targetState) + { + case TaskState.Failed: + await updater.FailAsync(cancellationToken: cancellationToken); + break; + case TaskState.Canceled: + await updater.CancelAsync(cancellationToken); + break; + case TaskState.InputRequired: + await updater.RequireInputAsync( + new Message { Role = Role.Agent, MessageId = Guid.NewGuid().ToString("N"), Parts = [Part.FromText("Need input")] }, + cancellationToken); + break; + default: + await updater.CompleteAsync(cancellationToken: cancellationToken); + break; + } + } + + public static AgentCard GetAgentCard(string agentUrl) => + new() + { + Name = "Echo Agent", + Description = "Agent which will echo every message it receives.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface + { + Url = agentUrl, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + }, + Skills = + [ + new AgentSkill + { + Id = "echo", + Name = "Echo", + Description = "Echoes back the user message with task tracking.", + Tags = ["echo", "test"], + } + ], + }; + + private static TaskState? GetTargetStateFromMetadata(Dictionary? metadata) + { + if (metadata?.TryGetValue("task-target-state", out var targetStateElement) == true) + { + if (Enum.TryParse(targetStateElement.GetString(), true, out var state)) + { + return state; + } + } + + return null; + } } \ No newline at end of file diff --git a/samples/AgentServer/FileTaskStore.cs b/samples/AgentServer/FileTaskStore.cs new file mode 100644 index 00000000..9a2dcc08 --- /dev/null +++ b/samples/AgentServer/FileTaskStore.cs @@ -0,0 +1,218 @@ +using A2A; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace AgentServer; + +/// +/// File-backed task store with per-task JSON files and context index files +/// for efficient . +/// +/// Storage layout: +/// +/// {baseDir}/ +/// tasks/{taskId}.json — materialized AgentTask snapshot +/// indexes/context_{id}.idx — newline-delimited task IDs per context +/// +/// +/// +public sealed class FileTaskStore : ITaskStore +{ + private readonly string _tasksDir; + private readonly string _indexesDir; + + // Compact JSON for storage + private static readonly JsonSerializerOptions s_storageOptions = new(A2AJsonUtilities.DefaultOptions) + { + WriteIndented = false, + }; + + // Per-task lock to serialize writes (prevents interleaved writes to the same file) + private readonly ConcurrentDictionary _taskLocks = new(); + + public FileTaskStore(string baseDir) + { + _tasksDir = Path.Combine(baseDir, "tasks"); + _indexesDir = Path.Combine(baseDir, "indexes"); + + Directory.CreateDirectory(_tasksDir); + Directory.CreateDirectory(_indexesDir); + } + + /// + public async Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + return await ReadTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default) + { + var taskLock = _taskLocks.GetOrAdd(taskId, _ => new SemaphoreSlim(1, 1)); + await taskLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await WriteTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false); + + // Update context index on save (idempotent) + if (!string.IsNullOrEmpty(task.ContextId)) + { + await AppendToIndexAsync("context", task.ContextId, taskId, cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + taskLock.Release(); + } + } + + /// + public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + var path = GetTaskFilePath(taskId); + if (File.Exists(path)) + File.Delete(path); + return Task.CompletedTask; + } + + /// + public async Task ListTasksAsync(ListTasksRequest request, + CancellationToken cancellationToken = default) + { + // Step 1: Find candidate task IDs from index files + var taskIds = await GetTaskIdsForQueryAsync(request, cancellationToken).ConfigureAwait(false); + + // Step 2: Load tasks for candidates + var tasks = new List(); + foreach (var taskId in taskIds) + { + var task = await ReadTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + if (task is null) continue; + + // Apply filters that aren't covered by the index + if (request.Status is { } statusFilter && task.Status.State != statusFilter) + continue; + if (request.StatusTimestampAfter is not null && + (task.Status.Timestamp is null || task.Status.Timestamp <= request.StatusTimestampAfter)) + continue; + + tasks.Add(task); + } + + // Step 3: Sort, paginate, trim + tasks.Sort((a, b) => + (b.Status.Timestamp ?? DateTimeOffset.MinValue) + .CompareTo(a.Status.Timestamp ?? DateTimeOffset.MinValue)); + + var totalSize = tasks.Count; + + int startIndex = 0; + if (!string.IsNullOrEmpty(request.PageToken)) + { + if (!int.TryParse(request.PageToken, out var offset) || offset < 0) + throw new A2AException($"Invalid pageToken: {request.PageToken}", A2AErrorCode.InvalidParams); + startIndex = offset; + } + + var pageSize = request.PageSize ?? 50; + var page = tasks.Skip(startIndex).Take(pageSize).ToList(); + + // Trim history/artifacts per request + foreach (var task in page) + { + if (request.HistoryLength is { } historyLength) + { + if (historyLength == 0) + task.History = null; + else if (task.History is { Count: > 0 }) + task.History = task.History.TakeLast(historyLength).ToList(); + } + + if (request.IncludeArtifacts is not true) + task.Artifacts = null; + } + + var nextIndex = startIndex + page.Count; + return new ListTasksResponse + { + Tasks = page, + NextPageToken = nextIndex < totalSize + ? nextIndex.ToString(System.Globalization.CultureInfo.InvariantCulture) + : string.Empty, + PageSize = page.Count, + TotalSize = totalSize, + }; + } + + // ─── File I/O Helpers ─── + + private string GetTaskFilePath(string taskId) => Path.Combine(_tasksDir, $"{taskId}.json"); + private string GetIndexFilePath(string prefix, string id) => Path.Combine(_indexesDir, $"{prefix}_{id}.idx"); + + private async Task ReadTaskAsync(string taskId, CancellationToken ct) + { + var path = GetTaskFilePath(taskId); + if (!File.Exists(path)) return null; + + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, A2AJsonUtilities.DefaultOptions, ct) + .ConfigureAwait(false); + } + + private async Task WriteTaskAsync(string taskId, AgentTask task, CancellationToken ct) + { + var path = GetTaskFilePath(taskId); + var tempPath = path + ".tmp"; + + await using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync(stream, task, A2AJsonUtilities.DefaultOptions, ct) + .ConfigureAwait(false); + } + + File.Move(tempPath, path, overwrite: true); + } + + private async Task AppendToIndexAsync(string prefix, string id, string taskId, CancellationToken ct) + { + if (string.IsNullOrEmpty(id)) return; + + var indexPath = GetIndexFilePath(prefix, id); + + // Check if task is already in the index (avoid duplicates) + if (File.Exists(indexPath)) + { + var existing = await File.ReadAllTextAsync(indexPath, ct).ConfigureAwait(false); + if (existing.Contains(taskId, StringComparison.Ordinal)) + return; + } + + await File.AppendAllTextAsync(indexPath, taskId + Environment.NewLine, ct).ConfigureAwait(false); + } + + private async Task> GetTaskIdsForQueryAsync( + ListTasksRequest request, CancellationToken ct) + { + // Use the most specific index available + if (!string.IsNullOrEmpty(request.ContextId)) + return await ReadIndexAsync("context", request.ContextId, ct).ConfigureAwait(false); + + // No index match — scan all task files + return Directory.GetFiles(_tasksDir, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => name is not null) + .Cast() + .ToList(); + } + + private async Task> ReadIndexAsync(string prefix, string id, CancellationToken ct) + { + var path = GetIndexFilePath(prefix, id); + if (!File.Exists(path)) return []; + + var content = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + return content.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + } +} diff --git a/samples/AgentServer/Program.cs b/samples/AgentServer/Program.cs index fa104348..33a9cc0a 100644 --- a/samples/AgentServer/Program.cs +++ b/samples/AgentServer/Program.cs @@ -1,93 +1,100 @@ -using A2A; -using A2A.AspNetCore; -using AgentServer; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; - -var builder = WebApplication.CreateBuilder(args); - -// Configure OpenTelemetry -builder.Services.AddOpenTelemetry() - .ConfigureResource(resource => resource.AddService("A2AAgentServer")) - .WithTracing(tracing => tracing - .AddSource(TaskManager.ActivitySource.Name) - .AddSource(A2AJsonRpcProcessor.ActivitySource.Name) - .AddSource(ResearcherAgent.ActivitySource.Name) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddConsoleExporter() - .AddOtlpExporter(options => - { - options.Endpoint = new Uri("http://localhost:4317"); - options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; - }) - ); - -var app = builder.Build(); - -app.UseHttpsRedirection(); - -// Add health endpoint -app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow })); - -// Get the agent type from command line arguments -var agentType = GetAgentTypeFromArgs(args); - -// Create and register the specified agent -var taskManager = new TaskManager(); - -switch (agentType.ToLowerInvariant()) -{ - case "echo": - var echoAgent = new EchoAgent(); - echoAgent.Attach(taskManager); - app.MapA2A(taskManager, "/echo"); - app.MapWellKnownAgentCard(taskManager, "/echo"); - app.MapHttpA2A(taskManager, "/echo"); - break; - - case "echotasks": - var echoAgentWithTasks = new EchoAgentWithTasks(); - echoAgentWithTasks.Attach(taskManager); - app.MapA2A(taskManager, "/echotasks"); - app.MapWellKnownAgentCard(taskManager, "/echotasks"); - app.MapHttpA2A(taskManager, "/echotasks"); - break; - - case "researcher": - var researcherAgent = new ResearcherAgent(); - researcherAgent.Attach(taskManager); - app.MapA2A(taskManager, "/researcher"); - app.MapWellKnownAgentCard(taskManager, "/researcher"); - break; - - case "speccompliance": - var specComplianceAgent = new SpecComplianceAgent(); - specComplianceAgent.Attach(taskManager); - app.MapA2A(taskManager, "/speccompliance"); - app.MapWellKnownAgentCard(taskManager, "/speccompliance"); - break; - - default: - Console.WriteLine($"Unknown agent type: {agentType}"); - Environment.Exit(1); - return; -} - -app.Run(); - -static string GetAgentTypeFromArgs(string[] args) -{ - // Look for --agent parameter - for (int i = 0; i < args.Length - 1; i++) - { - if (args[i] == "--agent" || args[i] == "-a") - { - return args[i + 1]; - } - } - - // Default to echo if no agent specified - Console.WriteLine("No agent specified. Use --agent or -a parameter to specify agent type (echo, echotasks, researcher, speccompliance). Defaulting to 'echo'."); - return "echo"; +using A2A; +using A2A.AspNetCore; +using AgentServer; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +var builder = WebApplication.CreateBuilder(args); + +// Get the agent type and store type from command line arguments +var agentType = GetArgValue(args, "--agent", "-a") ?? "echo"; +var storeType = GetArgValue(args, "--store", "-s"); +var baseUrl = "http://localhost:5048"; + +// Register file-backed task store if requested (before AddA2AAgent so TryAddSingleton picks it up) +if (storeType?.Equals("file", StringComparison.OrdinalIgnoreCase) == true) +{ + var dataDir = GetArgValue(args, "--data-dir", "-d") ?? Path.Combine(Directory.GetCurrentDirectory(), "a2a-data"); + Console.WriteLine($"Using FileTaskStore at: {dataDir}"); + builder.Services.AddSingleton(sp => + new FileTaskStore(dataDir)); +} + +// Register the appropriate agent via DI +switch (agentType.ToLowerInvariant()) +{ + case "echo": + builder.Services.AddA2AAgent(EchoAgent.GetAgentCard($"{baseUrl}/echo")); + break; + + case "echotasks": + builder.Services.AddA2AAgent(EchoAgentWithTasks.GetAgentCard($"{baseUrl}/echotasks")); + break; + + case "researcher": + builder.Services.AddA2AAgent(ResearcherAgent.GetAgentCard($"{baseUrl}/researcher")); + break; + + case "speccompliance": + builder.Services.AddA2AAgent(SpecComplianceAgent.GetAgentCard($"{baseUrl}/speccompliance")); + break; + + default: + Console.WriteLine($"Unknown agent type: {agentType}"); + Environment.Exit(1); + return; +} + +// Configure OpenTelemetry +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("A2AAgentServer")) + .WithTracing(tracing => tracing + .AddSource("A2A") + .AddSource("A2A.AspNetCore") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddConsoleExporter() + .AddOtlpExporter(options => + { + options.Endpoint = new Uri("http://localhost:4317"); + options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; + }) + ); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +// Add health endpoint +app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow })); + +// Map A2A endpoints using DI-registered services +var path = agentType.ToLowerInvariant() switch +{ + "echo" => "/echo", + "echotasks" => "/echotasks", + "researcher" => "/researcher", + "speccompliance" => "/speccompliance", + _ => "/agent", +}; + +app.MapA2A(path); + +// For spec compliance, also map at root for well-known agent card discovery +if (agentType.Equals("speccompliance", StringComparison.OrdinalIgnoreCase)) +{ + var card = app.Services.GetRequiredService(); + app.MapWellKnownAgentCard(card); +} + +app.Run(); + +static string? GetArgValue(string[] args, string longName, string shortName) +{ + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i] == longName || args[i] == shortName) + return args[i + 1]; + } + return null; } \ No newline at end of file diff --git a/samples/AgentServer/ResearcherAgent.cs b/samples/AgentServer/ResearcherAgent.cs index 6b5408f6..de9403a8 100644 --- a/samples/AgentServer/ResearcherAgent.cs +++ b/samples/AgentServer/ResearcherAgent.cs @@ -1,171 +1,98 @@ -using System.Diagnostics; - -namespace A2A; - -public class ResearcherAgent -{ - private ITaskManager? _taskManager; - private readonly Dictionary _agentStates = []; - public static readonly ActivitySource ActivitySource = new("A2A.ResearcherAgent", "1.0.0"); - - private enum AgentState - { - Planning, - WaitingForFeedbackOnPlan, - Researching - } - - public void Attach(ITaskManager taskManager) - { - _taskManager = taskManager; - _taskManager.OnTaskCreated = async (task, cancellationToken) => - { - // Initialize the agent state for the task - _agentStates[task.Id] = AgentState.Planning; - // Ignore other content in the task, just assume it is a text message. - var message = ((TextPart?)task.History?.Last()?.Parts?.FirstOrDefault())?.Text ?? string.Empty; - await InvokeAsync(task.Id, message, cancellationToken); - }; - _taskManager.OnTaskUpdated = async (task, cancellationToken) => - { - // Note that the updated callback is helpful to know not to initialize the agent state again. - var message = ((TextPart?)task.History?.Last()?.Parts?.FirstOrDefault())?.Text ?? string.Empty; - await InvokeAsync(task.Id, message, cancellationToken); - }; - _taskManager.OnAgentCardQuery = GetAgentCardAsync; - } - - // This is the main entry point for the agent. It is called when a task is created or updated. - // It probably should have a cancellation token to enable the process to be cancelled. - public async Task InvokeAsync(string taskId, string message, CancellationToken cancellationToken) - { - if (_taskManager == null) - { - throw new InvalidOperationException("TaskManager is not attached."); - } - - using var activity = ActivitySource.StartActivity("Invoke", ActivityKind.Server); - activity?.SetTag("task.id", taskId); - activity?.SetTag("message", message); - activity?.SetTag("state", _agentStates[taskId].ToString()); - - switch (_agentStates[taskId]) - { - case AgentState.Planning: - await DoPlanningAsync(taskId, message, cancellationToken); - await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage() - { - Parts = [new TextPart() { Text = "When ready say go ahead" }], - }, - cancellationToken: cancellationToken); - break; - case AgentState.WaitingForFeedbackOnPlan: - if (message == "go ahead") // Dumb check for now to avoid using an LLM - { - await DoResearchAsync(taskId, message, cancellationToken); - } - else - { - // Take the message and redo planning - await DoPlanningAsync(taskId, message, cancellationToken); - await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage() - { - Parts = [new TextPart() { Text = "When ready say go ahead" }], - }, - cancellationToken: cancellationToken); - } - break; - case AgentState.Researching: - await DoResearchAsync(taskId, message, cancellationToken); - break; - } - } - - private async Task DoResearchAsync(string taskId, string message, CancellationToken cancellationToken) - { - if (_taskManager == null) - { - throw new InvalidOperationException("TaskManager is not attached."); - } - - using var activity = ActivitySource.StartActivity("DoResearch", ActivityKind.Server); - activity?.SetTag("task.id", taskId); - activity?.SetTag("message", message); - - _agentStates[taskId] = AgentState.Researching; - await _taskManager.UpdateStatusAsync(taskId, TaskState.Working, cancellationToken: cancellationToken); - - await _taskManager.ReturnArtifactAsync( - taskId, - new Artifact() - { - Parts = [new TextPart() { Text = $"{message} received." }], - }, - cancellationToken); - - await _taskManager.UpdateStatusAsync(taskId, TaskState.Completed, new AgentMessage() - { - Parts = [new TextPart() { Text = "Task completed successfully" }], - }, - cancellationToken: cancellationToken); - } - private async Task DoPlanningAsync(string taskId, string message, CancellationToken cancellationToken) - { - if (_taskManager == null) - { - throw new InvalidOperationException("TaskManager is not attached."); - } - - using var activity = ActivitySource.StartActivity("DoPlanning", ActivityKind.Server); - activity?.SetTag("task.id", taskId); - activity?.SetTag("message", message); - - // Task should be in status Submitted - // Simulate being in a queue for a while - await Task.Delay(1000, cancellationToken); - - // Simulate processing the task - await _taskManager.UpdateStatusAsync(taskId, TaskState.Working, cancellationToken: cancellationToken); - - await _taskManager.ReturnArtifactAsync( - taskId, - new Artifact() - { - Parts = [new TextPart() { Text = $"{message} received." }], - }, - cancellationToken); - - await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage() - { - Parts = [new TextPart() { Text = "When ready say go ahead" }], - }, - cancellationToken: cancellationToken); - _agentStates[taskId] = AgentState.WaitingForFeedbackOnPlan; - } - - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "Researcher Agent", - Description = "Agent which conducts research.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); - } +using A2A; + +namespace AgentServer; + +public sealed class ResearcherAgent : IAgentHandler +{ + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + + if (!context.IsContinuation) + { + // New task: planning phase — ask for confirmation + await updater.SubmitAsync(cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText($"{context.UserText} received.")], + cancellationToken: cancellationToken); + + await Task.Delay(500, cancellationToken); + + await updater.RequireInputAsync(new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString("N"), + ContextId = updater.ContextId, + Parts = [Part.FromText("When ready say go ahead")], + }, cancellationToken); + return; + } + + // Continuation + if (context.UserText == "go ahead") + { + // Research phase + await updater.StartWorkAsync(cancellationToken: cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText($"{context.UserText} received.")], + cancellationToken: cancellationToken); + await updater.CompleteAsync( + new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString("N"), + Parts = [Part.FromText("Task completed successfully")], + }, + cancellationToken); + } + else + { + // Re-plan — ask again + await Task.Delay(500, cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText($"{context.UserText} received.")], + cancellationToken: cancellationToken); + await updater.RequireInputAsync(new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString("N"), + ContextId = updater.ContextId, + Parts = [Part.FromText("When ready say go ahead")], + }, cancellationToken); + } + } + + public static AgentCard GetAgentCard(string agentUrl) => + new() + { + Name = "Researcher Agent", + Description = "Agent which conducts research.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface + { + Url = agentUrl, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + }, + Skills = + [ + new AgentSkill + { + Id = "research", + Name = "Research", + Description = "Conducts research on a given topic.", + Tags = ["research", "planning"], + } + ], + }; } \ No newline at end of file diff --git a/samples/AgentServer/SpecComplianceAgent.cs b/samples/AgentServer/SpecComplianceAgent.cs index 9aacf602..b70a4bc8 100644 --- a/samples/AgentServer/SpecComplianceAgent.cs +++ b/samples/AgentServer/SpecComplianceAgent.cs @@ -1,67 +1,100 @@ -using A2A; - -namespace AgentServer; - -public class SpecComplianceAgent -{ - private ITaskManager? _taskManager; - - public void Attach(ITaskManager taskManager) - { - taskManager.OnAgentCardQuery = GetAgentCard; - taskManager.OnTaskCreated = OnTaskCreatedAsync; - taskManager.OnTaskUpdated = OnTaskUpdatedAsync; - _taskManager = taskManager; - } - - private async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken) - { - // A temporary solution to prevent the compliance test at https://github.com/a2aproject/a2a-tck/blob/main/tests/optional/capabilities/test_streaming_methods.py from hanging. - // It can be removed after the issue https://github.com/a2aproject/a2a-dotnet/issues/97 is resolved. - if (task.History?.Any(m => m.MessageId.StartsWith("test-stream-message-id", StringComparison.InvariantCulture)) ?? false) - { - await _taskManager!.UpdateStatusAsync( - task.Id, - status: TaskState.Completed, - final: true, - cancellationToken: cancellationToken); - } - } - - private async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancellationToken) - { - if (task.Status.State is TaskState.Submitted && task.History?.Count > 0) - { - // The spec does not specify that a task state must be updated when a message is sent, - // but the tck tests expect the task to be in Working/Input-required or Completed state after a message is sent: - // https://github.com/a2aproject/a2a-tck/blob/22f7c191d85f2d4ff2f4564da5d8691944bb7ffd/tests/optional/quality/test_task_state_quality.py#L129 - await _taskManager!.UpdateStatusAsync(task.Id, TaskState.Working, cancellationToken: cancellationToken); - } - } - - private Task GetAgentCard(string agentUrl, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "A2A Specification Compliance Agent", - Description = "Agent to run A2A specification compliance tests.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); - } -} +using A2A; + +namespace AgentServer; + +public sealed class SpecComplianceAgent : IAgentHandler +{ + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + + // Echo the user's message parts back as an agent reply + var replyParts = context.Message.Parts?.ToList() ?? [Part.FromText("")]; + + if (!context.IsContinuation) + { + // New task: Submit, then echo back with Working status + var agentReply = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.Agent, + TaskId = context.TaskId, + ContextId = updater.ContextId, + Parts = replyParts, + }; + + // Emit initial task + await eventQueue.EnqueueTaskAsync(new AgentTask + { + Id = context.TaskId, + ContextId = updater.ContextId, + Status = new A2A.TaskStatus + { + State = TaskState.Working, + Timestamp = DateTimeOffset.UtcNow, + }, + History = [context.Message, agentReply], + }, cancellationToken); + } + else + { + // Continuation: echo the parts back + var agentReply = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + Role = Role.Agent, + TaskId = context.TaskId, + ContextId = updater.ContextId, + Parts = replyParts, + }; + + // Return updated task with the reply added to history + var history = context.Task!.History?.ToList() ?? []; + history.Add(context.Message); + history.Add(agentReply); + + await eventQueue.EnqueueTaskAsync(new AgentTask + { + Id = context.TaskId, + ContextId = updater.ContextId, + Status = context.Task.Status, + History = history, + Artifacts = context.Task.Artifacts, + }, cancellationToken); + } + } + + public static AgentCard GetAgentCard(string agentUrl) => + new() + { + Name = "A2A Specification Compliance Agent", + Description = "Agent for A2A specification compliance verification.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface + { + Url = agentUrl, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + }, + Skills = + [ + new AgentSkill + { + Id = "echo", + Name = "Echo", + Description = "Echoes back user messages for specification compliance verification.", + Tags = ["echo", "a2a", "compliance"], + } + ], + }; +} diff --git a/samples/FileStoreDemo/FileStoreDemo.csproj b/samples/FileStoreDemo/FileStoreDemo.csproj new file mode 100644 index 00000000..9dd44379 --- /dev/null +++ b/samples/FileStoreDemo/FileStoreDemo.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/samples/FileStoreDemo/Program.cs b/samples/FileStoreDemo/Program.cs new file mode 100644 index 00000000..1caab531 --- /dev/null +++ b/samples/FileStoreDemo/Program.cs @@ -0,0 +1,261 @@ +// FileStoreDemo: Demonstrates file-backed task store with data recovery after server restart. +// +// This demo: +// 1. Starts an A2A server with FileTaskStore (persists to ./demo-data/) +// 2. Sends messages that create tasks with artifacts +// 3. Lists tasks — shows they exist +// 4. Stops the server (simulating a crash/restart) +// 5. Starts a NEW server with FileTaskStore pointing to the same directory +// 6. Lists tasks again — shows data survived the restart +// 7. Retrieves individual task details — full history and artifacts preserved +// +// Usage: dotnet run + +using A2A; +using A2A.AspNetCore; +using AgentServer; + +var baseUrl = "http://localhost:5099"; +var agentPath = "/demo"; +var dataDir = Path.Combine(Directory.GetCurrentDirectory(), "demo-data"); + +// Clean up from previous runs +if (Directory.Exists(dataDir)) + Directory.Delete(dataDir, recursive: true); + +Console.WriteLine("╔══════════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ FileTaskStore Demo — Data Recovery After Restart ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════════╝"); +Console.WriteLine(); + +// ───── Phase 1: Start server, create tasks ───── + +Console.WriteLine("▶ Phase 1: Starting server with FileTaskStore..."); +var (host1, client) = await StartServerAsync(baseUrl, agentPath, dataDir); + +Console.WriteLine(" Sending 3 messages to create tasks..."); + +var task1Response = await client.SendMessageAsync(new SendMessageRequest +{ + Message = new Message + { + Role = Role.User, + MessageId = Guid.NewGuid().ToString(), + ContextId = "project-alpha", + Parts = [Part.FromText("Analyze the quarterly report for Q4 2025")], + } +}); +var task1Id = task1Response.Task?.Id ?? task1Response.Message?.TaskId ?? "(no task)"; +Console.WriteLine($" ✓ Task 1 created: {task1Id}"); + +var task2Response = await client.SendMessageAsync(new SendMessageRequest +{ + Message = new Message + { + Role = Role.User, + MessageId = Guid.NewGuid().ToString(), + ContextId = "project-alpha", + Parts = [Part.FromText("Summarize the key findings from the analysis")], + } +}); +var task2Id = task2Response.Task?.Id ?? task2Response.Message?.TaskId ?? "(no task)"; +Console.WriteLine($" ✓ Task 2 created: {task2Id}"); + +var task3Response = await client.SendMessageAsync(new SendMessageRequest +{ + Message = new Message + { + Role = Role.User, + MessageId = Guid.NewGuid().ToString(), + ContextId = "project-beta", + Parts = [Part.FromText("Draft a proposal for the new initiative")], + } +}); +var task3Id = task3Response.Task?.Id ?? task3Response.Message?.TaskId ?? "(no task)"; +Console.WriteLine($" ✓ Task 3 created: {task3Id}"); + +// List tasks before restart +Console.WriteLine(); +Console.WriteLine(" Listing all tasks before restart:"); +var listBefore = await client.ListTasksAsync(new ListTasksRequest()); +Console.WriteLine($" Found {listBefore.Tasks?.Count ?? 0} tasks (totalSize: {listBefore.TotalSize})"); +foreach (var task in listBefore.Tasks ?? []) +{ + Console.WriteLine($" - {task.Id}: status={task.Status.State}, context={task.ContextId}"); +} + +// List with context filter +Console.WriteLine(); +Console.WriteLine(" Listing tasks for context 'project-alpha':"); +var alphaTasksBefore = await client.ListTasksAsync(new ListTasksRequest { ContextId = "project-alpha" }); +Console.WriteLine($" Found {alphaTasksBefore.Tasks?.Count ?? 0} tasks"); + +// ───── Phase 2: Stop server (simulate restart) ───── + +Console.WriteLine(); +Console.WriteLine("▶ Phase 2: Stopping server (simulating crash/restart)..."); +await host1.StopAsync(); +await host1.DisposeAsync(); +Console.WriteLine(" ✓ Server stopped. Data persisted at: " + dataDir); + +// Show what's on disk +Console.WriteLine(); +Console.WriteLine(" Files on disk:"); +foreach (var file in Directory.GetFiles(dataDir, "*.*", SearchOption.AllDirectories)) +{ + var relativePath = Path.GetRelativePath(dataDir, file); + var size = new FileInfo(file).Length; + Console.WriteLine($" {relativePath} ({size} bytes)"); +} + +// ───── Phase 3: Start NEW server, verify data recovery ───── + +Console.WriteLine(); +Console.WriteLine("▶ Phase 3: Starting NEW server with same data directory..."); +var (host2, client2) = await StartServerAsync(baseUrl, agentPath, dataDir); + +Console.WriteLine(" Listing all tasks after restart:"); +var listAfter = await client2.ListTasksAsync(new ListTasksRequest()); +Console.WriteLine($" Found {listAfter.Tasks?.Count ?? 0} tasks (totalSize: {listAfter.TotalSize})"); +foreach (var task in listAfter.Tasks ?? []) +{ + Console.WriteLine($" - {task.Id}: status={task.Status.State}, context={task.ContextId}"); +} + +// Verify context filter still works +Console.WriteLine(); +Console.WriteLine(" Listing tasks for context 'project-alpha' after restart:"); +var alphaTasksAfter = await client2.ListTasksAsync(new ListTasksRequest { ContextId = "project-alpha" }); +Console.WriteLine($" Found {alphaTasksAfter.Tasks?.Count ?? 0} tasks"); + +// Get individual task details +Console.WriteLine(); +Console.WriteLine(" Getting task details after restart:"); +try +{ + var details = await client2.GetTaskAsync(new GetTaskRequest { Id = task1Id }); + Console.WriteLine($" Task {task1Id}:"); + Console.WriteLine($" Status: {details.Status.State}"); + Console.WriteLine($" ContextId: {details.ContextId}"); + Console.WriteLine($" History: {details.History?.Count ?? 0} messages"); + if (details.History is { Count: > 0 }) + { + foreach (var msg in details.History) + { + var text = msg.Parts?.FirstOrDefault()?.Text ?? "(no text)"; + Console.WriteLine($" [{msg.Role}] {text}"); + } + } +} +catch (Exception ex) +{ + Console.WriteLine($" Error: {ex.Message}"); +} + +// ───── Phase 4: Send a follow-up to an existing task ───── + +Console.WriteLine(); +Console.WriteLine("▶ Phase 4: Sending follow-up message to existing task..."); +try +{ + var followUp = await client2.SendMessageAsync(new SendMessageRequest + { + Message = new Message + { + Role = Role.User, + MessageId = Guid.NewGuid().ToString(), + TaskId = task1Id, + Parts = [Part.FromText("Can you also include the revenue breakdown?")], + } + }); + Console.WriteLine($" ✓ Follow-up sent to task {task1Id}"); + + var updatedTask = await client2.GetTaskAsync(new GetTaskRequest { Id = task1Id }); + Console.WriteLine($" History now has {updatedTask.History?.Count ?? 0} messages"); +} +catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation) +{ + Console.WriteLine($" ✓ Follow-up correctly rejected: {ex.Message}"); + Console.WriteLine(" (Task is completed — spec requires rejecting messages to terminal tasks)"); +} + +// ───── Done ───── + +Console.WriteLine(); +Console.WriteLine("╔══════════════════════════════════════════════════════════════╗"); +Console.WriteLine("║ ✓ Demo complete — data survived server restart! ║"); +Console.WriteLine("╚══════════════════════════════════════════════════════════════╝"); + +await host2.StopAsync(); +await host2.DisposeAsync(); + +// Clean up demo data +if (Directory.Exists(dataDir)) + Directory.Delete(dataDir, recursive: true); + +return; + +// ─── Helper: start an A2A server with FileTaskStore ─── + +static async Task<(WebApplication host, A2AClient client)> StartServerAsync( + string baseUrl, string agentPath, string dataDir) +{ + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls(baseUrl); + + // Register FileTaskStore BEFORE AddA2AAgent (TryAddSingleton picks up ours) + builder.Services.AddSingleton(sp => + new FileTaskStore(dataDir)); + builder.Services.AddA2AAgent(DemoAgent.GetAgentCard($"{baseUrl}{agentPath}")); + + // Suppress noisy console output + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + var app = builder.Build(); + app.MapA2A(agentPath); + + await app.StartAsync(); + + var client = new A2AClient(new Uri($"{baseUrl}{agentPath}")); + return (app, client); +} + +// ─── Simple demo agent ─── + +file sealed class DemoAgent : IAgentHandler +{ + public static AgentCard GetAgentCard(string url) => new() + { + Name = "File Store Demo Agent", + Description = "Demonstrates FileTaskStore with data persistence across restarts.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface { Url = url, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" } + ], + Capabilities = new AgentCapabilities { Streaming = true }, + Skills = [new AgentSkill { Id = "demo", Name = "Demo" }], + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + }; + + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.SubmitAsync(ct); + await updater.StartWorkAsync(cancellationToken: ct); + + // Echo back with a response + var userText = context.Message.Parts?.FirstOrDefault()?.Text ?? "no input"; + var responder = new MessageResponder(eventQueue, updater.ContextId); + await responder.ReplyAsync($"Acknowledged: {userText}", ct); + + await updater.CompleteAsync(cancellationToken: ct); + } + + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(ct); + } +} diff --git a/samples/SemanticKernelAgent/Program.cs b/samples/SemanticKernelAgent/Program.cs index 4636be24..69e9635e 100644 --- a/samples/SemanticKernelAgent/Program.cs +++ b/samples/SemanticKernelAgent/Program.cs @@ -1,40 +1,55 @@ -using A2A; -using A2A.AspNetCore; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; -using SemanticKernelAgent; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddHttpClient() - .AddLogging() - .AddOpenTelemetry() - .ConfigureResource(resource => - { - resource.AddService("TravelAgent"); - }) - .WithTracing(tracing => tracing - .AddSource(TaskManager.ActivitySource.Name) - .AddSource(A2AJsonRpcProcessor.ActivitySource.Name) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddOtlpExporter(options => - { - options.Endpoint = new Uri("http://localhost:4317"); - options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; - }) - ); -var app = builder.Build(); - -var configuration = app.Configuration; -var httpClient = app.Services.GetRequiredService().CreateClient(); -var logger = app.Logger; - -var agent = new SemanticKernelTravelAgent(configuration, httpClient, logger); -var taskManager = new TaskManager(); -agent.Attach(taskManager); -app.MapA2A(taskManager, string.Empty); -app.MapWellKnownAgentCard(taskManager, string.Empty); - -await app.RunAsync(); +using A2A; +using A2A.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using SemanticKernelAgent; + +using Microsoft.Extensions.Logging; + +var builder = WebApplication.CreateBuilder(args); + +var agentUrl = "http://localhost:5000"; + +// Register the SK Travel Agent — constructor needs IConfiguration, HttpClient, ILogger +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(sp => +{ + var configuration = sp.GetRequiredService(); + var httpClient = sp.GetRequiredService().CreateClient(); + var logger = sp.GetRequiredService().CreateLogger(); + return new SemanticKernelTravelAgent(configuration, httpClient, logger); +}); +builder.Services.AddSingleton(SemanticKernelTravelAgent.GetAgentCard(agentUrl)); +builder.Services.AddSingleton(new A2AServerOptions()); +builder.Services.TryAddSingleton(); +builder.Services.TryAddSingleton(); +builder.Services.TryAddSingleton(sp => + new A2AServer( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService())); + +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("TravelAgent")) + .WithTracing(tracing => tracing + .AddSource("A2A") + .AddSource("A2A.AspNetCore") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(options => + { + options.Endpoint = new Uri("http://localhost:4317"); + options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; + }) + ); + +var app = builder.Build(); +app.MapA2A("/"); + +await app.RunAsync(); diff --git a/samples/SemanticKernelAgent/SemanticKernelAgent.csproj b/samples/SemanticKernelAgent/SemanticKernelAgent.csproj index d05763fb..86f1d25a 100644 --- a/samples/SemanticKernelAgent/SemanticKernelAgent.csproj +++ b/samples/SemanticKernelAgent/SemanticKernelAgent.csproj @@ -2,9 +2,10 @@ Exe - net9.0 + net10.0 enable enable + $(NoWarn);CA1873;NU1904 diff --git a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs index 8307bc21..d473be74 100644 --- a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs +++ b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs @@ -1,297 +1,281 @@ -using A2A; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Polly; -using System.ComponentModel; -using System.Diagnostics; -using System.Text.Json; - -namespace SemanticKernelAgent; - -#region Plugin -/// -/// A simple currency plugin that leverages Frankfurter for exchange rates. -/// The Plugin is used by the currency_exchange_agent. -/// -public class CurrencyPlugin -{ - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private readonly AsyncPolicy _retryPolicy; - - /// - /// Initialize a new instance of the CurrencyPlugin - /// - /// Logger for the plugin - /// HTTP client for making API requests - public CurrencyPlugin(ILogger logger, HttpClient httpClient) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - - // Create a retry policy for transient HTTP errors - _retryPolicy = Policy - .HandleResult(r => !r.IsSuccessStatusCode && IsTransientError(r)) - .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); - } - - /// - /// Retrieves exchange rate between currency_from and currency_to using Frankfurter API - /// - /// Currency code to convert from, e.g. USD - /// Currency code to convert to, e.g. EUR or INR - /// Date or 'latest' - /// String representation of exchange rate - [KernelFunction] - [Description("Retrieves exchange rate between currency_from and currency_to using Frankfurter API")] - public async Task GetExchangeRateAsync( - [Description("Currency code to convert from, e.g. USD")] string currencyFrom, - [Description("Currency code to convert to, e.g. EUR or INR")] string currencyTo, - [Description("Date or 'latest'")] string date = "latest") - { - try - { - _logger.LogInformation("Getting exchange rate from {CurrencyFrom} to {CurrencyTo} for date {Date}", - currencyFrom, currencyTo, date); - - // Build request URL with query parameters - var requestUri = $"https://api.frankfurter.app/{date}?from={Uri.EscapeDataString(currencyFrom)}&to={Uri.EscapeDataString(currencyTo)}"; - - // Use retry policy for resilience - var response = await _retryPolicy.ExecuteAsync(() => _httpClient.GetAsync(requestUri)); - response.EnsureSuccessStatusCode(); - - var jsonContent = await response.Content.ReadAsStringAsync(); - var data = JsonSerializer.Deserialize(jsonContent); - - if (!data.TryGetProperty("rates", out var rates) || - !rates.TryGetProperty(currencyTo, out var rate)) - { - _logger.LogWarning("Could not retrieve rate for {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo); - return $"Could not retrieve rate for {currencyFrom} to {currencyTo}"; - } - - return $"1 {currencyFrom} = {rate.GetDecimal()} {currencyTo}"; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting exchange rate from {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo); - return $"Currency API call failed: {ex.Message}"; - } - } - - /// - /// Checks if the HTTP response indicates a transient error - /// - /// HTTP response message - /// True if the status code indicates a transient error - private static bool IsTransientError(HttpResponseMessage response) => - (int)response.StatusCode is 408 // Request Timeout - or 429 // Too Many Requests - or >= 500 and < 600; // Server errors -} -#endregion - -#region Semantic Kernel Agent - -/// -/// Wraps Semantic Kernel-based agents to handle Travel related tasks -/// -public class SemanticKernelTravelAgent : IDisposable -{ - public static readonly ActivitySource ActivitySource = new("A2A.SemanticKernelTravelAgent", "1.0.0"); - - /// - /// Initializes a new instance of the SemanticKernelTravelAgent - /// - /// Application configuration - /// HTTP client - /// Logger for the agent - public SemanticKernelTravelAgent( - IConfiguration configuration, - HttpClient httpClient, - ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - - // Create currency plugin - _currencyPlugin = new CurrencyPlugin( - logger: new Logger(new LoggerFactory()), - httpClient: _httpClient); - - // Initialize the agent - _agent = InitializeAgent(); - } - - /// - /// Dispose of resources - /// - public void Dispose() - { - GC.SuppressFinalize(this); - } - - public void Attach(ITaskManager taskManager) - { - _taskManager = taskManager; - taskManager.OnTaskCreated = ExecuteAgentTaskAsync; - taskManager.OnTaskUpdated = ExecuteAgentTaskAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; - } - - public async Task ExecuteAgentTaskAsync(AgentTask task, CancellationToken cancellationToken) - { - if (_taskManager == null) - { - throw new InvalidOperationException("TaskManager is not attached."); - } - - await _taskManager.UpdateStatusAsync(task.Id, TaskState.Working, cancellationToken: cancellationToken); - - // Get message from the user - var userMessage = task.History!.Last().Parts.First().AsTextPart().Text; - - // Get the response from the agent - var artifact = new Artifact(); - await foreach (AgentResponseItem response in _agent.InvokeAsync(userMessage, cancellationToken: cancellationToken)) - { - var content = response.Message.Content; - artifact.Parts.Add(new TextPart() { Text = content! }); - } - - // Return as artifacts - await _taskManager.ReturnArtifactAsync(task.Id, artifact, cancellationToken); - await _taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, cancellationToken: cancellationToken); - } - - public static Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = false, - PushNotifications = false, - }; - - var skillTripPlanning = new AgentSkill() - { - Id = "trip_planning_sk", - Name = "Semantic Kernel Trip Planning", - Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.", - Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"], - Examples = - [ - "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.", - "I am from Korea. What's the exchange rate and recommended itinerary for visiting Galway?", - ], - }; - - return Task.FromResult(new AgentCard() - { - Name = "SK Travel Agent", - Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [skillTripPlanning], - }); - } - - #region private - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private readonly CurrencyPlugin _currencyPlugin; - private readonly HttpClient _httpClient; - private readonly ChatCompletionAgent _agent; - private ITaskManager? _taskManager; - - public List SupportedContentTypes { get; } = ["text", "text/plain"]; - - private ChatCompletionAgent InitializeAgent() - { - try - { - string provider = _configuration["Provider"] ?? "OpenAI"; - var builder = Kernel.CreateBuilder(); - - switch (provider.ToUpperInvariant()) - { - case "AZUREOPENAI": - var azureConfig = _configuration.GetSection("AzureOpenAI"); - if (!azureConfig.Exists()) - { - throw new ArgumentException("AzureOpenAI configuration section must be provided when Provider is set to 'AzureOpenAI'"); - } - string endpoint = azureConfig["Endpoint"] ?? throw new ArgumentException("AzureOpenAI Endpoint must be provided"); - string azureApiKey = azureConfig["ApiKey"] ?? throw new ArgumentException("AzureOpenAI ApiKey must be provided"); - string deploymentName = azureConfig["DeploymentName"] ?? throw new ArgumentException("AzureOpenAI DeploymentName must be provided"); - string? apiVersion = azureConfig["ApiVersion"]; - - _logger.LogInformation("Initializing Semantic Kernel agent with Azure OpenAI deployment {DeploymentName}", deploymentName); - builder.AddAzureOpenAIChatCompletion(deploymentName, endpoint, azureApiKey, apiVersion: apiVersion); - break; - - case "OPENAI": - default: - var openAiConfig = _configuration.GetSection("OpenAI"); - if (!openAiConfig.Exists()) - { - throw new ArgumentException("OpenAI configuration section must be provided when Provider is set to 'OpenAI' or not specified"); - } - string apiKey = openAiConfig["ApiKey"] ?? throw new ArgumentException("OpenAI ApiKey must be provided"); - string modelId = openAiConfig["Model"] ?? "gpt-4.1"; - - _logger.LogInformation("Initializing Semantic Kernel agent with OpenAI model {ModelId}", modelId); - builder.AddOpenAIChatCompletion(modelId, apiKey); - break; - } - - builder.Plugins.AddFromObject(_currencyPlugin); - - var kernel = builder.Build(); - var travelPlannerAgent = new ChatCompletionAgent() - { - Kernel = kernel, - Arguments = new KernelArguments(new PromptExecutionSettings() - { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), - Name = "TravelPlannerAgent", - Instructions = - """ - You specialize in planning and recommending activities for travelers. - This includes suggesting sightseeing options, local events, dining recommendations, - booking tickets for attractions, advising on travel itineraries, and ensuring activities - align with traveler preferences and schedule. - Your goal is to create enjoyable and personalized experiences for travelers. - You specialize in planning and recommending activities for travelers. - This includes suggesting sightseeing options, local events, dining recommendations, - booking tickets for attractions, advising on travel itineraries, and ensuring activities - align with traveler preferences and schedule. - This includes providing current exchange rates, converting amounts between different currencies, - explaining fees or charges related to currency exchange, and giving advice on the best practices for exchanging currency. - Your goal is to create enjoyable and personalized experiences for travelers. - """ - }; - - return travelPlannerAgent; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initialize Semantic Kernel agent"); - throw; - } - } - - #endregion -} -#endregion - +using A2A; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Polly; +using System.ComponentModel; +using System.Text.Json; + +namespace SemanticKernelAgent; + +#region Plugin +/// +/// A simple currency plugin that leverages Frankfurter for exchange rates. +/// The Plugin is used by the currency_exchange_agent. +/// +public class CurrencyPlugin +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly AsyncPolicy _retryPolicy; + + /// + /// Initialize a new instance of the CurrencyPlugin + /// + /// Logger for the plugin + /// HTTP client for making API requests + public CurrencyPlugin(ILogger logger, HttpClient httpClient) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + + // Create a retry policy for transient HTTP errors + _retryPolicy = Policy + .HandleResult(r => !r.IsSuccessStatusCode && IsTransientError(r)) + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } + + /// + /// Retrieves exchange rate between currency_from and currency_to using Frankfurter API + /// + /// Currency code to convert from, e.g. USD + /// Currency code to convert to, e.g. EUR or INR + /// Date or 'latest' + /// String representation of exchange rate + [KernelFunction] + [Description("Retrieves exchange rate between currency_from and currency_to using Frankfurter API")] + public async Task GetExchangeRateAsync( + [Description("Currency code to convert from, e.g. USD")] string currencyFrom, + [Description("Currency code to convert to, e.g. EUR or INR")] string currencyTo, + [Description("Date or 'latest'")] string date = "latest") + { + try + { + _logger.LogInformation("Getting exchange rate from {CurrencyFrom} to {CurrencyTo} for date {Date}", + currencyFrom, currencyTo, date); + + // Build request URL with query parameters + var requestUri = $"https://api.frankfurter.app/{date}?from={Uri.EscapeDataString(currencyFrom)}&to={Uri.EscapeDataString(currencyTo)}"; + + // Use retry policy for resilience + var response = await _retryPolicy.ExecuteAsync(() => _httpClient.GetAsync(requestUri)); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(); + var data = JsonSerializer.Deserialize(jsonContent); + + if (!data.TryGetProperty("rates", out var rates) || + !rates.TryGetProperty(currencyTo, out var rate)) + { + _logger.LogWarning("Could not retrieve rate for {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo); + return $"Could not retrieve rate for {currencyFrom} to {currencyTo}"; + } + + return $"1 {currencyFrom} = {rate.GetDecimal()} {currencyTo}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting exchange rate from {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo); + return $"Currency API call failed: {ex.Message}"; + } + } + + /// + /// Checks if the HTTP response indicates a transient error + /// + /// HTTP response message + /// True if the status code indicates a transient error + private static bool IsTransientError(HttpResponseMessage response) => + (int)response.StatusCode is 408 // Request Timeout + or 429 // Too Many Requests + or >= 500 and < 600; // Server errors +} +#endregion + +#region Semantic Kernel Agent + +/// +/// Wraps Semantic Kernel-based agents to handle Travel related tasks +/// +public sealed class SemanticKernelTravelAgent : IAgentHandler, IDisposable +{ + /// + /// Initializes a new instance of the SemanticKernelTravelAgent + /// + /// Application configuration + /// HTTP client + /// Logger for the agent + public SemanticKernelTravelAgent( + IConfiguration configuration, + HttpClient httpClient, + ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + + // Create currency plugin + _currencyPlugin = new CurrencyPlugin( + logger: new Logger(new LoggerFactory()), + httpClient: _httpClient); + + // Initialize the agent + _agent = InitializeAgent(); + } + + /// + /// Dispose of resources + /// + public void Dispose() + { + GC.SuppressFinalize(this); + } + + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.SubmitAsync(cancellationToken); + await updater.StartWorkAsync(cancellationToken: cancellationToken); + + // Get the response from the Semantic Kernel agent + var artifactParts = new List(); + await foreach (AgentResponseItem response in _agent.InvokeAsync(context.UserText ?? string.Empty, cancellationToken: cancellationToken)) + { + var content = response.Message.Content; + artifactParts.Add(Part.FromText(content!)); + } + + await updater.AddArtifactAsync(artifactParts, cancellationToken: cancellationToken); + await updater.CompleteAsync(cancellationToken: cancellationToken); + } + + public static AgentCard GetAgentCard(string agentUrl) + { + var capabilities = new AgentCapabilities() + { + Streaming = false, + PushNotifications = false, + }; + + var skillTripPlanning = new AgentSkill() + { + Id = "trip_planning_sk", + Name = "Semantic Kernel Trip Planning", + Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.", + Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"], + Examples = + [ + "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.", + "I am from Korea. What's the exchange rate and recommended itinerary for visiting Galway?", + ], + }; + + return new AgentCard() + { + Name = "SK Travel Agent", + Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.", + Version = "1.0.0", + SupportedInterfaces = + [ + new AgentInterface + { + Url = agentUrl, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Capabilities = capabilities, + Skills = [skillTripPlanning], + }; + } + + #region private + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly CurrencyPlugin _currencyPlugin; + private readonly HttpClient _httpClient; + private readonly ChatCompletionAgent _agent; + + public List SupportedContentTypes { get; } = ["text", "text/plain"]; + + private ChatCompletionAgent InitializeAgent() + { + try + { + string provider = _configuration["Provider"] ?? "OpenAI"; + var builder = Kernel.CreateBuilder(); + + switch (provider.ToUpperInvariant()) + { + case "AZUREOPENAI": + var azureConfig = _configuration.GetSection("AzureOpenAI"); + if (!azureConfig.Exists()) + { + throw new ArgumentException("AzureOpenAI configuration section must be provided when Provider is set to 'AzureOpenAI'"); + } + string endpoint = azureConfig["Endpoint"] ?? throw new ArgumentException("AzureOpenAI Endpoint must be provided"); + string azureApiKey = azureConfig["ApiKey"] ?? throw new ArgumentException("AzureOpenAI ApiKey must be provided"); + string deploymentName = azureConfig["DeploymentName"] ?? throw new ArgumentException("AzureOpenAI DeploymentName must be provided"); + string? apiVersion = azureConfig["ApiVersion"]; + + _logger.LogInformation("Initializing Semantic Kernel agent with Azure OpenAI deployment {DeploymentName}", deploymentName); + builder.AddAzureOpenAIChatCompletion(deploymentName, endpoint, azureApiKey, apiVersion: apiVersion); + break; + + case "OPENAI": + default: + var openAiConfig = _configuration.GetSection("OpenAI"); + if (!openAiConfig.Exists()) + { + throw new ArgumentException("OpenAI configuration section must be provided when Provider is set to 'OpenAI' or not specified"); + } + string apiKey = openAiConfig["ApiKey"] ?? throw new ArgumentException("OpenAI ApiKey must be provided"); + string modelId = openAiConfig["Model"] ?? "gpt-4.1"; + + _logger.LogInformation("Initializing Semantic Kernel agent with OpenAI model {ModelId}", modelId); + builder.AddOpenAIChatCompletion(modelId, apiKey); + break; + } + + builder.Plugins.AddFromObject(_currencyPlugin); + + var kernel = builder.Build(); + var travelPlannerAgent = new ChatCompletionAgent() + { + Kernel = kernel, + Arguments = new KernelArguments(new PromptExecutionSettings() + { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), + Name = "TravelPlannerAgent", + Instructions = + """ + You specialize in planning and recommending activities for travelers. + This includes suggesting sightseeing options, local events, dining recommendations, + booking tickets for attractions, advising on travel itineraries, and ensuring activities + align with traveler preferences and schedule. + Your goal is to create enjoyable and personalized experiences for travelers. + You specialize in planning and recommending activities for travelers. + This includes suggesting sightseeing options, local events, dining recommendations, + booking tickets for attractions, advising on travel itineraries, and ensuring activities + align with traveler preferences and schedule. + This includes providing current exchange rates, converting amounts between different currencies, + explaining fees or charges related to currency exchange, and giving advice on the best practices for exchanging currency. + Your goal is to create enjoyable and personalized experiences for travelers. + """ + }; + + return travelPlannerAgent; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize Semantic Kernel agent"); + throw; + } + } + + #endregion +} +#endregion + diff --git a/src/A2A.AspNetCore/A2A.AspNetCore.csproj b/src/A2A.AspNetCore/A2A.AspNetCore.csproj index af7aaa0d..35fedd25 100644 --- a/src/A2A.AspNetCore/A2A.AspNetCore.csproj +++ b/src/A2A.AspNetCore/A2A.AspNetCore.csproj @@ -1,7 +1,7 @@  - net9.0;net8.0 + net10.0;net8.0 true true diff --git a/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs b/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs new file mode 100644 index 00000000..a43afecb --- /dev/null +++ b/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; + +namespace A2A.AspNetCore; + +/// +/// Diagnostics for the A2A ASP.NET Core integration layer. +/// +internal static class A2AAspNetCoreDiagnostics +{ + private static readonly string Version = + typeof(A2AAspNetCoreDiagnostics).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + /// Activity source for HTTP/JSON-RPC protocol processing. + internal static readonly ActivitySource Source = new("A2A.AspNetCore", Version); +} diff --git a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index a91e58da..4b446f9e 100644 --- a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -1,120 +1,154 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; - -namespace A2A.AspNetCore; - -/// -/// Extension methods for configuring A2A endpoints in ASP.NET Core applications. -/// -public static class A2ARouteBuilderExtensions -{ - /// - /// Activity source for tracing A2A endpoint operations. - /// - public static readonly ActivitySource ActivitySource = new("A2A.Endpoint", "1.0.0"); - - /// - /// Enables JSON-RPC A2A endpoints for the specified path. - /// - /// The endpoint route builder to configure. - /// The task manager for handling A2A operations. - /// The base path for the A2A endpoints. - /// An endpoint convention builder for further configuration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string path) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(taskManager); - ArgumentException.ThrowIfNullOrEmpty(path); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - var routeGroup = endpoints.MapGroup(""); - - routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) => A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, request, cancellationToken)); - - return routeGroup; - } - - /// - /// Enables the well-known agent card endpoint for agent discovery. - /// - /// The endpoint route builder to configure. - /// The task manager for handling A2A operations. - /// The base path where the A2A agent is hosted. - /// An endpoint convention builder for further configuration. - public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string agentPath) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(taskManager); - ArgumentException.ThrowIfNullOrEmpty(agentPath); - - var routeGroup = endpoints.MapGroup(""); - - routeGroup.MapGet(".well-known/agent-card.json", async (HttpRequest request, CancellationToken cancellationToken) => - { - var agentUrl = $"{request.Scheme}://{request.Host}{agentPath}"; - var agentCard = await taskManager.OnAgentCardQuery(agentUrl, cancellationToken); - return Results.Ok(agentCard); - }); - - return routeGroup; - } - - /// - /// Enables experimental HTTP A2A endpoints for the specified path. - /// - /// The endpoint route builder to configure. - /// The task manager for handling A2A operations. - /// The base path for the HTTP A2A endpoints. - /// An endpoint convention builder for further configuration. - public static IEndpointConventionBuilder MapHttpA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string path) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(taskManager); - ArgumentException.ThrowIfNullOrEmpty(path); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var logger = loggerFactory.CreateLogger(); - - var routeGroup = endpoints.MapGroup(path); - - // /v1/card endpoint - Agent discovery - routeGroup.MapGet("/v1/card", async (HttpRequest request, CancellationToken cancellationToken) => - await A2AHttpProcessor.GetAgentCardAsync(taskManager, logger, $"{request.Scheme}://{request.Host}{path}", cancellationToken).ConfigureAwait(false)); - - // /v1/tasks/{id} endpoint - routeGroup.MapGet("/v1/tasks/{id}", (string id, [FromQuery] int? historyLength, [FromQuery] string? metadata, CancellationToken cancellationToken) => - A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, metadata, cancellationToken)); - - // /v1/tasks/{id}:cancel endpoint - routeGroup.MapPost("/v1/tasks/{id}:cancel", (string id, CancellationToken cancellationToken) => A2AHttpProcessor.CancelTaskAsync(taskManager, logger, id, cancellationToken)); - - // /v1/tasks/{id}:subscribe endpoint - routeGroup.MapGet("/v1/tasks/{id}:subscribe", (string id, CancellationToken cancellationToken) => A2AHttpProcessor.SubscribeToTask(taskManager, logger, id, cancellationToken)); - - // /v1/tasks/{id}/pushNotificationConfigs endpoint - POST - routeGroup.MapPost("/v1/tasks/{id}/pushNotificationConfigs", (string id, [FromBody] PushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken) => - A2AHttpProcessor.SetPushNotificationAsync(taskManager, logger, id, pushNotificationConfig, cancellationToken)); - - // /v1/tasks/{id}/pushNotificationConfigs endpoint - GET - routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs/{notificationConfigId?}", (string id, string? notificationConfigId, CancellationToken cancellationToken) => - A2AHttpProcessor.GetPushNotificationAsync(taskManager, logger, id, notificationConfigId, cancellationToken)); - - // /v1/message:send endpoint - routeGroup.MapPost("/v1/message:send", ([FromBody] MessageSendParams sendParams, CancellationToken cancellationToken) => - A2AHttpProcessor.SendMessageAsync(taskManager, logger, sendParams, cancellationToken)); - - // /v1/message:stream endpoint - routeGroup.MapPost("/v1/message:stream", ([FromBody] MessageSendParams sendParams, CancellationToken cancellationToken) => - A2AHttpProcessor.SendMessageStream(taskManager, logger, sendParams, cancellationToken)); - - return routeGroup; - } -} +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace A2A.AspNetCore; + +/// +/// Extension methods for configuring A2A endpoints in ASP.NET Core applications. +/// +public static class A2ARouteBuilderExtensions +{ + /// + /// Maps A2A JSON-RPC endpoint and well-known agent card using DI-registered services. + /// Requires prior call to . + /// + /// The endpoint route builder. + /// The route path for the A2A endpoint. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrEmpty(path); + + var handler = endpoints.ServiceProvider.GetRequiredService(); + var agentCard = endpoints.ServiceProvider.GetRequiredService(); + + var routeGroup = endpoints.MapGroup(""); + routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) + => A2AJsonRpcProcessor.ProcessRequestAsync(handler, request, cancellationToken)); + + var wellKnown = endpoints.MapGroup(path); + wellKnown.MapGet(".well-known/agent-card.json", () => Results.Ok(agentCard)); + + return routeGroup; + } + + /// Enables JSON-RPC A2A endpoints for the specified path. + /// The endpoint route builder. + /// The A2A request handler. + /// The route path for the A2A endpoint. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, [StringSyntax("Route")] string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(requestHandler); + ArgumentException.ThrowIfNullOrEmpty(path); + + var routeGroup = endpoints.MapGroup(""); + + routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) => A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, request, cancellationToken)); + + return routeGroup; + } + + /// Enables the well-known agent card endpoint for agent discovery. + /// The endpoint route builder. + /// The agent card to serve. + /// An optional route prefix. When provided, the agent card is served at {path}/.well-known/agent-card.json. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, AgentCard agentCard, [StringSyntax("Route")] string path = "") + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentCard); + + var routeGroup = endpoints.MapGroup(path); + + routeGroup.MapGet(".well-known/agent-card.json", () => Results.Ok(agentCard)); + + return routeGroup; + } + + /// + /// Maps HTTP+JSON REST API endpoints for A2A v1. + /// + /// + /// All REST endpoints are prefixed with /v1/ (e.g., /v1/tasks/{id}). + /// The A2A specification defines routes without this prefix (e.g., /tasks/{id}). + /// Configure the parameter to control the base path. + /// Limitation: Multi-tenant route variants + /// (/{tenant}/tasks/{id}) defined in the A2A specification are not currently + /// supported. The Tenant field on request types will always be null + /// for REST API calls. Use the JSON-RPC binding with explicit tenant parameters + /// if multi-tenant routing is required. + /// + /// The endpoint route builder. + /// The A2A request handler. + /// The agent card to serve at the /v1/card endpoint. + /// The route prefix for all REST endpoints. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapHttpA2A( + this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, AgentCard agentCard, [StringSyntax("Route")] string path = "") + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(requestHandler); + ArgumentNullException.ThrowIfNull(agentCard); + + var routeGroup = endpoints.MapGroup(path); + var logger = endpoints.ServiceProvider.GetRequiredService().CreateLogger("A2A.REST"); + + // Agent card + routeGroup.MapGet("/v1/card", (CancellationToken ct) + => A2AHttpProcessor.GetAgentCardRestAsync(requestHandler, logger, agentCard, ct)); + + // Task operations + routeGroup.MapGet("/v1/tasks/{id}", (string id, [FromQuery] int? historyLength, CancellationToken ct) + => A2AHttpProcessor.GetTaskRestAsync(requestHandler, logger, id, historyLength, ct)); + + routeGroup.MapPost("/v1/tasks/{id}:cancel", (string id, CancellationToken ct) + => A2AHttpProcessor.CancelTaskRestAsync(requestHandler, logger, id, ct)); + + routeGroup.MapGet("/v1/tasks/{id}:subscribe", (string id, CancellationToken ct) + => A2AHttpProcessor.SubscribeToTaskRest(requestHandler, logger, id, ct)); + + routeGroup.MapGet("/v1/tasks", ([FromQuery] string? contextId, [FromQuery] string? status, + [FromQuery] int? pageSize, [FromQuery] string? pageToken, [FromQuery] int? historyLength, + CancellationToken ct) + => A2AHttpProcessor.ListTasksRestAsync(requestHandler, logger, contextId, status, pageSize, pageToken, + historyLength, ct)); + + // Message operations + routeGroup.MapPost("/v1/message:send", ([FromBody] SendMessageRequest request, CancellationToken ct) + => A2AHttpProcessor.SendMessageRestAsync(requestHandler, logger, request, ct)); + + routeGroup.MapPost("/v1/message:stream", ([FromBody] SendMessageRequest request, CancellationToken ct) + => A2AHttpProcessor.SendMessageStreamRest(requestHandler, logger, request, ct)); + + // Push notification config operations + routeGroup.MapPost("/v1/tasks/{id}/pushNotificationConfigs", + (string id, [FromBody] PushNotificationConfig config, CancellationToken ct) + => A2AHttpProcessor.CreatePushNotificationConfigRestAsync(requestHandler, logger, id, config, ct)); + + routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs", + (string id, [FromQuery] int? pageSize, [FromQuery] string? pageToken, CancellationToken ct) + => A2AHttpProcessor.ListPushNotificationConfigRestAsync(requestHandler, logger, id, pageSize, pageToken, ct)); + + routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs/{configId}", + (string id, string configId, CancellationToken ct) + => A2AHttpProcessor.GetPushNotificationConfigRestAsync(requestHandler, logger, id, configId, ct)); + + routeGroup.MapDelete("/v1/tasks/{id}/pushNotificationConfigs/{configId}", + (string id, string configId, CancellationToken ct) + => A2AHttpProcessor.DeletePushNotificationConfigRestAsync(requestHandler, logger, id, configId, ct)); + + // Extended agent card + routeGroup.MapGet("/v1/extendedAgentCard", (CancellationToken ct) + => A2AHttpProcessor.GetExtendedAgentCardRestAsync(requestHandler, logger, ct)); + + return routeGroup; + } +} diff --git a/src/A2A.AspNetCore/A2AHttpProcessor.cs b/src/A2A.AspNetCore/A2AHttpProcessor.cs index eb3a95d1..0eeed5af 100644 --- a/src/A2A.AspNetCore/A2AHttpProcessor.cs +++ b/src/A2A.AspNetCore/A2AHttpProcessor.cs @@ -1,407 +1,344 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Logging; - -using System.Diagnostics; -using System.Text; -using System.Text.Json; - -namespace A2A.AspNetCore; - -/// -/// Static processor class for handling A2A HTTP requests in ASP.NET Core applications. -/// -/// -/// Provides methods for processing agent card queries, task operations, message sending, -/// and push notification configuration through HTTP endpoints. -/// -internal static class A2AHttpProcessor -{ - /// - /// OpenTelemetry ActivitySource for tracing HTTP processor operations. - /// - public static readonly ActivitySource ActivitySource = new("A2A.HttpProcessor", "1.0.0"); - - /// - /// Processes a request to retrieve the agent card containing agent capabilities and metadata. - /// - /// - /// Invokes the task manager's agent card query handler to get current agent information. - /// - /// The task manager instance containing the agent card query handler. - /// Logger instance for recording operation details and errors. - /// The URL of the agent to retrieve the card for. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the agent card JSON or an error response. - internal static Task GetAgentCardAsync(ITaskManager taskManager, ILogger logger, string agentUrl, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "GetAgentCard", async ct => - { - var agentCard = await taskManager.OnAgentCardQuery(agentUrl, ct); - - return Results.Ok(agentCard); - }, cancellationToken: cancellationToken); - - /// - /// Processes a request to retrieve a specific task by its ID. - /// - /// - /// Returns the task's current state, history, and metadata with optional history length limiting. - /// - /// The task manager instance for accessing task storage. - /// Logger instance for recording operation details and errors. - /// The unique identifier of the task to retrieve. - /// Optional limit on the number of history items to return. - /// Optional JSON metadata filter for the task query. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the task JSON or a not found/error response. - internal static Task GetTaskAsync(ITaskManager taskManager, ILogger logger, string id, int? historyLength, string? metadata, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "GetTask", async ct => - { - var agentTask = await taskManager.GetTaskAsync(new TaskQueryParams() - { - Id = id, - HistoryLength = historyLength, - Metadata = string.IsNullOrWhiteSpace(metadata) ? null : (Dictionary?)JsonSerializer.Deserialize(metadata, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(Dictionary))) - }, ct).ConfigureAwait(false); - - return agentTask is not null ? new A2AResponseResult(agentTask) : Results.NotFound(); - }, id, cancellationToken: cancellationToken); - - /// - /// Processes a request to cancel a specific task by setting its status to Canceled. - /// - /// - /// Invokes the task manager's cancellation logic and returns the updated task state. - /// - /// The task manager instance for handling task cancellation. - /// Logger instance for recording operation details and errors. - /// The unique identifier of the task to cancel. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the canceled task JSON or a not found/error response. - internal static Task CancelTaskAsync(ITaskManager taskManager, ILogger logger, string id, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "CancelTask", async ct => - { - var agentTask = await taskManager.CancelTaskAsync(new TaskIdParams { Id = id }, ct).ConfigureAwait(false); - if (agentTask == null) - { - return Results.NotFound(); - } - - return new A2AResponseResult(agentTask); - }, id, cancellationToken: cancellationToken); - - /// - /// Processes a request to send a message to a task and return a single response. - /// - /// - /// Creates a new task if no task ID is provided, or updates an existing task's history. - /// Configures message sending parameters including history length and metadata. - /// - /// The task manager instance for handling message processing. - /// Logger instance for recording operation details and errors. - /// The message parameters containing the message content and configuration. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the agent's response (Task or Message) or an error response. - internal static Task SendMessageAsync(ITaskManager taskManager, ILogger logger, MessageSendParams sendParams, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "SendMessage", async ct => - { - var a2aResponse = await taskManager.SendMessageAsync(sendParams, ct).ConfigureAwait(false); - if (a2aResponse == null) - { - return Results.NotFound(); - } - - return new A2AResponseResult(a2aResponse); - }, cancellationToken: cancellationToken); - - /// - /// Processes a request to send a message to a task and return a stream of events. - /// - /// - /// Creates or updates a task and establishes a Server-Sent Events stream that yields - /// Task, Message, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects as they occur. - /// - /// The task manager instance for handling streaming message processing. - /// Logger instance for recording operation details and errors. - /// The message parameters containing the message content and configuration. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result that streams events as Server-Sent Events or an error response. - internal static IResult SendMessageStream(ITaskManager taskManager, ILogger logger, MessageSendParams sendParams, CancellationToken cancellationToken) - => WithExceptionHandling(logger, nameof(SendMessageStream), () => - { - var taskEvents = taskManager.SendMessageStreamingAsync(sendParams, cancellationToken); - - return new A2AEventStreamResult(taskEvents); - }, sendParams.Message.TaskId); - - /// - /// Processes a request to resubscribe to an existing task's event stream. - /// - /// - /// Returns the active event enumerator for the specified task, allowing clients - /// to reconnect to ongoing task updates via Server-Sent Events. - /// - /// The task manager instance containing active task event streams. - /// Logger instance for recording operation details and errors. - /// The unique identifier of the task to resubscribe to. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result that streams existing task events or an error response. - internal static IResult SubscribeToTask(ITaskManager taskManager, ILogger logger, string id, CancellationToken cancellationToken) - => WithExceptionHandling(logger, nameof(SubscribeToTask), () => - { - var taskEvents = taskManager.SubscribeToTaskAsync(new TaskIdParams { Id = id }, cancellationToken); - - return new A2AEventStreamResult(taskEvents); - }, id); - - /// - /// Processes a request to set or update push notification configuration for a specific task. - /// - /// - /// Configures callback URLs and authentication settings for receiving task update notifications via HTTP. - /// - /// The task manager instance for handling push notification configuration. - /// Logger instance for recording operation details and errors. - /// The unique identifier of the task to configure push notifications for. - /// The push notification configuration containing callback URL and authentication details. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the configured settings or an error response. - internal static Task SetPushNotificationAsync(ITaskManager taskManager, ILogger logger, string id, PushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "SetPushNotification", async ct => - { - var taskIdParams = new TaskIdParams { Id = id }; - var result = await taskManager.SetPushNotificationAsync(new TaskPushNotificationConfig - { - TaskId = id, - PushNotificationConfig = pushNotificationConfig - }, ct).ConfigureAwait(false); - - if (result == null) - { - return Results.NotFound(); - } - - return Results.Ok(result); - }, id, cancellationToken: cancellationToken); - - /// - /// Processes a request to retrieve the push notification configuration for a specific task. - /// - /// - /// Returns the callback URL and authentication settings configured for receiving task update notifications. - /// - /// The task manager instance for accessing push notification configurations. - /// Logger instance for recording operation details and errors. - /// The unique identifier of the task to get push notification configuration for. - /// The unique identifier of the push notification configuration to retrieve. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the push notification configuration or a not found/error response. - internal static Task GetPushNotificationAsync(ITaskManager taskManager, ILogger logger, string taskId, string? notificationConfigId, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "GetPushNotification", async ct => - { - var taskIdParams = new GetTaskPushNotificationConfigParams { Id = taskId, PushNotificationConfigId = notificationConfigId }; - var result = await taskManager.GetPushNotificationAsync(taskIdParams, ct).ConfigureAwait(false); - - if (result == null) - { - return Results.NotFound(); - } - - return Results.Ok(result); - }, taskId, cancellationToken: cancellationToken); - - /// - /// Provides centralized exception handling for HTTP A2A endpoints. - /// - /// Logger instance for recording operation details and errors. - /// Name of the operation for logging purposes. - /// The operation to execute with exception handling. - /// Optional task ID to include in the activity for tracing. - /// Cancellation token for the async operation - /// A task that represents the HTTP result with proper error handling. - private static async Task WithExceptionHandlingAsync(ILogger logger, string activityName, - Func> operation, string? taskId = null, CancellationToken cancellationToken = default) - { - using var activity = ActivitySource.StartActivity(activityName, ActivityKind.Server); - if (taskId is not null) - { - activity?.AddTag("task.id", taskId); - } - - try - { - return await operation(cancellationToken); - } - catch (A2AException ex) - { - logger.A2AErrorInActivityName(ex, activityName); - return MapA2AExceptionToHttpResult(ex); - } - catch (Exception ex) - { - logger.UnexpectedErrorInActivityName(ex, activityName); - return Results.Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError); - } - } - - /// - /// Provides centralized exception handling for HTTP A2A endpoints. - /// - /// Logger instance for recording operation details and errors. - /// Name of the operation for logging purposes. - /// The operation to execute with exception handling. - /// Optional task ID to include in the activity for tracing. - /// A task that represents the HTTP result with proper error handling. - private static IResult WithExceptionHandling(ILogger logger, string activityName, - Func operation, string? taskId = null) - { - using var activity = ActivitySource.StartActivity(activityName, ActivityKind.Server); - if (taskId is not null) - { - activity?.AddTag("task.id", taskId); - } - - try - { - return operation(); - } - catch (A2AException ex) - { - logger.A2AErrorInActivityName(ex, activityName); - return MapA2AExceptionToHttpResult(ex); - } - catch (Exception ex) - { - logger.UnexpectedErrorInActivityName(ex, activityName); - return Results.Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError); - } - } - - /// - /// Maps an A2AException to an appropriate HTTP result based on the error code. - /// - /// The A2AException to map to an HTTP result. - /// An HTTP result with the appropriate status code and error message. - private static IResult MapA2AExceptionToHttpResult(A2AException exception) - { - return exception.ErrorCode switch - { - A2AErrorCode.TaskNotFound or - A2AErrorCode.MethodNotFound => Results.NotFound(exception.Message), - - A2AErrorCode.TaskNotCancelable or - A2AErrorCode.UnsupportedOperation or - A2AErrorCode.InvalidRequest or - A2AErrorCode.InvalidParams or - A2AErrorCode.ParseError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest), - - // Return HTTP 400 for now. Later we may want to return 501 Not Implemented in case - // push notifications are advertised by agent card(AgentCard.capabilities.pushNotifications: true) - // but there's no server-side support for them. - A2AErrorCode.PushNotificationNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest), - - A2AErrorCode.ContentTypeNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity), - - A2AErrorCode.InternalError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError), - - // Default case for unhandled error codes - this should never happen with current A2AErrorCode enum values - // but provides a safety net for future enum additions or unexpected values - _ => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError) - }; - } -} - -/// -/// Result type for returning A2A responses as JSON in HTTP responses. -/// -/// -/// Implements IResult to provide custom serialization of A2AResponse objects -/// using the configured JSON serialization options. -/// -public class A2AResponseResult : IResult -{ - private readonly A2AResponse a2aResponse; - - /// - /// Initializes a new instance of the A2AResponseResult class. - /// - /// The A2A response object to serialize and return in the HTTP response. - public A2AResponseResult(A2AResponse a2aResponse) - { - this.a2aResponse = a2aResponse; - } - - /// - /// Executes the result by serializing the A2A response as JSON to the HTTP response body. - /// - /// - /// Sets the appropriate content type and uses the default A2A JSON serialization options. - /// - /// The HTTP context to write the response to. - /// A task representing the asynchronous serialization operation. - public async Task ExecuteAsync(HttpContext httpContext) - { - httpContext.Response.ContentType = "application/json"; - - await JsonSerializer.SerializeAsync(httpContext.Response.Body, a2aResponse, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AResponse))).ConfigureAwait(false); - } -} - -/// -/// Result type for streaming A2A events as Server-Sent Events (SSE) in HTTP responses. -/// -/// -/// Implements IResult to provide real-time streaming of task events including Task objects, -/// TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects. -/// -internal sealed class A2AEventStreamResult : IResult -{ - private readonly IAsyncEnumerable taskEvents; - - internal A2AEventStreamResult(IAsyncEnumerable taskEvents) - { - ArgumentNullException.ThrowIfNull(taskEvents); - - this.taskEvents = taskEvents; - } - - /// - /// Executes the result by streaming A2A events as Server-Sent Events to the HTTP response. - /// - /// - /// Sets the appropriate SSE content type and formats each event according to the SSE specification. - /// Each event is serialized as JSON and sent with the "data:" prefix followed by double newlines. - /// - /// The HTTP context to stream the events to. - /// A task representing the asynchronous streaming operation. - public async Task ExecuteAsync(HttpContext httpContext) - { - ArgumentNullException.ThrowIfNull(httpContext); - - httpContext.Response.ContentType = "text/event-stream"; - - // .NET 10 Preview 3 has introduced ServerSentEventsResult (and TypedResults.ServerSentEventsResult), - // but we need to support .NET 8 and 9, so we implement our own SSE result - // and mimic what the forementioned types do. - httpContext.Response.Headers.CacheControl = "no-cache,no-store"; - httpContext.Response.Headers.Pragma = "no-cache"; - httpContext.Response.Headers.ContentEncoding = "identity"; - - var bufferingFeature = httpContext.Features.GetRequiredFeature(); - bufferingFeature.DisableBuffering(); - - try - { - await foreach (var taskEvent in taskEvents) - { - var json = JsonSerializer.Serialize(taskEvent, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AEvent))); - await httpContext.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes($"data: {json}\n\n"), httpContext.RequestAborted).ConfigureAwait(false); - await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // Closed connection - } - } -} \ No newline at end of file +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; + +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace A2A.AspNetCore; + +/// +/// Static processor class for handling A2A HTTP requests in ASP.NET Core applications. +/// +internal static class A2AHttpProcessor +{ + internal static Task GetTaskAsync(IA2ARequestHandler requestHandler, ILogger logger, string id, int? historyLength, string? metadata, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "GetTask", async ct => + { + var agentTask = await requestHandler.GetTaskAsync(new GetTaskRequest + { + Id = id, + HistoryLength = historyLength, + }, ct).ConfigureAwait(false); + + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), agentTask)); + }, id, cancellationToken: cancellationToken); + + internal static Task CancelTaskAsync(IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "CancelTask", async ct => + { + var cancelledTask = await requestHandler.CancelTaskAsync(new CancelTaskRequest { Id = id }, ct).ConfigureAwait(false); + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), cancelledTask)); + }, id, cancellationToken: cancellationToken); + + internal static Task SendMessageAsync(IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest sendRequest, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "SendMessage", async ct => + { + var result = await requestHandler.SendMessageAsync(sendRequest, ct).ConfigureAwait(false); + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), result)); + }, cancellationToken: cancellationToken); + + internal static IResult SendMessageStream(IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest sendRequest, CancellationToken cancellationToken) + => WithExceptionHandling(logger, nameof(SendMessageStream), () => + { + var events = requestHandler.SendStreamingMessageAsync(sendRequest, cancellationToken); + return new JsonRpcStreamedResult(events, new JsonRpcId("http")); + }); + + internal static IResult SubscribeToTask(IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken) + => WithExceptionHandling(logger, nameof(SubscribeToTask), () => + { + var events = requestHandler.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = id }, cancellationToken); + return new JsonRpcStreamedResult(events, new JsonRpcId("http")); + }, id); + + private static async Task WithExceptionHandlingAsync(ILogger logger, string activityName, + Func> operation, string? taskId = null, CancellationToken cancellationToken = default) + { + using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity(activityName, ActivityKind.Server); + if (taskId is not null) + { + activity?.SetTag("task.id", taskId); + } + + try + { + return await operation(cancellationToken); + } + catch (A2AException ex) + { + logger.A2AErrorInActivityName(ex, activityName); + return MapA2AExceptionToHttpResult(ex); + } + catch (Exception ex) + { + logger.UnexpectedErrorInActivityName(ex, activityName); + return Results.Problem(detail: "An internal error occurred.", statusCode: StatusCodes.Status500InternalServerError); + } + } + + private static IResult WithExceptionHandling(ILogger logger, string activityName, + Func operation, string? taskId = null) + { + using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity(activityName, ActivityKind.Server); + if (taskId is not null) + { + activity?.SetTag("task.id", taskId); + } + + try + { + return operation(); + } + catch (A2AException ex) + { + logger.A2AErrorInActivityName(ex, activityName); + return MapA2AExceptionToHttpResult(ex); + } + catch (Exception ex) + { + logger.UnexpectedErrorInActivityName(ex, activityName); + return Results.Problem(detail: "An internal error occurred.", statusCode: StatusCodes.Status500InternalServerError); + } + } + + private static IResult MapA2AExceptionToHttpResult(A2AException exception) + { + return exception.ErrorCode switch + { + A2AErrorCode.TaskNotFound or + A2AErrorCode.MethodNotFound => Results.NotFound(exception.Message), + + A2AErrorCode.TaskNotCancelable or + A2AErrorCode.UnsupportedOperation or + A2AErrorCode.InvalidRequest or + A2AErrorCode.InvalidParams or + A2AErrorCode.ParseError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest), + + A2AErrorCode.PushNotificationNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest), + + A2AErrorCode.ContentTypeNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity), + + A2AErrorCode.InternalError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError), + + _ => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError) + }; + } + + // ======= REST API handler methods ======= + + // REST handler: Get agent card + internal static Task GetAgentCardRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, AgentCard agentCard, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.GetAgentCard", ct => + Task.FromResult(new A2AResponseResult(agentCard)), cancellationToken: cancellationToken); + + // REST handler: Get task by ID + internal static Task GetTaskRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string id, int? historyLength, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.GetTask", async ct => + { + var result = await requestHandler.GetTaskAsync( + new GetTaskRequest { Id = id, HistoryLength = historyLength }, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, id, cancellationToken); + + // REST handler: Cancel task + internal static Task CancelTaskRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.CancelTask", async ct => + { + var result = await requestHandler.CancelTaskAsync( + new CancelTaskRequest { Id = id }, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, id, cancellationToken); + + // REST handler: Send message + internal static Task SendMessageRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest request, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.SendMessage", async ct => + { + var result = await requestHandler.SendMessageAsync(request, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, cancellationToken: cancellationToken); + + // REST handler: Send streaming message + internal static IResult SendMessageStreamRest( + IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest request, CancellationToken cancellationToken) + => WithExceptionHandling(logger, "REST.SendMessageStream", () => + { + var events = requestHandler.SendStreamingMessageAsync(request, cancellationToken); + return new A2AEventStreamResult(events); + }); + + // REST handler: Subscribe to task + internal static IResult SubscribeToTaskRest( + IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken) + => WithExceptionHandling(logger, "REST.SubscribeToTask", () => + { + var events = requestHandler.SubscribeToTaskAsync( + new SubscribeToTaskRequest { Id = id }, cancellationToken); + return new A2AEventStreamResult(events); + }, id); + + // REST handler: List tasks + internal static Task ListTasksRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string? contextId, string? status, int? pageSize, + string? pageToken, int? historyLength, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.ListTasks", async ct => + { + var request = new ListTasksRequest + { + ContextId = contextId, + PageSize = pageSize, + PageToken = pageToken, + HistoryLength = historyLength, + }; + if (!string.IsNullOrEmpty(status)) + { + if (!Enum.TryParse(status, ignoreCase: true, out var taskState)) + { + return Results.Problem( + detail: $"Invalid status filter: '{status}'. Valid values: {string.Join(", ", Enum.GetNames())}", + statusCode: StatusCodes.Status400BadRequest); + } + request.Status = taskState; + } + + var result = await requestHandler.ListTasksAsync(request, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, cancellationToken: cancellationToken); + + // REST handler: Get extended agent card + internal static Task GetExtendedAgentCardRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.GetExtendedAgentCard", async ct => + { + var result = await requestHandler.GetExtendedAgentCardAsync( + new GetExtendedAgentCardRequest(), ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, cancellationToken: cancellationToken); + + // REST handler: Create push notification config + internal static Task CreatePushNotificationConfigRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string taskId, PushNotificationConfig config, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.CreatePushNotificationConfig", async ct => + { + var request = new CreateTaskPushNotificationConfigRequest + { + TaskId = taskId, + Config = config, + ConfigId = config.Id ?? string.Empty, + }; + var result = await requestHandler.CreateTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, taskId, cancellationToken); + + // REST handler: List push notification configs for a task + internal static Task ListPushNotificationConfigRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string taskId, int? pageSize, string? pageToken, + CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.ListPushNotificationConfig", async ct => + { + var request = new ListTaskPushNotificationConfigRequest + { + TaskId = taskId, + PageSize = pageSize, + PageToken = pageToken, + }; + var result = await requestHandler.ListTaskPushNotificationConfigAsync(request, ct) + .ConfigureAwait(false); + return new A2AResponseResult(result); + }, taskId, cancellationToken); + + // REST handler: Get push notification config + internal static Task GetPushNotificationConfigRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string taskId, string configId, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.GetPushNotificationConfig", async ct => + { + var request = new GetTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId }; + var result = await requestHandler.GetTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false); + return new A2AResponseResult(result); + }, taskId, cancellationToken); + + // REST handler: Delete push notification config + internal static Task DeletePushNotificationConfigRestAsync( + IA2ARequestHandler requestHandler, ILogger logger, string taskId, string configId, CancellationToken cancellationToken) + => WithExceptionHandlingAsync(logger, "REST.DeletePushNotificationConfig", async ct => + { + var request = new DeleteTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId }; + await requestHandler.DeleteTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false); + return Results.NoContent(); + }, taskId, cancellationToken); +} + +/// IResult for REST API JSON responses. +internal sealed class A2AResponseResult : IResult +{ + private readonly object _response; + private readonly Type _responseType; + + internal A2AResponseResult(SendMessageResponse response) { _response = response; _responseType = typeof(SendMessageResponse); } + internal A2AResponseResult(AgentTask task) { _response = task; _responseType = typeof(AgentTask); } + internal A2AResponseResult(ListTasksResponse response) { _response = response; _responseType = typeof(ListTasksResponse); } + internal A2AResponseResult(AgentCard card) { _response = card; _responseType = typeof(AgentCard); } + internal A2AResponseResult(TaskPushNotificationConfig config) { _response = config; _responseType = typeof(TaskPushNotificationConfig); } + internal A2AResponseResult(ListTaskPushNotificationConfigResponse response) { _response = response; _responseType = typeof(ListTaskPushNotificationConfigResponse); } + + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(httpContext.Response.Body, _response, + A2AJsonUtilities.DefaultOptions.GetTypeInfo(_responseType)); + } +} + +/// IResult for REST API Server-Sent Events streaming. +internal sealed class A2AEventStreamResult : IResult +{ + private readonly IAsyncEnumerable _events; + + internal A2AEventStreamResult(IAsyncEnumerable events) => _events = events; + + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.CacheControl = "no-cache,no-store"; + httpContext.Response.Headers.Pragma = "no-cache"; + httpContext.Response.Headers.ContentEncoding = "identity"; + + var bufferingFeature = httpContext.Features.GetRequiredFeature(); + bufferingFeature.DisableBuffering(); + + try + { + await foreach (var taskEvent in _events.WithCancellation(httpContext.RequestAborted)) + { + var json = JsonSerializer.Serialize(taskEvent, + A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StreamResponse))); + await httpContext.Response.BodyWriter.WriteAsync( + Encoding.UTF8.GetBytes($"data: {json}\n\n"), httpContext.RequestAborted); + await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted); + } + } + catch (OperationCanceledException) + { + // Client disconnected — expected + } + catch (Exception) + { + // Stream error — response already started, best-effort error event + try + { + await httpContext.Response.BodyWriter.WriteAsync( + Encoding.UTF8.GetBytes("data: {\"error\":\"An internal error occurred during streaming.\"}\n\n"), httpContext.RequestAborted); + await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted); + } + catch + { + // Response body no longer writable + } + } + } +} diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs index 6fdf8d3b..44f6588a 100644 --- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs +++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs @@ -8,31 +8,22 @@ namespace A2A.AspNetCore; /// /// Static processor class for handling A2A JSON-RPC requests in ASP.NET Core applications. /// -/// -/// Provides methods for processing JSON-RPC 2.0 protocol requests including message sending, -/// task operations, streaming responses, and push notification configuration. -/// public static class A2AJsonRpcProcessor { - /// - /// OpenTelemetry ActivitySource for tracing JSON-RPC processor operations. - /// - public static readonly ActivitySource ActivitySource = new("A2A.Processor", "1.0.0"); - - /// - /// Processes an incoming JSON-RPC request and routes it to the appropriate handler. - /// - /// - /// Determines whether the request requires a single response or streaming response. - /// based on the method name and dispatches accordingly. - /// - /// The task manager instance for handling A2A operations. - /// Http request containing the JSON-RPC request body. - /// The cancellation token to cancel the operation if needed. - /// An HTTP result containing either a single JSON-RPC response or a streaming SSE response. - internal static async Task ProcessRequestAsync(ITaskManager taskManager, HttpRequest request, CancellationToken cancellationToken) + internal static async Task ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken) { - using var activity = ActivitySource.StartActivity("HandleA2ARequest", ActivityKind.Server); + // Version negotiation: check A2A-Version header + var version = request.Headers["A2A-Version"].FirstOrDefault(); + if (!string.IsNullOrEmpty(version) && version != "1.0" && version != "0.3") + { + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse( + new JsonRpcId((string?)null), + new A2AException( + $"Protocol version '{version}' is not supported. Supported versions: 0.3, 1.0", + A2AErrorCode.VersionNotSupported))); + } + + using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("HandleA2ARequest", ActivityKind.Server); JsonRpcRequest? rpcRequest = null; @@ -40,16 +31,15 @@ internal static async Task ProcessRequestAsync(ITaskManager taskManager { rpcRequest = (JsonRpcRequest?)await JsonSerializer.DeserializeAsync(request.Body, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcRequest)), cancellationToken).ConfigureAwait(false); - activity?.AddTag("request.id", rpcRequest!.Id.ToString()); - activity?.AddTag("request.method", rpcRequest!.Method); + activity?.SetTag("request.id", rpcRequest!.Id.ToString()); + activity?.SetTag("request.method", rpcRequest!.Method); - // Dispatch based on return type if (A2AMethods.IsStreamingMethod(rpcRequest!.Method)) { - return StreamResponse(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken); + return StreamResponse(requestHandler, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken); } - return await SingleResponseAsync(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false); + return await SingleResponseAsync(requestHandler, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false); } catch (A2AException ex) { @@ -61,26 +51,13 @@ internal static async Task ProcessRequestAsync(ITaskManager taskManager { activity?.SetStatus(ActivityStatusCode.Error, ex.Message); var errorId = rpcRequest?.Id ?? new JsonRpcId((string?)null); - return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, ex.Message)); + return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, "An internal error occurred.")); } } - /// - /// Processes JSON-RPC requests that require a single response (non-streaming). - /// - /// - /// Handles methods like message sending, task retrieval, task cancellation, - /// and push notification configuration operations. - /// - /// The task manager instance for handling A2A operations. - /// The JSON-RPC request ID for response correlation. - /// The JSON-RPC method name to execute. - /// The JSON parameters for the method call. - /// A cancellation token that can be used to cancel the operation. - /// A JSON-RPC response result containing the operation result or error. - internal static async Task SingleResponseAsync(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) + internal static async Task SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) { - using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server); + using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity($"SingleResponse/{method}", ActivityKind.Server); activity?.SetTag("request.id", requestId.ToString()); activity?.SetTag("request.method", method); @@ -92,34 +69,89 @@ internal static async Task SingleResponseAsync(ITaskManag return new JsonRpcResponseResult(JsonRpcResponse.InvalidParamsResponse(requestId)); } + // For push notification methods, check if push notifications are supported + // BEFORE deserializing params. DeserializeAndValidate would throw InvalidParams + // for malformed requests, masking the PushNotificationNotSupported error. + if (A2AMethods.IsPushNotificationMethod(method)) + { + try + { + await requestHandler.GetTaskPushNotificationConfigAsync(null!, cancellationToken).ConfigureAwait(false); + } + catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.PushNotificationNotSupported) + { + throw; + } + catch + { + // Any other exception means push notifications are supported; + // continue with normal deserialization and handling. + } + } + switch (method) { - case A2AMethods.MessageSend: - var taskSendParams = DeserializeAndValidate(parameters.Value); - var a2aResponse = await taskManager.SendMessageAsync(taskSendParams, cancellationToken).ConfigureAwait(false); - response = JsonRpcResponse.CreateJsonRpcResponse(requestId, a2aResponse); + case A2AMethods.SendMessage: + var sendRequest = DeserializeAndValidate(parameters.Value); + var sendResult = await requestHandler.SendMessageAsync(sendRequest, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, sendResult); + break; + case A2AMethods.GetTask: + var getTaskRequest = DeserializeAndValidate(parameters.Value); + var agentTask = await requestHandler.GetTaskAsync(getTaskRequest, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, agentTask); break; - case A2AMethods.TaskGet: - var taskIdParams = DeserializeAndValidate(parameters.Value); - var getAgentTask = await taskManager.GetTaskAsync(taskIdParams, cancellationToken).ConfigureAwait(false); - response = getAgentTask is null - ? JsonRpcResponse.TaskNotFoundResponse(requestId) - : JsonRpcResponse.CreateJsonRpcResponse(requestId, getAgentTask); + case A2AMethods.ListTasks: + var listTasksRequest = DeserializeAndValidate(parameters.Value); + + // Validate pageSize: must be 1-100 if specified + if (listTasksRequest.PageSize is { } ps && (ps <= 0 || ps > 100)) + { + throw new A2AException( + $"Invalid pageSize: {ps}. Must be between 1 and 100.", + A2AErrorCode.InvalidParams); + } + + // Validate historyLength: must be >= 0 if specified + if (listTasksRequest.HistoryLength is { } hl && hl < 0) + { + throw new A2AException( + $"Invalid historyLength: {hl}. Must be non-negative.", + A2AErrorCode.InvalidParams); + } + + var listResult = await requestHandler.ListTasksAsync(listTasksRequest, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, listResult); break; - case A2AMethods.TaskCancel: - var taskIdParamsCancel = DeserializeAndValidate(parameters.Value); - var cancelledTask = await taskManager.CancelTaskAsync(taskIdParamsCancel, cancellationToken).ConfigureAwait(false); + case A2AMethods.CancelTask: + var cancelRequest = DeserializeAndValidate(parameters.Value); + var cancelledTask = await requestHandler.CancelTaskAsync(cancelRequest, cancellationToken).ConfigureAwait(false); response = JsonRpcResponse.CreateJsonRpcResponse(requestId, cancelledTask); break; - case A2AMethods.TaskPushNotificationConfigSet: - var taskPushNotificationConfig = DeserializeAndValidate(parameters.Value); - var setConfig = await taskManager.SetPushNotificationAsync(taskPushNotificationConfig, cancellationToken).ConfigureAwait(false); - response = JsonRpcResponse.CreateJsonRpcResponse(requestId, setConfig); + case A2AMethods.CreateTaskPushNotificationConfig: + var createPnConfig = DeserializeAndValidate(parameters.Value); + var createdConfig = await requestHandler.CreateTaskPushNotificationConfigAsync(createPnConfig, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, createdConfig); + break; + case A2AMethods.GetTaskPushNotificationConfig: + var getPnConfig = DeserializeAndValidate(parameters.Value); + var gotConfig = await requestHandler.GetTaskPushNotificationConfigAsync(getPnConfig, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, gotConfig); + break; + case A2AMethods.ListTaskPushNotificationConfig: + var listPnConfig = DeserializeAndValidate(parameters.Value); + var listPnResult = await requestHandler.ListTaskPushNotificationConfigAsync(listPnConfig, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, listPnResult); + break; + case A2AMethods.DeleteTaskPushNotificationConfig: + var deletePnConfig = DeserializeAndValidate(parameters.Value); + await requestHandler.DeleteTaskPushNotificationConfigAsync(deletePnConfig, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, (object?)null); break; - case A2AMethods.TaskPushNotificationConfigGet: - var notificationConfigParams = DeserializeAndValidate(parameters.Value); - var getConfig = await taskManager.GetPushNotificationAsync(notificationConfigParams, cancellationToken).ConfigureAwait(false); - response = JsonRpcResponse.CreateJsonRpcResponse(requestId, getConfig); + case A2AMethods.GetExtendedAgentCard: + var getCardRequest = DeserializeAndValidate(parameters.Value); + var extCard = await requestHandler.GetExtendedAgentCardAsync(getCardRequest, cancellationToken).ConfigureAwait(false); + response = JsonRpcResponse.CreateJsonRpcResponse(requestId, extCard); break; default: response = JsonRpcResponse.MethodNotFoundResponse(requestId); @@ -138,39 +170,25 @@ private static T DeserializeAndValidate(JsonElement jsonParamValue) where T : } catch (JsonException ex) { - // Provide more specific error information about why parameter deserialization failed - throw new A2AException($"Invalid parameters for {typeof(T).Name}: {ex.Message}", ex, A2AErrorCode.InvalidParams); + throw new A2AException($"Invalid parameters: request body could not be deserialized as {typeof(T).Name}.", ex, A2AErrorCode.InvalidParams); } - switch (parms) + if (parms is null) { - case null: - throw new A2AException($"Failed to deserialize parameters as {typeof(T).Name}", A2AErrorCode.InvalidParams); - case MessageSendParams messageSendParams when messageSendParams.Message.Parts.Count == 0: - throw new A2AException("Message parts cannot be empty", A2AErrorCode.InvalidParams); - case TaskQueryParams taskQueryParams when taskQueryParams.HistoryLength < 0: - throw new A2AException("History length cannot be negative", A2AErrorCode.InvalidParams); - default: - return parms; + throw new A2AException($"Failed to deserialize parameters as {typeof(T).Name}", A2AErrorCode.InvalidParams); } + + if (parms is SendMessageRequest sendMsgRequest && sendMsgRequest.Message.Parts.Count == 0) + { + throw new A2AException("Message parts cannot be empty", A2AErrorCode.InvalidParams); + } + + return parms; } - /// - /// Processes JSON-RPC requests that require streaming responses using Server-Sent Events. - /// - /// - /// Handles methods like task resubscription and streaming message sending that return - /// continuous streams of events rather than single responses. - /// - /// The task manager instance for handling streaming A2A operations. - /// The JSON-RPC request ID for response correlation. - /// The JSON-RPC streaming method name to execute. - /// The JSON parameters for the streaming method call. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result that streams JSON-RPC responses as Server-Sent Events or an error response. - internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) + internal static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) { - using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server); + using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("StreamResponse", ActivityKind.Server); activity?.SetTag("request.id", requestId.ToString()); if (parameters == null) @@ -181,17 +199,17 @@ internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId reque switch (method) { - case A2AMethods.TaskSubscribe: - var taskIdParams = DeserializeAndValidate(parameters.Value); - var taskEvents = taskManager.SubscribeToTaskAsync(taskIdParams, cancellationToken); + case A2AMethods.SubscribeToTask: + var subscribeRequest = DeserializeAndValidate(parameters.Value); + var taskEvents = requestHandler.SubscribeToTaskAsync(subscribeRequest, cancellationToken); return new JsonRpcStreamedResult(taskEvents, requestId); - case A2AMethods.MessageStream: - var taskSendParams = DeserializeAndValidate(parameters.Value); - var sendEvents = taskManager.SendMessageStreamingAsync(taskSendParams, cancellationToken); + case A2AMethods.SendStreamingMessage: + var sendRequest = DeserializeAndValidate(parameters.Value); + var sendEvents = requestHandler.SendStreamingMessageAsync(sendRequest, cancellationToken); return new JsonRpcStreamedResult(sendEvents, requestId); default: activity?.SetStatus(ActivityStatusCode.Error, "Invalid method"); return new JsonRpcResponseResult(JsonRpcResponse.MethodNotFoundResponse(requestId)); } } -} \ No newline at end of file +} diff --git a/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs b/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs new file mode 100644 index 00000000..e009f0f7 --- /dev/null +++ b/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace A2A.AspNetCore; + +/// +/// DI registration extensions for the A2A easy-path agent hosting. +/// +public static class A2AServiceCollectionExtensions +{ + /// + /// Registers an and supporting infrastructure + /// for the easy-path agent hosting pattern. Call + /// to map the endpoint. + /// + /// The agent handler type implementing . + /// The service collection. + /// The agent card describing this agent's capabilities. + /// Optional callback to configure . + /// The service collection for chaining. + public static IServiceCollection AddA2AAgent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( + this IServiceCollection services, + AgentCard agentCard, + Action? configureOptions = null) + where THandler : class, IAgentHandler + { + services.AddSingleton(); + services.AddSingleton(agentCard); + + var options = new A2AServerOptions(); + configureOptions?.Invoke(options); + services.AddSingleton(options); + + services.TryAddSingleton(); + + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + new A2AServer( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService())); + + return services; + } +} diff --git a/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs b/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs deleted file mode 100644 index 20e29498..00000000 --- a/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using System.Text.Json; - -namespace A2A.AspNetCore.Caching; - -/// -/// Distributed cache implementation of task store. -/// -/// The used to store tasks. -public class DistributedCacheTaskStore(IDistributedCache cache) - : ITaskStore -{ - /// - public async Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrEmpty(taskId)) - { - throw new ArgumentNullException(nameof(taskId)); - } - var bytes = await cache.GetAsync(BuildTaskCacheKey(taskId), cancellationToken).ConfigureAwait(false); - if (bytes == null || bytes.Length < 1) - { - return null; - } - return JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.AgentTask); - } - - /// - public async Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrEmpty(taskId)) - { - throw new ArgumentNullException(nameof(taskId)); - } - var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(taskId), cancellationToken).ConfigureAwait(false); - if (bytes == null || bytes.Length < 1) - { - return null; - } - var pushNotificationConfigs = JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig); - if (pushNotificationConfigs == null || pushNotificationConfigs.Count < 1) - { - return null; - } - return pushNotificationConfigs.FirstOrDefault(config => config.PushNotificationConfig.Id == notificationConfigId); - } - - /// - public async Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(taskId), cancellationToken).ConfigureAwait(false); - if (bytes == null || bytes.Length < 1) - { - return []; - } - return JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig) ?? []; - } - - /// - public async Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrEmpty(taskId)) - { - throw new ArgumentNullException(nameof(taskId)); - } - var cacheKey = BuildTaskCacheKey(taskId); - var bytes = await cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); - if (bytes == null || bytes.Length < 1) - { - throw new A2AException($"Task with ID '{taskId}' not found in cache.", A2AErrorCode.TaskNotFound); - } - var task = JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.AgentTask) ?? throw new InvalidDataException("Task data from cache is corrupt."); - task.Status = task.Status with - { - State = status, - Message = message, - Timestamp = DateTimeOffset.UtcNow - }; - bytes = JsonSerializer.SerializeToUtf8Bytes(task, A2AJsonUtilities.JsonContext.Default.AgentTask); - await cache.SetAsync(cacheKey, bytes, cancellationToken).ConfigureAwait(false); - return task.Status; - } - - /// - public async Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - var bytes = JsonSerializer.SerializeToUtf8Bytes(task, A2AJsonUtilities.JsonContext.Default.AgentTask); - await cache.SetAsync(BuildTaskCacheKey(task.Id), bytes, cancellationToken).ConfigureAwait(false); - } - - /// - public async Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (pushNotificationConfig is null) - { - throw new ArgumentNullException(nameof(pushNotificationConfig)); - } - - if (string.IsNullOrWhiteSpace(pushNotificationConfig.TaskId)) - { - throw new ArgumentException("Task ID cannot be null or empty.", nameof(pushNotificationConfig)); - } - var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(pushNotificationConfig.TaskId), cancellationToken).ConfigureAwait(false); - var pushNotificationConfigs = bytes == null ? [] : JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig) ?? []; - pushNotificationConfigs.RemoveAll(c => c.PushNotificationConfig.Id == pushNotificationConfig.PushNotificationConfig.Id); - pushNotificationConfigs.Add(pushNotificationConfig); - bytes = JsonSerializer.SerializeToUtf8Bytes(pushNotificationConfigs, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig); - await cache.SetAsync(BuildPushNotificationsCacheKey(pushNotificationConfig.TaskId), bytes, cancellationToken).ConfigureAwait(false); - } - - static string BuildTaskCacheKey(string taskId) => $"task:{taskId}"; - - static string BuildPushNotificationsCacheKey(string taskId) => $"task-push-notification:{taskId}"; -} diff --git a/src/A2A.AspNetCore/JsonRpcResponseResult.cs b/src/A2A.AspNetCore/JsonRpcResponseResult.cs index a581ec7c..56734181 100644 --- a/src/A2A.AspNetCore/JsonRpcResponseResult.cs +++ b/src/A2A.AspNetCore/JsonRpcResponseResult.cs @@ -10,7 +10,7 @@ namespace A2A.AspNetCore; /// Implements IResult to provide custom serialization of JSON-RPC response objects /// with appropriate HTTP status codes based on success or error conditions. /// -public class JsonRpcResponseResult : IResult +public sealed class JsonRpcResponseResult : IResult { private readonly JsonRpcResponse jsonRpcResponse; diff --git a/src/A2A.AspNetCore/JsonRpcStreamedResult.cs b/src/A2A.AspNetCore/JsonRpcStreamedResult.cs index f5588d0b..8f1790e8 100644 --- a/src/A2A.AspNetCore/JsonRpcStreamedResult.cs +++ b/src/A2A.AspNetCore/JsonRpcStreamedResult.cs @@ -1,61 +1,72 @@ -using Microsoft.AspNetCore.Http; -using System.Net.ServerSentEvents; -using System.Text.Encodings.Web; -using System.Text.Json; - -namespace A2A.AspNetCore; - -/// -/// Result type for streaming JSON-RPC responses as Server-Sent Events (SSE) in HTTP responses. -/// -/// -/// Implements IResult to provide real-time streaming of JSON-RPC responses for continuous -/// event streams like task updates, status changes, and artifact notifications. -/// -public class JsonRpcStreamedResult : IResult -{ - private readonly IAsyncEnumerable _events; - private readonly JsonRpcId requestId; - - /// - /// Initializes a new instance of the JsonRpcStreamedResult class. - /// - /// The async enumerable stream of A2A events to send as Server-Sent Events. - /// The JSON-RPC request ID used for correlating responses with the original request. - public JsonRpcStreamedResult(IAsyncEnumerable events, JsonRpcId requestId) - { - ArgumentNullException.ThrowIfNull(events); - - _events = events; - this.requestId = requestId; - } - - /// - /// Executes the result by streaming JSON-RPC responses as Server-Sent Events to the HTTP response. - /// - /// - /// Sets appropriate SSE headers, wraps each A2A event in a JSON-RPC response format, - /// and streams them using the SSE protocol with proper formatting and encoding. - /// - /// The HTTP context to stream the responses to. - /// A task representing the asynchronous streaming operation. - public async Task ExecuteAsync(HttpContext httpContext) - { - ArgumentNullException.ThrowIfNull(httpContext); - - httpContext.Response.StatusCode = StatusCodes.Status200OK; - httpContext.Response.ContentType = "text/event-stream"; - httpContext.Response.Headers.Append("Cache-Control", "no-cache"); - - var responseTypeInfo = A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse)); - await SseFormatter.WriteAsync( - _events.Select(e => new SseItem(JsonRpcResponse.CreateJsonRpcResponse(requestId, e))), - httpContext.Response.Body, - (item, writer) => - { - using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - JsonSerializer.Serialize(json, item.Data, responseTypeInfo); - }, - httpContext.RequestAborted).ConfigureAwait(false); - } +using Microsoft.AspNetCore.Http; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace A2A.AspNetCore; + +/// +/// Result type for streaming JSON-RPC responses as Server-Sent Events (SSE) in HTTP responses. +/// +public sealed class JsonRpcStreamedResult : IResult +{ + private readonly IAsyncEnumerable _events; + private readonly JsonRpcId _requestId; + + /// Initializes a new instance of the class. + /// The stream of response events. + /// The JSON-RPC request ID. + public JsonRpcStreamedResult(IAsyncEnumerable events, JsonRpcId requestId) + { + ArgumentNullException.ThrowIfNull(events); + _events = events; + _requestId = requestId; + } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.StatusCode = StatusCodes.Status200OK; + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.Append("Cache-Control", "no-cache"); + + var responseTypeInfo = A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse)); + try + { + await SseFormatter.WriteAsync( + _events.Select(e => new SseItem(JsonRpcResponse.CreateJsonRpcResponse(_requestId, e))), + httpContext.Response.Body, + (item, writer) => + { + using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + JsonSerializer.Serialize(json, item.Data, responseTypeInfo); + }, + httpContext.RequestAborted).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Client disconnected — expected + } + catch (Exception) + { + // Stream error — response already started, cannot change status code. + // Best effort: write an error event if the response body is still writable. + try + { + var errorResponse = JsonRpcResponse.InternalErrorResponse( + _requestId, "An internal error occurred during streaming."); + var errorJson = JsonSerializer.Serialize(errorResponse, responseTypeInfo); + var errorBytes = Encoding.UTF8.GetBytes($"data: {errorJson}\n\n"); + await httpContext.Response.Body.WriteAsync(errorBytes, httpContext.RequestAborted); + await httpContext.Response.Body.FlushAsync(httpContext.RequestAborted); + } + catch + { + // Response body is no longer writable — silently abandon + } + } + } } \ No newline at end of file diff --git a/src/A2A.V0_3/A2A.V0_3.csproj b/src/A2A.V0_3/A2A.V0_3.csproj new file mode 100644 index 00000000..aa1f42f4 --- /dev/null +++ b/src/A2A.V0_3/A2A.V0_3.csproj @@ -0,0 +1,46 @@ + + + + net10.0;net8.0 + true + + + A2A.V0_3 + .NET SDK for the Agent2Agent (A2A) protocol v0.3 (legacy). + Agent2Agent;a2a;agent;ai;llm;aspnetcore + README.md + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/A2A.V0_3/A2AErrorCode.cs b/src/A2A.V0_3/A2AErrorCode.cs new file mode 100644 index 00000000..c289cd6a --- /dev/null +++ b/src/A2A.V0_3/A2AErrorCode.cs @@ -0,0 +1,57 @@ +namespace A2A.V0_3; + +/// +/// Standard JSON-RPC error codes used in A2A protocol. +/// +public enum A2AErrorCode +{ + /// + /// Task not found - The specified task does not exist. + /// + TaskNotFound = -32001, + + /// + /// Task not cancelable - The task cannot be canceled. + /// + TaskNotCancelable = -32002, + + /// + /// Push notification not supported - Push notifications are not supported. + /// + PushNotificationNotSupported = -32003, + + /// + /// Unsupported operation - The requested operation is not supported. + /// + UnsupportedOperation = -32004, + + /// + /// Content type not supported - The content type is not supported. + /// + ContentTypeNotSupported = -32005, + + /// + /// Invalid request - The JSON is not a valid Request object. + /// + InvalidRequest = -32600, + + /// + /// Method not found - The method does not exist or is not available. + /// + MethodNotFound = -32601, + + /// + /// Invalid params - Invalid method parameters. + /// + InvalidParams = -32602, + + /// + /// Internal error - Internal JSON-RPC error. + /// + InternalError = -32603, + + /// + /// Parse error - Invalid JSON received. + /// + ParseError = -32700, +} \ No newline at end of file diff --git a/src/A2A.V0_3/A2AException.cs b/src/A2A.V0_3/A2AException.cs new file mode 100644 index 00000000..57666560 --- /dev/null +++ b/src/A2A.V0_3/A2AException.cs @@ -0,0 +1,78 @@ +namespace A2A.V0_3; + +/// +/// Represents an exception that is thrown when an Agent-to-Agent (A2A) protocol error occurs. +/// +/// +/// This exception is used to represent failures to do with protocol-level concerns, such as invalid JSON-RPC requests, +/// invalid parameters, or internal errors. It is not intended to be used for application-level errors. +/// or from a may be +/// propagated to the remote endpoint; sensitive information should not be included. If sensitive details need +/// to be included, a different exception type should be used. +/// +public class A2AException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public A2AException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public A2AException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public A2AException(string message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with a specified error message and JSON-RPC error code. + /// + /// The message that describes the error. + /// A . + public A2AException(string message, A2AErrorCode errorCode) : this(message, null, errorCode) + { + } + + /// + /// Initializes a new instance of the class with a specified error message, inner exception, and JSON-RPC error code. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// A . + public A2AException(string message, Exception? innerException, A2AErrorCode errorCode) : base(message, innerException) + { + ErrorCode = errorCode; + } + + /// + /// Gets the error code associated with this exception. + /// + /// + /// This property contains a standard JSON-RPC error code as defined in the A2A specification. Available error codes include: + /// + /// -32600: Invalid request - The JSON is not a valid Request object + /// -32601: Method not found - The method does not exist or is not available + /// -32602: Invalid params - Invalid method parameters + /// -32603: Internal error - Internal JSON-RPC error + /// -32700: Parse error - Invalid JSON received + /// -32001: Task not found - The specified task does not exist + /// -32002: Task not cancelable - The task cannot be canceled + /// -32003: Push notification not supported - Push notifications are not supported + /// -32004: Unsupported operation - The requested operation is not supported + /// -32005: Content type not supported - The content type is not supported + /// + /// + public A2AErrorCode ErrorCode { get; } = A2AErrorCode.InternalError; +} \ No newline at end of file diff --git a/src/A2A.V0_3/A2AExceptionExtensions.cs b/src/A2A.V0_3/A2AExceptionExtensions.cs new file mode 100644 index 00000000..e9ee1d80 --- /dev/null +++ b/src/A2A.V0_3/A2AExceptionExtensions.cs @@ -0,0 +1,84 @@ +namespace A2A.V0_3 +{ + /// + /// Provides extension methods for . + /// + public static class A2AExceptionExtensions + { + private const string RequestIdKey = "RequestId"; + + /// + /// Associates a request ID with the specified . + /// + /// The to associate the request ID with. + /// The request ID to associate with the exception. Can be null. + /// The same instance with the request ID stored in its Data collection. + /// + /// This method stores the request ID in the exception's Data collection using the key "RequestId". + /// The request ID can be later retrieved using the method. + /// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios. + /// + public static A2AException WithRequestId(this A2AException exception, string? requestId) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + exception.Data[RequestIdKey] = requestId; + + return exception; + } + + /// + /// Associates a request ID with the specified . + /// + /// The to associate the request ID with. + /// The request ID to associate with the exception. + /// The same instance with the request ID stored in its Data collection. + /// + /// This method stores the request ID in the exception's Data collection using the key "RequestId". + /// The request ID can be later retrieved using the method. + /// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios. + /// + public static A2AException WithRequestId(this A2AException exception, JsonRpcId requestId) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + exception.Data[RequestIdKey] = requestId.ToString(); + + return exception; + } + + /// + /// Retrieves the request ID associated with the specified . + /// + /// The to retrieve the request ID from. + /// + /// The request ID associated with the exception if one was previously set using , + /// or null if no request ID was set or if the stored value is not a string. + /// + /// + /// This method retrieves the request ID from the exception's Data collection using the key "RequestId". + /// If the stored value is not a string or doesn't exist, null is returned. + /// This method is typically used in exception handlers to correlate exceptions with specific HTTP requests. + /// + public static string? GetRequestId(this A2AException exception) + { + if (exception is null) + { + throw new ArgumentNullException(nameof(exception)); + } + + if (exception.Data[RequestIdKey] is string requestIdString) + { + return requestIdString; + } + + return null; + } + } +} diff --git a/src/A2A/A2AJsonConverter.cs b/src/A2A.V0_3/A2AJsonConverter.cs similarity index 97% rename from src/A2A/A2AJsonConverter.cs rename to src/A2A.V0_3/A2AJsonConverter.cs index 6d50da9a..24ac2b78 100644 --- a/src/A2A/A2AJsonConverter.cs +++ b/src/A2A.V0_3/A2AJsonConverter.cs @@ -1,129 +1,129 @@ -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace A2A -{ - internal interface IA2AJsonConverter; - - /// - /// Provides a base JSON converter for types in the A2A protocol, enabling custom error handling and - /// safe delegation to source-generated or built-in converters for the target type. - /// - /// The type to convert. - internal class A2AJsonConverter : JsonConverter, IA2AJsonConverter where T : notnull - { - private static JsonSerializerOptions? _serializerOptionsWithoutThisConverter; - private static JsonSerializerOptions? _outsideSerializerOptions; - - /// - /// Reads and converts the JSON to type . - /// - /// The reader to read from. - /// The type to convert. - /// The serializer options to use. - /// The deserialized value of type . - /// - /// Thrown when deserialization fails, wrapping the original exception and providing an error code. - /// - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - using var d = JsonDocument.ParseValue(ref reader); - return DeserializeImpl(typeToConvert, GetSafeOptions(options), d); - } - - /// - /// Deserializes the specified to type using the provided options. - /// - /// The type to convert. - /// The serializer options to use. - /// The JSON document to deserialize. - /// The deserialized value of type . - protected virtual T? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) => document.Deserialize((JsonTypeInfo)options.GetTypeInfo(typeToConvert)); - - /// - /// Writes the specified value as JSON. - /// - /// The writer to write to. - /// The value to write. - /// The serializer options to use. - /// - /// Thrown when serialization fails, wrapping the original exception and providing an error code. - /// - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - { - try - { - if (value is null) - { - writer.WriteNullValue(); - return; - } - - SerializeImpl(writer, value, GetSafeOptions(options)); - } - catch (Exception e) - { - throw new A2AException($"Failed to serialize {typeof(T).Name}: {e.Message}", e, A2AErrorCode.InternalError); - } - } - - /// - /// Serializes the specified value to JSON using the provided options. - /// - /// The writer to write to. - /// The value to serialize. - /// The serializer options to use. - protected virtual void SerializeImpl(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, (JsonTypeInfo)options.GetTypeInfo(value.GetType())); - - /// - /// Returns a copy of the provided with this - /// removed from its chain. - /// - /// - /// This converter delegates to the source-generated or built-in converter for by - /// resolving from the options. If the original options are used as-is, this converter - /// would be selected again, causing infinite recursion and a stack overflow. Cloning the options and removing - /// this converter ensures the underlying, "real" converter handles serialization/deserialization of . - /// - /// The returned options are cached per closed generic type to avoid repeated allocations. The cache assumes a - /// stable options instance; if multiple distinct options are used, the first encountered configuration is captured. - /// - /// Caller options; used as a template for the safe copy. - /// A copy of the options that can safely resolve the underlying converter for . - private static JsonSerializerOptions GetSafeOptions(JsonSerializerOptions options) - { - if (_serializerOptionsWithoutThisConverter is null) - { - // keep reeference to original options for cache validation - _outsideSerializerOptions = options; - - // Clone options so we can modify the converters chain safely - var baseOptions = new JsonSerializerOptions(options); - - // Remove this converter so base/source-generated converter handles T, otherwise stack overflow - for (int i = baseOptions.Converters.Count - 1; i >= 0; i--) - { - if (baseOptions.Converters[i] is A2AJsonConverter) - { - baseOptions.Converters.RemoveAt(i); - break; - } - } - - _serializerOptionsWithoutThisConverter = baseOptions; - } - else if (_outsideSerializerOptions != options && options != _serializerOptionsWithoutThisConverter) - { - // Unexpected!!! This caching is based on promise that A2A will use only ONE instance of SerializerOptions - // and we can therefore cache modified SerializerOptions without dealing with invalidation and pairing. - // Since this is possible only by internal code, some recent code changes must have had broke this promise - Debug.Fail("This should never happen"); - throw new InvalidOperationException(); - } - - return _serializerOptionsWithoutThisConverter; - } - } +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace A2A.V0_3 +{ + internal interface IA2AJsonConverter; + + /// + /// Provides a base JSON converter for types in the A2A protocol, enabling custom error handling and + /// safe delegation to source-generated or built-in converters for the target type. + /// + /// The type to convert. + internal class A2AJsonConverter : JsonConverter, IA2AJsonConverter where T : notnull + { + private static JsonSerializerOptions? _serializerOptionsWithoutThisConverter; + private static JsonSerializerOptions? _outsideSerializerOptions; + + /// + /// Reads and converts the JSON to type . + /// + /// The reader to read from. + /// The type to convert. + /// The serializer options to use. + /// The deserialized value of type . + /// + /// Thrown when deserialization fails, wrapping the original exception and providing an error code. + /// + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var d = JsonDocument.ParseValue(ref reader); + return DeserializeImpl(typeToConvert, GetSafeOptions(options), d); + } + + /// + /// Deserializes the specified to type using the provided options. + /// + /// The type to convert. + /// The serializer options to use. + /// The JSON document to deserialize. + /// The deserialized value of type . + protected virtual T? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) => document.Deserialize((JsonTypeInfo)options.GetTypeInfo(typeToConvert)); + + /// + /// Writes the specified value as JSON. + /// + /// The writer to write to. + /// The value to write. + /// The serializer options to use. + /// + /// Thrown when serialization fails, wrapping the original exception and providing an error code. + /// + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + try + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + SerializeImpl(writer, value, GetSafeOptions(options)); + } + catch (Exception e) + { + throw new A2AException($"Failed to serialize {typeof(T).Name}: {e.Message}", e, A2AErrorCode.InternalError); + } + } + + /// + /// Serializes the specified value to JSON using the provided options. + /// + /// The writer to write to. + /// The value to serialize. + /// The serializer options to use. + protected virtual void SerializeImpl(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, (JsonTypeInfo)options.GetTypeInfo(value.GetType())); + + /// + /// Returns a copy of the provided with this + /// removed from its chain. + /// + /// + /// This converter delegates to the source-generated or built-in converter for by + /// resolving from the options. If the original options are used as-is, this converter + /// would be selected again, causing infinite recursion and a stack overflow. Cloning the options and removing + /// this converter ensures the underlying, "real" converter handles serialization/deserialization of . + /// + /// The returned options are cached per closed generic type to avoid repeated allocations. The cache assumes a + /// stable options instance; if multiple distinct options are used, the first encountered configuration is captured. + /// + /// Caller options; used as a template for the safe copy. + /// A copy of the options that can safely resolve the underlying converter for . + private static JsonSerializerOptions GetSafeOptions(JsonSerializerOptions options) + { + if (_serializerOptionsWithoutThisConverter is null) + { + // keep reeference to original options for cache validation + _outsideSerializerOptions = options; + + // Clone options so we can modify the converters chain safely + var baseOptions = new JsonSerializerOptions(options); + + // Remove this converter so base/source-generated converter handles T, otherwise stack overflow + for (int i = baseOptions.Converters.Count - 1; i >= 0; i--) + { + if (baseOptions.Converters[i] is A2AJsonConverter) + { + baseOptions.Converters.RemoveAt(i); + break; + } + } + + _serializerOptionsWithoutThisConverter = baseOptions; + } + else if (_outsideSerializerOptions != options && options != _serializerOptionsWithoutThisConverter) + { + // Unexpected!!! This caching is based on promise that A2A will use only ONE instance of SerializerOptions + // and we can therefore cache modified SerializerOptions without dealing with invalidation and pairing. + // Since this is possible only by internal code, some recent code changes must have had broke this promise + Debug.Fail("This should never happen"); + throw new InvalidOperationException(); + } + + return _serializerOptionsWithoutThisConverter; + } + } } \ No newline at end of file diff --git a/src/A2A.V0_3/A2AJsonUtilities.cs b/src/A2A.V0_3/A2AJsonUtilities.cs new file mode 100644 index 00000000..9fc44441 --- /dev/null +++ b/src/A2A.V0_3/A2AJsonUtilities.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.AI; +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Provides a collection of utility methods for working with JSON data in the context of A2A. +/// +public static partial class A2AJsonUtilities +{ + /// + /// Gets the singleton used as the default in JSON serialization operations. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for all common exchange types contained in the A2A library. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// Enables AllowOutOfOrderMetadataProperties to allow for type discriminators anywhere in a JSON payload. + /// + /// + /// + public static JsonSerializerOptions DefaultOptions => defaultOptions.Value; + + private static Lazy defaultOptions = new(() => + { + // Clone source-generated options so we can customize + var opts = new JsonSerializerOptions(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // optional: keep '+' unescaped + }; + + // Chain with all supported types from MEAI. + opts.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); + + // Register custom converters at options-level (not attributes) + opts.Converters.Add(new A2AJsonConverter()); + opts.Converters.Add(new FileContent.Converter()); + + opts.MakeReadOnly(); + return opts; + }); + + // Keep in sync with CreateDefaultOptions above. + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowOutOfOrderMetadataProperties = true)] + + // JSON-RPC + [JsonSerializable(typeof(JsonRpcError))] + [JsonSerializable(typeof(JsonRpcId))] + [JsonSerializable(typeof(JsonRpcRequest))] + [JsonSerializable(typeof(JsonRpcResponse))] + [JsonSerializable(typeof(Dictionary))] + + // A2A + [JsonSerializable(typeof(A2AEvent))] + [JsonSerializable(typeof(A2AResponse))] + [JsonSerializable(typeof(AgentCard))] + [JsonSerializable(typeof(AgentTask))] + [JsonSerializable(typeof(GetTaskPushNotificationConfigParams))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(MessageSendParams))] + [JsonSerializable(typeof(PushNotificationAuthenticationInfo))] + [JsonSerializable(typeof(PushNotificationConfig))] + [JsonSerializable(typeof(TaskIdParams))] + [JsonSerializable(typeof(TaskPushNotificationConfig))] + [JsonSerializable(typeof(TaskQueryParams))] + + [ExcludeFromCodeCoverage] + internal sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/src/A2A/BaseKindDiscriminatorConverter.cs b/src/A2A.V0_3/BaseKindDiscriminatorConverter.cs similarity index 97% rename from src/A2A/BaseKindDiscriminatorConverter.cs rename to src/A2A.V0_3/BaseKindDiscriminatorConverter.cs index 24ede8bf..d76084fe 100644 --- a/src/A2A/BaseKindDiscriminatorConverter.cs +++ b/src/A2A.V0_3/BaseKindDiscriminatorConverter.cs @@ -1,93 +1,93 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -internal abstract class BaseKindDiscriminatorConverter : JsonConverter - where TBase : class -{ - internal const string DiscriminatorPropertyName = "kind"; - - /// - /// Gets the mapping from kind string values to their corresponding concrete types. - /// - protected abstract IReadOnlyDictionary KindToTypeMapping { get; } - - /// - /// Gets the entity name used in error messages (e.g., "part", "file content", "event"). - /// - protected abstract string DisplayName { get; } - - /// - /// Reads an instance of from JSON using a kind discriminator. - /// - /// The to read from. - /// The type to convert (ignored). - /// Serialization options used to obtain type metadata. - /// The deserialized instance of . - public override TBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - using var document = JsonDocument.ParseValue(ref reader); - var root = document.RootElement; - - if (!root.TryGetProperty(DiscriminatorPropertyName, out var kindProp)) - { - throw new A2AException($"Missing required '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest); - } - - if (kindProp.ValueKind is not JsonValueKind.String) - { - throw new A2AException($"Invalid '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}: '{(kindProp.ValueKind is JsonValueKind.Null ? "null" : kindProp)}'.", A2AErrorCode.InvalidRequest); - } - - var kindValue = kindProp.GetString(); - if (string.IsNullOrEmpty(kindValue)) - { - throw new A2AException($"Missing '{DiscriminatorPropertyName}' discriminator value for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest); - } - - var kindToTypeMapping = KindToTypeMapping; - if (!kindToTypeMapping.TryGetValue(kindValue!, out var targetType)) - { - throw new A2AException($"Unknown {DisplayName} kind: '{kindValue}'", A2AErrorCode.InvalidRequest); - } - - TBase? obj = null; - Exception? deserializationException = null; - try - { - var typeInfo = options.GetTypeInfo(targetType); - obj = (TBase?)root.Deserialize(typeInfo); - } - catch (Exception e) - { - deserializationException = e; - } - - if (deserializationException is not null || obj is null) - { - throw new A2AException($"Failed to deserialize '{kindValue}' {DisplayName}", deserializationException, A2AErrorCode.InvalidRequest); - } - - return obj; - } - - /// - /// Writes the provided value to JSON. - /// - /// The to write to. - /// The value to write. - /// Serialization options used to obtain type metadata. - public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options) - { - var element = JsonSerializer.SerializeToElement(value, options.GetTypeInfo(value.GetType())); - writer.WriteStartObject(); - - foreach (var prop in element.EnumerateObject()) - { - prop.WriteTo(writer); - } - - writer.WriteEndObject(); - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +internal abstract class BaseKindDiscriminatorConverter : JsonConverter + where TBase : class +{ + internal const string DiscriminatorPropertyName = "kind"; + + /// + /// Gets the mapping from kind string values to their corresponding concrete types. + /// + protected abstract IReadOnlyDictionary KindToTypeMapping { get; } + + /// + /// Gets the entity name used in error messages (e.g., "part", "file content", "event"). + /// + protected abstract string DisplayName { get; } + + /// + /// Reads an instance of from JSON using a kind discriminator. + /// + /// The to read from. + /// The type to convert (ignored). + /// Serialization options used to obtain type metadata. + /// The deserialized instance of . + public override TBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + + if (!root.TryGetProperty(DiscriminatorPropertyName, out var kindProp)) + { + throw new A2AException($"Missing required '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest); + } + + if (kindProp.ValueKind is not JsonValueKind.String) + { + throw new A2AException($"Invalid '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}: '{(kindProp.ValueKind is JsonValueKind.Null ? "null" : kindProp)}'.", A2AErrorCode.InvalidRequest); + } + + var kindValue = kindProp.GetString(); + if (string.IsNullOrEmpty(kindValue)) + { + throw new A2AException($"Missing '{DiscriminatorPropertyName}' discriminator value for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest); + } + + var kindToTypeMapping = KindToTypeMapping; + if (!kindToTypeMapping.TryGetValue(kindValue!, out var targetType)) + { + throw new A2AException($"Unknown {DisplayName} kind: '{kindValue}'", A2AErrorCode.InvalidRequest); + } + + TBase? obj = null; + Exception? deserializationException = null; + try + { + var typeInfo = options.GetTypeInfo(targetType); + obj = (TBase?)root.Deserialize(typeInfo); + } + catch (Exception e) + { + deserializationException = e; + } + + if (deserializationException is not null || obj is null) + { + throw new A2AException($"Failed to deserialize '{kindValue}' {DisplayName}", deserializationException, A2AErrorCode.InvalidRequest); + } + + return obj; + } + + /// + /// Writes the provided value to JSON. + /// + /// The to write to. + /// The value to write. + /// Serialization options used to obtain type metadata. + public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options) + { + var element = JsonSerializer.SerializeToElement(value, options.GetTypeInfo(value.GetType())); + writer.WriteStartObject(); + + foreach (var prop in element.EnumerateObject()) + { + prop.WriteTo(writer); + } + + writer.WriteEndObject(); + } +} diff --git a/src/A2A.V0_3/Client/A2ACardResolver.cs b/src/A2A.V0_3/Client/A2ACardResolver.cs new file mode 100644 index 00000000..1a80e33f --- /dev/null +++ b/src/A2A.V0_3/Client/A2ACardResolver.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Text.Json; + +namespace A2A.V0_3; + +/// +/// Resolves Agent Card information from an A2A-compatible endpoint. +/// +public sealed class A2ACardResolver +{ + private readonly HttpClient _httpClient; + private readonly Uri _agentCardPath; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of . + /// + /// The base url of the agent's hosting service. + /// Optional HTTP client (if not provided, a shared one will be used). + /// Path to the agent card (defaults to "/.well-known/agent-card.json"). + /// Optional logger. + public A2ACardResolver( + Uri baseUrl, + HttpClient? httpClient = null, + string agentCardPath = "/.well-known/agent-card.json", + ILogger? logger = null) + { + if (baseUrl is null) + { + throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null."); + } + + if (string.IsNullOrEmpty(agentCardPath)) + { + throw new ArgumentNullException(nameof(agentCardPath), "Agent card path cannot be null or empty."); + } + + _agentCardPath = new Uri(baseUrl, agentCardPath.TrimStart('/')); + + _httpClient = httpClient ?? A2AClient.s_sharedClient; + + _logger = logger ?? NullLogger.Instance; + } + + /// + /// Gets the agent card asynchronously. + /// + /// Optional cancellation token. + /// The agent card. + public async Task GetAgentCardAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.FetchingAgentCardFromUrl(_agentCardPath); + } + + try + { + using var response = await _httpClient.GetAsync(_agentCardPath, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.AgentCard, cancellationToken).ConfigureAwait(false) ?? + throw new A2AException("Failed to parse agent card JSON."); + } + catch (JsonException ex) + { + _logger.FailedToParseAgentCardJson(ex); + throw new A2AException($"Failed to parse JSON: {ex.Message}"); + } + catch (HttpRequestException ex) + { + HttpStatusCode statusCode = ex.StatusCode ?? HttpStatusCode.InternalServerError; + + _logger.HttpRequestFailedWithStatusCode(ex, statusCode); + throw new A2AException("HTTP request failed", ex); + } + } +} diff --git a/src/A2A.V0_3/Client/A2AClient.cs b/src/A2A.V0_3/Client/A2AClient.cs new file mode 100644 index 00000000..0771043b --- /dev/null +++ b/src/A2A.V0_3/Client/A2AClient.cs @@ -0,0 +1,198 @@ +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace A2A.V0_3; + +/// +/// Implementation of A2A client for communicating with agents. +/// +public sealed class A2AClient : IA2AClient +{ + internal static readonly HttpClient s_sharedClient = new(); + private readonly HttpClient _httpClient; + private readonly Uri _baseUri; + + /// + /// Initializes a new instance of . + /// + /// The base url of the agent's hosting service. + /// The HTTP client to use for requests. + public A2AClient(Uri baseUrl, HttpClient? httpClient = null) + { + if (baseUrl is null) + { + throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null."); + } + + _baseUri = baseUrl; + + _httpClient = httpClient ?? s_sharedClient; + } + + /// + public Task SendMessageAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) => + SendRpcRequestAsync( + taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)), + A2AMethods.MessageSend, + A2AJsonUtilities.JsonContext.Default.MessageSendParams, + A2AJsonUtilities.JsonContext.Default.A2AResponse, + cancellationToken); + + /// + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) => + SendRpcRequestAsync( + new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId }, + A2AMethods.TaskGet, + A2AJsonUtilities.JsonContext.Default.TaskIdParams, + A2AJsonUtilities.JsonContext.Default.AgentTask, + cancellationToken); + + /// + public Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default) => + SendRpcRequestAsync( + taskIdParams ?? throw new ArgumentNullException(nameof(taskIdParams)), + A2AMethods.TaskCancel, + A2AJsonUtilities.JsonContext.Default.TaskIdParams, + A2AJsonUtilities.JsonContext.Default.AgentTask, + cancellationToken); + + /// + public Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) => + SendRpcRequestAsync( + pushNotificationConfig ?? throw new ArgumentNullException(nameof(pushNotificationConfig)), + A2AMethods.TaskPushNotificationConfigSet, + A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, + A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, + cancellationToken); + + /// + public Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams notificationConfigParams, CancellationToken cancellationToken = default) => + SendRpcRequestAsync( + notificationConfigParams ?? throw new ArgumentNullException(nameof(notificationConfigParams)), + A2AMethods.TaskPushNotificationConfigGet, + A2AJsonUtilities.JsonContext.Default.GetTaskPushNotificationConfigParams, + A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, + cancellationToken); + + /// + public IAsyncEnumerable> SendMessageStreamingAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) => + SendRpcSseRequestAsync( + taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)), + A2AMethods.MessageStream, + A2AJsonUtilities.JsonContext.Default.MessageSendParams, + A2AJsonUtilities.JsonContext.Default.A2AEvent, + cancellationToken); + + /// + public IAsyncEnumerable> SubscribeToTaskAsync(string taskId, CancellationToken cancellationToken = default) => + SendRpcSseRequestAsync( + new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId }, + A2AMethods.TaskSubscribe, + A2AJsonUtilities.JsonContext.Default.TaskIdParams, + A2AJsonUtilities.JsonContext.Default.A2AEvent, + cancellationToken); + + private async Task SendRpcRequestAsync( + TInput jsonRpcParams, + string method, + JsonTypeInfo inputTypeInfo, + JsonTypeInfo outputTypeInfo, + CancellationToken cancellationToken) where TOutput : class + { + cancellationToken.ThrowIfCancellationRequested(); + + using var responseStream = await SendAndReadResponseStreamAsync( + jsonRpcParams, + method, + inputTypeInfo, + "application/json", + cancellationToken).ConfigureAwait(false); + + var responseObject = await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse, cancellationToken).ConfigureAwait(false); + + if (responseObject?.Error is { } error) + { + throw new A2AException(error.Message, (A2AErrorCode)error.Code); + } + + return responseObject?.Result?.Deserialize(outputTypeInfo) ?? + throw new InvalidOperationException("Response does not contain a result."); + } + + private async IAsyncEnumerable> SendRpcSseRequestAsync( + TInput jsonRpcParams, + string method, + JsonTypeInfo inputTypeInfo, + JsonTypeInfo outputTypeInfo, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + using var responseStream = await SendAndReadResponseStreamAsync( + jsonRpcParams, + method, + inputTypeInfo, + "text/event-stream", + cancellationToken).ConfigureAwait(false); + + var sseParser = SseParser.Create(responseStream, (_, data) => + { + var reader = new Utf8JsonReader(data); + + var responseObject = JsonSerializer.Deserialize(ref reader, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse); + + if (responseObject?.Error is { } error) + { + throw new A2AException(error.Message, (A2AErrorCode)error.Code); + } + + if (responseObject?.Result is null) + { + throw new InvalidOperationException("Failed to deserialize the event: Result is null."); + } + + return responseObject.Result.Deserialize(outputTypeInfo) ?? + throw new InvalidOperationException("Failed to deserialize the event."); + }); + + await foreach (var item in sseParser.EnumerateAsync(cancellationToken)) + { + yield return item; + } + } + + private async ValueTask SendAndReadResponseStreamAsync( + TInput jsonRpcParams, + string method, + JsonTypeInfo inputTypeInfo, + string expectedContentType, + CancellationToken cancellationToken) + { + var response = await _httpClient.SendAsync(new(HttpMethod.Post, _baseUri) + { + Content = new JsonRpcContent(new JsonRpcRequest() + { + Id = Guid.NewGuid().ToString(), + Method = method, + Params = JsonSerializer.SerializeToElement(jsonRpcParams, inputTypeInfo), + }) + }, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + try + { + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentType?.MediaType != expectedContentType) + { + throw new InvalidOperationException($"Invalid content type. Expected '{expectedContentType}' but got '{response.Content.Headers.ContentType?.MediaType}'."); + } + + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + response.Dispose(); + throw; + } + } +} \ No newline at end of file diff --git a/src/A2A.V0_3/Client/A2AClientExtensions.cs b/src/A2A.V0_3/Client/A2AClientExtensions.cs new file mode 100644 index 00000000..934c080c --- /dev/null +++ b/src/A2A.V0_3/Client/A2AClientExtensions.cs @@ -0,0 +1,132 @@ +using System.Net.ServerSentEvents; +using System.Text.Json; + +namespace A2A.V0_3; + +/// +/// Extension methods for the class making its API more +/// convenient for certain use-cases. +/// +public static class A2AClientExtensions +{ + /// + public static Task SendMessageAsync( + this A2AClient client, + AgentMessage message, + MessageSendConfiguration? configuration = null, + Dictionary? metadata = null, + CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendMessageAsync( + new MessageSendParams + { + Message = message, + Configuration = configuration, + Metadata = metadata + }, + cancellationToken); + } + + /// + public static Task CancelTaskAsync( + this A2AClient client, + string taskId, + Dictionary? metadata = null, + CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.CancelTaskAsync( + new TaskIdParams + { + Id = taskId, + Metadata = metadata + }, + cancellationToken); + } + + /// + public static Task SetPushNotificationAsync( + this A2AClient client, + string taskId, + string url, + string? configId = null, + string? token = null, + PushNotificationAuthenticationInfo? authentication = null, + CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SetPushNotificationAsync( + new TaskPushNotificationConfig + { + TaskId = taskId, + PushNotificationConfig = new PushNotificationConfig + { + Id = configId, + Url = url, + Token = token, + Authentication = authentication + } + }, + cancellationToken); + } + + /// + public static Task GetPushNotificationAsync( + this A2AClient client, + string taskId, + string configId, + Dictionary? metadata = null, + CancellationToken cancellationToken = default) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.GetPushNotificationAsync( + new GetTaskPushNotificationConfigParams + { + Id = taskId, + PushNotificationConfigId = configId, + Metadata = metadata + }, + cancellationToken); + } + + /// + public static IAsyncEnumerable> SendMessageStreamingAsync( + this A2AClient client, + AgentMessage message, + MessageSendConfiguration? configuration = null, + Dictionary? metadata = null, + CancellationToken cancellationToken = default) + + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + return client.SendMessageStreamingAsync( + new MessageSendParams + { + Message = message, + Configuration = configuration, + Metadata = metadata + }, + cancellationToken); + } +} diff --git a/src/A2A.V0_3/Client/IA2AClient.cs b/src/A2A.V0_3/Client/IA2AClient.cs new file mode 100644 index 00000000..a1cced94 --- /dev/null +++ b/src/A2A.V0_3/Client/IA2AClient.cs @@ -0,0 +1,68 @@ +using System.Net.ServerSentEvents; + +namespace A2A.V0_3; + +/// +/// Interface for A2A client operations for interacting with an A2A agent. +/// +public interface IA2AClient +{ + /// + /// Sends a non-streaming message request to the agent. + /// + /// The message parameters containing the message and configuration. + /// A cancellation token to cancel the operation. + /// The agent's response containing a Task or Message. + Task SendMessageAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default); + + /// + /// Retrieves the current state and history of a specific task. + /// + /// The ID of the task to retrieve. + /// A cancellation token to cancel the operation. + /// The requested task with its current state and history. + Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); + + /// + /// Requests the agent to cancel a specific task. + /// + /// Parameters containing the task ID to cancel. + /// A cancellation token to cancel the operation. + /// The updated task with canceled status. + Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); + + /// + /// Sends a streaming message request to the agent and yields responses as they arrive. + /// + /// + /// This method uses Server-Sent Events (SSE) to receive a stream of updates from the agent. + /// + /// The message parameters containing the message and configuration. + /// A cancellation token to cancel the operation. + /// An async enumerable of server-sent events containing Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent. + IAsyncEnumerable> SendMessageStreamingAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default); + + /// + /// Subscribes to a task's event stream to receive ongoing updates. + /// + /// The ID of the task to subscribe to. + /// A cancellation token to cancel the operation. + /// An async enumerable of server-sent events containing task updates. + IAsyncEnumerable> SubscribeToTaskAsync(string taskId, CancellationToken cancellationToken = default); + + /// + /// Sets or updates the push notification configuration for a specific task. + /// + /// The push notification configuration to set. + /// A cancellation token to cancel the operation. + /// The configured push notification settings with confirmation. + Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); + + /// + /// Retrieves the push notification configuration for a specific task. + /// + /// Parameters containing the task ID and optional push notification config ID. + /// A cancellation token to cancel the operation. + /// The push notification configuration for the specified task. + Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams notificationConfigParams, CancellationToken cancellationToken = default); +} diff --git a/src/A2A.V0_3/Client/JsonRpcContent.cs b/src/A2A.V0_3/Client/JsonRpcContent.cs new file mode 100644 index 00000000..79304c4d --- /dev/null +++ b/src/A2A.V0_3/Client/JsonRpcContent.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace A2A.V0_3; + +/// +/// HTTP content for JSON-RPC requests and responses. +/// +public sealed class JsonRpcContent : HttpContent +{ + private readonly object? _contentToSerialize; + private readonly JsonTypeInfo _contentTypeInfo; + + /// + /// Initializes a new instance of JsonRpcContent for a JSON-RPC request. + /// + /// The JSON-RPC request to serialize. + public JsonRpcContent(JsonRpcRequest? request) : this(request, A2AJsonUtilities.JsonContext.Default.JsonRpcRequest) + { + } + + /// + /// Initializes a new instance of JsonRpcContent for a JSON-RPC response. + /// + /// The JSON-RPC response to serialize. + public JsonRpcContent(JsonRpcResponse? response) : this(response, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse) + { + } + + /// + /// Initializes a new instance of JsonRpcContent with custom content and type info. + /// + /// The content to serialize. + /// Type information for serialization. + private JsonRpcContent(object? contentToSerialize, JsonTypeInfo contentTypeInfo) + { + _contentToSerialize = contentToSerialize; + _contentTypeInfo = contentTypeInfo; + Headers.TryAddWithoutValidation("Content-Type", "application/json"); + } + + /// + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + JsonSerializer.SerializeAsync(stream, _contentToSerialize, _contentTypeInfo); + + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + JsonSerializer.SerializeAsync(stream, _contentToSerialize, _contentTypeInfo, cancellationToken); + + /// + protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + JsonSerializer.Serialize(stream, _contentToSerialize, _contentTypeInfo); +} \ No newline at end of file diff --git a/src/A2A.V0_3/Extensions/AIContentExtensions.cs b/src/A2A.V0_3/Extensions/AIContentExtensions.cs new file mode 100644 index 00000000..92b15b22 --- /dev/null +++ b/src/A2A.V0_3/Extensions/AIContentExtensions.cs @@ -0,0 +1,174 @@ +using A2A.V0_3; +using System; +using System.Text.Json; + +#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator +#pragma warning disable EA0013 // Consider removing unnecessary coalescing + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for integrating with and other types from Microsoft.Extensions.AI. +/// +public static class AIContentExtensions +{ + /// Creates a from the A2A . + /// The agent message to convert to an . + /// The created to represent the . + /// is . + public static ChatMessage ToChatMessage(this AgentMessage agentMessage) + { + if (agentMessage is null) + { + throw new ArgumentNullException(nameof(agentMessage)); + } + + return new() + { + AdditionalProperties = agentMessage.Metadata.ToAdditionalProperties(), + Contents = agentMessage.Parts.ConvertAll(p => p.ToAIContent()), + MessageId = agentMessage.MessageId, + RawRepresentation = agentMessage, + Role = agentMessage.Role switch + { + MessageRole.Agent => ChatRole.Assistant, + _ => ChatRole.User, + }, + }; + } + + /// Creates an A2A from the Microsoft.Extensions.AI . + /// The chat message to convert to an . + /// The created to represent the . + /// is . + /// + /// If the 's is already a , + /// that existing instance is returned. + /// + public static AgentMessage ToAgentMessage(this ChatMessage chatMessage) + { + if (chatMessage is null) + { + throw new ArgumentNullException(nameof(chatMessage)); + } + + if (chatMessage.RawRepresentation is AgentMessage existingAgentMessage) + { + return existingAgentMessage; + } + + return new AgentMessage + { + MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString("N"), + Parts = chatMessage.Contents.Select(ToPart).Where(p => p is not null).ToList()!, + Role = chatMessage.Role == ChatRole.Assistant ? MessageRole.Agent : MessageRole.User, + }; + } + + /// Creates an from the A2A . + /// The part to convert to an . + /// The created to represent the . + /// is . + public static AIContent ToAIContent(this Part part) + { + if (part is null) + { + throw new ArgumentNullException(nameof(part)); + } + + AIContent? content = null; + switch (part) + { + case TextPart textPart: + content = new TextContent(textPart.Text); + break; + + case FilePart { File: { } file }: + if (file.Uri is not null) + { + content = new UriContent(file.Uri, file.MimeType ?? "application/octet-stream"); + } + else if (file.Bytes is not null) + { + content = new DataContent(Convert.FromBase64String(file.Bytes), file.MimeType ?? "application/octet-stream") + { + Name = file.Name, + }; + } + break; + + case DataPart dataPart: + content = new DataContent( + JsonSerializer.SerializeToUtf8Bytes(dataPart.Data, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(Dictionary))), + "application/json"); + break; + } + + content ??= new AIContent(); + + content.AdditionalProperties = part.Metadata.ToAdditionalProperties(); + content.RawRepresentation = part; + + return content; + } + + /// Creates an A2A from the Microsoft.Extensions.AI . + /// The content to convert to a . + /// The created to represent the , or null if the content could not be mapped. + /// is . + /// + /// If the 's is already a , + /// that existing instance is returned. + /// + public static Part? ToPart(this AIContent content) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (content.RawRepresentation is Part existingPart) + { + return existingPart; + } + + Part? part = null; + switch (content) + { + case TextContent textContent: + part = new TextPart { Text = textContent.Text }; + break; + + case UriContent uriContent: + part = new FilePart + { + File = new FileContent(uriContent.Uri) { MimeType = uriContent.MediaType }, + }; + break; + + case DataContent dataContent: + part = new FilePart + { + File = new FileContent(dataContent.Base64Data.ToString()) { MimeType = dataContent.MediaType }, + }; + break; + } + + if (part is not null && content.AdditionalProperties is { Count: > 0 } props) + { + foreach (var kvp in props) + { + try + { + (part.Metadata ??= [])[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch (JsonException) + { + // Ignore properties that can't be converted to JsonElement + } + } + } + + return part; + } +} \ No newline at end of file diff --git a/src/A2A.V0_3/Extensions/AdditionalPropertiesExtensions.cs b/src/A2A.V0_3/Extensions/AdditionalPropertiesExtensions.cs new file mode 100644 index 00000000..2db97541 --- /dev/null +++ b/src/A2A.V0_3/Extensions/AdditionalPropertiesExtensions.cs @@ -0,0 +1,53 @@ +using A2A.V0_3; +using System.Text.Json; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for converting between and A2A metadata dictionaries. +/// +public static class AdditionalPropertiesExtensions +{ + /// + /// Creates an from A2A metadata. + /// + /// The A2A metadata dictionary to convert. + /// An containing the metadata, or if the metadata is empty. + public static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? metadata) + { + if (metadata is not { Count: > 0 }) + { + return null; + } + + AdditionalPropertiesDictionary props = []; + foreach (var kvp in metadata) + { + props[kvp.Key] = kvp.Value; + } + + return props; + } + + /// + /// Creates an A2A metadata dictionary from an . + /// + /// The additional properties dictionary to convert. + /// A dictionary of A2A metadata, or if the additional properties dictionary is empty. + public static Dictionary? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties) + { + if (additionalProperties is not { Count: > 0 }) + { + return null; + } + + var metadata = new Dictionary(); + + foreach (var kvp in additionalProperties) + { + metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + + return metadata; + } +} diff --git a/src/A2A.V0_3/Extensions/AgentTaskExtensions.cs b/src/A2A.V0_3/Extensions/AgentTaskExtensions.cs new file mode 100644 index 00000000..0321bb15 --- /dev/null +++ b/src/A2A.V0_3/Extensions/AgentTaskExtensions.cs @@ -0,0 +1,31 @@ +namespace A2A.V0_3.Extensions; + +/// +/// Provides extension methods for . +/// +internal static class AgentTaskExtensions +{ + /// + /// Returns a new with the collection trimmed to the specified length. + /// + /// The whose history should be trimmed. + /// The maximum number of messages to retain in the history. If null or greater than the current count, the original task is returned. + /// A new with the history trimmed, or the original task if no trimming is necessary. + public static AgentTask WithHistoryTrimmedTo(this AgentTask task, int? toLength) + { + if (toLength is not { } len || len < 0 || task.History is not { Count: > 0 } history || history.Count <= len) + { + return task; + } + + return new AgentTask + { + Id = task.Id, + ContextId = task.ContextId, + Status = task.Status, + Artifacts = task.Artifacts, + Metadata = task.Metadata, + History = [.. history.Skip(history.Count - len)], + }; + } +} \ No newline at end of file diff --git a/src/A2A.V0_3/GlobalSuppressions.cs b/src/A2A.V0_3/GlobalSuppressions.cs new file mode 100644 index 00000000..1ed1da11 --- /dev/null +++ b/src/A2A.V0_3/GlobalSuppressions.cs @@ -0,0 +1,9 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Reliability", "EA0002:Use 'System.TimeProvider' to make the code easier to test", Justification = "Not going to adhere to this (yet)", Scope = "module")] +[assembly: SuppressMessage("Performance", "EA0006:Replace uses of 'Enum.GetName' and 'Enum.ToString' for improved performance", Justification = "Not going to adhere to this (yet)", Scope = "module")] diff --git a/src/A2A.V0_3/JsonRpc/A2AMethods.cs b/src/A2A.V0_3/JsonRpc/A2AMethods.cs new file mode 100644 index 00000000..81f2f52e --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/A2AMethods.cs @@ -0,0 +1,56 @@ +namespace A2A.V0_3; + +/// +/// Constants for A2A JSON-RPC method names. +/// +public static class A2AMethods +{ + /// + /// Method for sending messages to agents. + /// + public const string MessageSend = "message/send"; + + /// + /// Method for streaming messages from agents. + /// + public const string MessageStream = "message/stream"; + + /// + /// Method for retrieving task information. + /// + public const string TaskGet = "tasks/get"; + + /// + /// Method for canceling tasks. + /// + public const string TaskCancel = "tasks/cancel"; + + /// + /// Method for subscribing to task updates. + /// + public const string TaskSubscribe = "tasks/resubscribe"; + + /// + /// Method for setting push notification configuration. + /// + public const string TaskPushNotificationConfigSet = "tasks/pushNotificationConfig/set"; + + /// + /// Method for getting push notification configuration. + /// + public const string TaskPushNotificationConfigGet = "tasks/pushNotificationConfig/get"; + + /// + /// Determines if a method requires streaming response handling. + /// + /// The method name to check. + /// True if the method requires streaming, false otherwise. + public static bool IsStreamingMethod(string method) => method is MessageStream or TaskSubscribe; + + /// + /// Determines if a method name is valid for A2A JSON-RPC. + /// + /// The method name to validate. + /// True if the method is valid, false otherwise. + public static bool IsValidMethod(string method) => method is MessageSend or MessageStream or TaskGet or TaskCancel or TaskSubscribe or TaskPushNotificationConfigSet or TaskPushNotificationConfigGet; +} \ No newline at end of file diff --git a/src/A2A.V0_3/JsonRpc/JsonRpcError.cs b/src/A2A.V0_3/JsonRpc/JsonRpcError.cs new file mode 100644 index 00000000..f135f37b --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/JsonRpcError.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a JSON-RPC 2.0 Error object. +/// +public class JsonRpcError +{ + /// + /// Gets or sets the number that indicates the error type that occurred. + /// + [JsonPropertyName("code")] + [JsonRequired] + public int Code { get; set; } = 0; + + /// + /// Gets or sets the string providing a short description of the error. + /// + [JsonPropertyName("message")] + [JsonRequired] + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets the primitive or structured value that contains additional information about the error. + /// This may be omitted. + /// + [JsonPropertyName("data")] + public JsonElement? Data { get; set; } + + /// + /// Deserializes a JsonRpcError from a JsonElement. + /// + /// The JSON element to deserialize. + /// A JsonRpcError instance. + /// Thrown when deserialization fails. + public static JsonRpcError FromJson(JsonElement jsonElement) => + jsonElement.Deserialize(A2AJsonUtilities.JsonContext.Default.JsonRpcError) ?? + throw new InvalidOperationException("Failed to deserialize JsonRpcError."); + + /// + /// Serializes a JsonRpcError to JSON. + /// + /// JSON string representation. + public string ToJson() => JsonSerializer.Serialize(this, A2AJsonUtilities.JsonContext.Default.JsonRpcError); +} \ No newline at end of file diff --git a/src/A2A.V0_3/JsonRpc/JsonRpcId.cs b/src/A2A.V0_3/JsonRpc/JsonRpcId.cs new file mode 100644 index 00000000..d43690f9 --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/JsonRpcId.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a JSON-RPC ID that can be either a string or a number, preserving the original type. +/// +[JsonConverter(typeof(Converter))] +public readonly struct JsonRpcId : IEquatable +{ + /// + /// Initializes a new instance of the struct with a string value. + /// + /// The string value. + public JsonRpcId(string? value) => Value = value; + + /// + /// Initializes a new instance of the struct with a numeric value. + /// + /// The numeric value. + public JsonRpcId(long value) => Value = value; + + /// + /// Initializes a new instance of the struct with a numeric value. + /// + /// The numeric value. + public JsonRpcId(int value) => Value = (long)value; + + /// + /// Gets a value indicating whether this ID has a value. + /// + public bool HasValue => Value is not null; + + /// + /// Gets a value indicating whether this ID is a string. + /// + public bool IsString => Value is string; + + /// + /// Gets a value indicating whether this ID is a number. + /// + public bool IsNumber => Value is long; + + /// + /// Gets the string value of this ID if it's a string. + /// + /// The string value, or null if not a string. + public string? AsString() => Value as string; + + /// + /// Gets the numeric value of this ID if it's a number. + /// + /// The numeric value, or null if not a number. + public long? AsNumber() => Value as long?; + + /// + /// Gets the raw value as an object. + /// + /// The raw value as an object. + public object? Value { get; } + + /// + /// Returns a string representation of this ID. + /// + /// A string representation of the ID. + public override string? ToString() => Value?.ToString(); + + /// + /// Determines whether the specified object is equal to the current ID. + /// + /// The object to compare with the current ID. + /// true if the specified object is equal to the current ID; otherwise, false. + public override bool Equals(object? obj) => obj is JsonRpcId other && Equals(other); + + /// + /// Determines whether the specified ID is equal to the current ID. + /// + /// The ID to compare with the current ID. + /// true if the specified ID is equal to the current ID; otherwise, false. + public bool Equals(JsonRpcId other) => Equals(Value, other.Value); + + /// + /// Returns the hash code for this ID. + /// + /// A hash code for the current ID. + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + + /// + /// Determines whether two IDs are equal. + /// + /// The first ID to compare. + /// The second ID to compare. + /// true if the IDs are equal; otherwise, false. + public static bool operator ==(JsonRpcId left, JsonRpcId right) => left.Equals(right); + + /// + /// Determines whether two IDs are not equal. + /// + /// The first ID to compare. + /// The second ID to compare. + /// true if the IDs are not equal; otherwise, false. + public static bool operator !=(JsonRpcId left, JsonRpcId right) => !left.Equals(right); + + /// + /// Implicitly converts a string to a JsonRpcId. + /// + /// The string value. + /// A JsonRpcId with the string value. + public static implicit operator JsonRpcId(string? value) => new(value); + + /// + /// Implicitly converts a long to a JsonRpcId. + /// + /// The numeric value. + /// A JsonRpcId with the numeric value. + public static implicit operator JsonRpcId(long value) => new(value); + + /// + /// Implicitly converts an int to a JsonRpcId. + /// + /// The numeric value. + /// A JsonRpcId with the numeric value. + public static implicit operator JsonRpcId(int value) => new(value); + + /// + /// JSON converter for JsonRpcId that preserves the original type during serialization/deserialization. + /// + internal sealed class Converter : JsonConverter + { + public override JsonRpcId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + return new JsonRpcId(reader.GetString()); + case JsonTokenType.Number: + if (reader.TryGetInt64(out var longValue)) + { + return new JsonRpcId(longValue); + } + throw new JsonException("Invalid numeric value for JSON-RPC ID."); + case JsonTokenType.Null: + return new JsonRpcId(null); + default: + throw new JsonException("Invalid JSON-RPC ID format. Must be string, number, or null."); + } + } + + public override void Write(Utf8JsonWriter writer, JsonRpcId value, JsonSerializerOptions options) + { + if (!value.HasValue) + { + writer.WriteNullValue(); + } + else if (value.IsString) + { + writer.WriteStringValue(value.AsString()); + } + else if (value.IsNumber) + { + writer.WriteNumberValue(value.AsNumber()!.Value); + } + else + { + writer.WriteNullValue(); + } + } + } +} \ No newline at end of file diff --git a/src/A2A.V0_3/JsonRpc/JsonRpcRequest.cs b/src/A2A.V0_3/JsonRpc/JsonRpcRequest.cs new file mode 100644 index 00000000..6bfb44ae --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/JsonRpcRequest.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a JSON-RPC 2.0 Request object. +/// +[JsonConverter(typeof(JsonRpcRequestConverter))] +public sealed class JsonRpcRequest +{ + /// + /// Gets or sets the version of the JSON-RPC protocol. + /// + /// + /// MUST be exactly "2.0". + /// + [JsonPropertyName("jsonrpc")] + // [JsonRequired] - we have to reject this with a special payload + public string JsonRpc { get; set; } = "2.0"; + + /// + /// Gets or sets the identifier established by the Client that MUST contain a String, Number. + /// + /// + /// Numbers SHOULD NOT contain fractional parts. + /// + [JsonPropertyName("id")] + public JsonRpcId Id { get; set; } + + /// + /// Gets or sets the string containing the name of the method to be invoked. + /// + [JsonPropertyName("method")] + // [JsonRequired] - we have to reject this with a special payload + public string Method { get; set; } = string.Empty; + + /// + /// Gets or sets the structured value that holds the parameter values to be used during the invocation of the method. + /// + [JsonPropertyName("params")] + public JsonElement? Params { get; set; } +} diff --git a/src/A2A.V0_3/JsonRpc/JsonRpcRequestConverter.cs b/src/A2A.V0_3/JsonRpc/JsonRpcRequestConverter.cs new file mode 100644 index 00000000..1b2fb12d --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/JsonRpcRequestConverter.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Custom JsonConverter for JsonRpcRequest that validates fields during deserialization. +/// +internal sealed class JsonRpcRequestConverter : JsonConverter +{ + /// + /// The supported JSON-RPC version. + /// + private const string JsonRpcSupportedVersion = "2.0"; + + public override JsonRpcRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + try + { + // Create JsonElement from Utf8JsonReader + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var rootElement = jsonDoc.RootElement; + + // Validate the JSON-RPC request structure + var idField = ReadAndValidateIdField(rootElement); + var requestId = idField.ToString(); + return new JsonRpcRequest + { + Id = idField, + JsonRpc = ReadAndValidateJsonRpcField(rootElement, requestId), + Method = ReadAndValidateMethodField(rootElement, requestId), + Params = ReadAndValidateParamsField(rootElement, requestId) + }; + } + catch (JsonException ex) + { + throw new A2AException("Invalid JSON-RPC request payload.", ex, A2AErrorCode.ParseError); + } + } + + public override void Write(Utf8JsonWriter writer, JsonRpcRequest value, JsonSerializerOptions options) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value), "Cannot serialize a null JsonRpcRequest."); + } + + writer.WriteStartObject(); + writer.WriteString("jsonrpc", value.JsonRpc); + + writer.WritePropertyName("id"); + if (!value.Id.HasValue) + { + writer.WriteNullValue(); + } + else if (value.Id.IsString) + { + writer.WriteStringValue(value.Id.AsString()); + } + else if (value.Id.IsNumber) + { + writer.WriteNumberValue(value.Id.AsNumber()!.Value); + } + else + { + writer.WriteNullValue(); + } + + writer.WriteString("method", value.Method); + + if (value.Params.HasValue) + { + writer.WritePropertyName("params"); + value.Params.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + /// + /// Reads and validates the 'id' field of a JSON-RPC request. + /// + /// The root JSON element containing the request. + /// The extracted request ID as a JsonRpcId. + private static JsonRpcId ReadAndValidateIdField(JsonElement rootElement) + { + if (rootElement.TryGetProperty("id", out var idElement)) + { + if ((idElement.ValueKind != JsonValueKind.String && + idElement.ValueKind != JsonValueKind.Number && + idElement.ValueKind != JsonValueKind.Null) + || (idElement.ValueKind is JsonValueKind.Number && !idElement.TryGetInt64(out var _))) + { + throw new A2AException("Invalid JSON-RPC request: 'id' field must be a string, non-fractional number, or null.", A2AErrorCode.InvalidRequest); + } + + return idElement.ValueKind switch + { + JsonValueKind.Null => new JsonRpcId((string?)null), + JsonValueKind.String => new JsonRpcId(idElement.GetString()), + JsonValueKind.Number => new JsonRpcId(idElement.GetInt64()), + _ => new JsonRpcId((string?)null) + }; + } + + return new JsonRpcId((string?)null); + } + + /// + /// Reads and validates the 'jsonrpc' field of a JSON-RPC request. + /// + /// The root JSON element containing the request. + /// The request ID for error context. + /// The JSON-RPC version as a string. + private static string ReadAndValidateJsonRpcField(JsonElement rootElement, string? requestId) + { + if (rootElement.TryGetProperty("jsonrpc", out var jsonRpcElement)) + { + var jsonRpc = jsonRpcElement.GetString(); + + if (jsonRpc != JsonRpcSupportedVersion) + { + throw new A2AException("Invalid JSON-RPC request: 'jsonrpc' field must be '2.0'.", A2AErrorCode.InvalidRequest) + .WithRequestId(requestId); + } + + return jsonRpc; + } + + throw new A2AException("Invalid JSON-RPC request: missing 'jsonrpc' field.", A2AErrorCode.InvalidRequest) + .WithRequestId(requestId); + } + + /// + /// Reads and validates the 'method' field of a JSON-RPC request. + /// + /// The root JSON element containing the request. + /// The request ID for error context. + /// The method name as a string. + private static string ReadAndValidateMethodField(JsonElement rootElement, string? requestId) + { + if (rootElement.TryGetProperty("method", out var methodElement)) + { + var method = methodElement.GetString(); + if (string.IsNullOrEmpty(method)) + { + throw new A2AException("Invalid JSON-RPC request: missing 'method' field.", A2AErrorCode.InvalidRequest) + .WithRequestId(requestId); + } + + if (!A2AMethods.IsValidMethod(method!)) + { + throw new A2AException("Invalid JSON-RPC request: 'method' field is not a valid A2A method.", A2AErrorCode.MethodNotFound) + .WithRequestId(requestId); + } + + return method!; + } + + throw new A2AException("Invalid JSON-RPC request: missing 'method' field.", A2AErrorCode.InvalidRequest) + .WithRequestId(requestId); + } + + /// + /// Reads and validates the 'params' field of a JSON-RPC request. + /// + /// The root JSON element containing the request. + /// The request ID for error context. + /// The 'params' element if it exists and is valid. + private static JsonElement? ReadAndValidateParamsField(JsonElement rootElement, string? requestId) + { + if (rootElement.TryGetProperty("params", out var paramsElement)) + { + if (paramsElement.ValueKind != JsonValueKind.Object && + paramsElement.ValueKind != JsonValueKind.Undefined && + paramsElement.ValueKind != JsonValueKind.Null) + { + throw new A2AException("Invalid JSON-RPC request: 'params' field must be an object or null.", A2AErrorCode.InvalidParams) + .WithRequestId(requestId); + } + } + + return paramsElement.ValueKind == JsonValueKind.Null || paramsElement.ValueKind == JsonValueKind.Undefined + ? null + : paramsElement.Clone(); + } +} \ No newline at end of file diff --git a/src/A2A.V0_3/JsonRpc/JsonRpcResponse.cs b/src/A2A.V0_3/JsonRpc/JsonRpcResponse.cs new file mode 100644 index 00000000..d939f55a --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/JsonRpcResponse.cs @@ -0,0 +1,237 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace A2A.V0_3; + +/// +/// Represents a JSON-RPC 2.0 Response object. +/// +public sealed class JsonRpcResponse +{ + /// + /// Gets or sets the version of the JSON-RPC protocol. MUST be exactly "2.0". + /// + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; set; } = "2.0"; + + /// + /// Gets or sets the identifier established by the Client. + /// + /// + /// MUST contain a String, Number. Numbers SHOULD NOT contain fractional parts. + /// + [JsonPropertyName("id")] + public JsonRpcId Id { get; set; } + + /// + /// Gets or sets the result object on success. + /// + [JsonPropertyName("result")] + public JsonNode? Result { get; set; } + + /// + /// Gets or sets the error object when an error occurs. + /// + [JsonPropertyName("error")] + public JsonRpcError? Error { get; set; } + + /// + /// Creates a JSON-RPC response with a result. + /// + /// The type of the result + /// The request ID. + /// The result to include. + /// Optional type information for serialization. + /// A JSON-RPC response object. + public static JsonRpcResponse CreateJsonRpcResponse(JsonRpcId requestId, T result, JsonTypeInfo? resultTypeInfo = null) + { + resultTypeInfo ??= (JsonTypeInfo)A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + return new JsonRpcResponse() + { + Id = requestId, + Result = result is not null ? JsonSerializer.SerializeToNode(result, resultTypeInfo) : null + }; + } + + /// + /// Creates a JSON-RPC error response for a given exception. + /// + /// The request ID. + /// The exception containing error details. + /// A JSON-RPC error response. + public static JsonRpcResponse CreateJsonRpcErrorResponse(JsonRpcId requestId, A2AException exception) + { + return new JsonRpcResponse() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)exception.ErrorCode, + Message = exception.Message, + } + }; + } + + /// + /// Creates a JSON-RPC error response for invalid parameters. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse InvalidParamsResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError() + { + Code = (int)A2AErrorCode.InvalidParams, + Message = message ?? "Invalid parameters", + }, + }; + + /// + /// Creates a JSON-RPC error response for invalid request. + /// + /// The request ID. + /// Optional error message. + /// A JSON-RPC error response. + public static JsonRpcResponse InvalidRequestResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.InvalidRequest, + Message = message ?? "Request payload validation error", + }, + }; + + /// + /// Creates a JSON-RPC error response for task not found. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse TaskNotFoundResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.TaskNotFound, + Message = message ?? "Task not found", + }, + }; + + /// + /// Creates a JSON-RPC error response for task not cancelable. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse TaskNotCancelableResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.TaskNotCancelable, + Message = message ?? "Task cannot be canceled", + }, + }; + + /// + /// Creates a JSON-RPC error response for method not found. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse MethodNotFoundResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.MethodNotFound, + Message = message ?? "Method not found", + }, + }; + + /// + /// Creates a JSON-RPC error response for push notification not supported. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse PushNotificationNotSupportedResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.PushNotificationNotSupported, + Message = message ?? "Push notification not supported", + }, + }; + + /// + /// Creates a JSON-RPC error response for internal error. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse InternalErrorResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.InternalError, + Message = message ?? "Internal error", + }, + }; + + /// + /// Creates a JSON-RPC error response for parse error. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse ParseErrorResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.ParseError, + Message = message ?? "Invalid JSON payload" + }, + }; + + /// + /// Creates a JSON-RPC error response for unsupported operation. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse UnsupportedOperationResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.UnsupportedOperation, + Message = message ?? "Unsupported operation", + }, + }; + + /// + /// Creates a JSON-RPC error response for content type not supported. + /// + /// The request ID. + /// The error message. + /// A JSON-RPC error response. + public static JsonRpcResponse ContentTypeNotSupportedResponse(JsonRpcId requestId, string? message = null) => new() + { + Id = requestId, + Error = new JsonRpcError + { + Code = (int)A2AErrorCode.ContentTypeNotSupported, + Message = message ?? "Content type not supported", + }, + }; +} \ No newline at end of file diff --git a/src/A2A.V0_3/JsonRpc/MethodNotFoundError.cs b/src/A2A.V0_3/JsonRpc/MethodNotFoundError.cs new file mode 100644 index 00000000..ba5439dc --- /dev/null +++ b/src/A2A.V0_3/JsonRpc/MethodNotFoundError.cs @@ -0,0 +1,16 @@ +namespace A2A.V0_3; + +/// +/// Error for method not found. +/// +public sealed class MethodNotFoundError : JsonRpcError +{ + /// + /// Creates a new method not found error. + /// + public MethodNotFoundError() + { + Code = -32601; + Message = "Method not found"; + } +} \ No newline at end of file diff --git a/src/A2A/KebabCaseLowerJsonStringEnumConverter.cs b/src/A2A.V0_3/KebabCaseLowerJsonStringEnumConverter.cs similarity index 93% rename from src/A2A/KebabCaseLowerJsonStringEnumConverter.cs rename to src/A2A.V0_3/KebabCaseLowerJsonStringEnumConverter.cs index 5805932d..32517a9c 100644 --- a/src/A2A/KebabCaseLowerJsonStringEnumConverter.cs +++ b/src/A2A.V0_3/KebabCaseLowerJsonStringEnumConverter.cs @@ -1,14 +1,14 @@ -using System.ComponentModel; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// A JSON string enum converter that converts enum values to kebab-case lower strings. -/// -/// The type of the enum to convert. -[EditorBrowsable(EditorBrowsableState.Never)] -public sealed class KebabCaseLowerJsonStringEnumConverter() : - JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) - where TEnum : struct, Enum; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// A JSON string enum converter that converts enum values to kebab-case lower strings. +/// +/// The type of the enum to convert. +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class KebabCaseLowerJsonStringEnumConverter() : + JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) + where TEnum : struct, Enum; diff --git a/src/A2A.V0_3/Log.cs b/src/A2A.V0_3/Log.cs new file mode 100644 index 00000000..8f9849f0 --- /dev/null +++ b/src/A2A.V0_3/Log.cs @@ -0,0 +1,20 @@ + +namespace A2A.V0_3 +{ +#pragma warning disable CS8019 + using Microsoft.Extensions.Logging; + using System; +#pragma warning restore CS8019 + + static partial class Log + { + [LoggerMessage(0, LogLevel.Information, "Fetching agent card from '{Url}'")] + internal static partial void FetchingAgentCardFromUrl(this ILogger logger, Uri Url); + + [LoggerMessage(1, LogLevel.Error, "Failed to parse agent card JSON")] + internal static partial void FailedToParseAgentCardJson(this ILogger logger, Exception exception); + + [LoggerMessage(2, LogLevel.Error, "HTTP request failed with status code {StatusCode}")] + internal static partial void HttpRequestFailedWithStatusCode(this ILogger logger, Exception exception, System.Net.HttpStatusCode StatusCode); + } +} diff --git a/src/A2A/Models/A2AEventKind.cs b/src/A2A.V0_3/Models/A2AEventKind.cs similarity index 95% rename from src/A2A/Models/A2AEventKind.cs rename to src/A2A.V0_3/Models/A2AEventKind.cs index 663d1044..cbd3de34 100644 --- a/src/A2A/Models/A2AEventKind.cs +++ b/src/A2A.V0_3/Models/A2AEventKind.cs @@ -1,34 +1,34 @@ -namespace A2A; - -/// -/// Defines the set of A2A event kinds used as the 'kind' discriminator in serialized payloads. -/// -/// -/// Values are serialized as lowercase kebab-case strings. -/// -internal static class A2AEventKind -{ - /// - /// A conversational message from an agent. - /// - /// - public const string Message = "message"; - - /// - /// A task issued to or produced by an agent. - /// - /// - public const string Task = "task"; - - /// - /// An update describing the current state of a task execution. - /// - /// - public const string StatusUpdate = "status-update"; - - /// - /// A notification that artifacts associated with a task have changed. - /// - /// - public const string ArtifactUpdate = "artifact-update"; +namespace A2A.V0_3; + +/// +/// Defines the set of A2A event kinds used as the 'kind' discriminator in serialized payloads. +/// +/// +/// Values are serialized as lowercase kebab-case strings. +/// +internal static class A2AEventKind +{ + /// + /// A conversational message from an agent. + /// + /// + public const string Message = "message"; + + /// + /// A task issued to or produced by an agent. + /// + /// + public const string Task = "task"; + + /// + /// An update describing the current state of a task execution. + /// + /// + public const string StatusUpdate = "status-update"; + + /// + /// A notification that artifacts associated with a task have changed. + /// + /// + public const string ArtifactUpdate = "artifact-update"; } \ No newline at end of file diff --git a/src/A2A/Models/A2AResponse.cs b/src/A2A.V0_3/Models/A2AResponse.cs similarity index 97% rename from src/A2A/Models/A2AResponse.cs rename to src/A2A.V0_3/Models/A2AResponse.cs index 65f99a8a..d4f9442e 100644 --- a/src/A2A/Models/A2AResponse.cs +++ b/src/A2A.V0_3/Models/A2AResponse.cs @@ -1,49 +1,49 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Base class for A2A events. -/// -/// The kind discriminator value -[JsonConverter(typeof(A2AEventConverterViaKindDiscriminator))] -[JsonDerivedType(typeof(TaskStatusUpdateEvent))] -[JsonDerivedType(typeof(TaskArtifactUpdateEvent))] -[JsonDerivedType(typeof(AgentMessage))] -[JsonDerivedType(typeof(AgentTask))] -// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the -// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so -// we implement our own converter to handle that, with the discriminator logic implemented by-hand. -public abstract class A2AEvent(string kind) -{ - /// - /// The 'kind' discriminator value - /// - [JsonRequired, JsonPropertyName(BaseKindDiscriminatorConverter.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)] - public string Kind { get; internal set; } = kind; -} - -/// -/// A2A response objects. -/// -/// The kind discriminator value -[JsonConverter(typeof(A2AEventConverterViaKindDiscriminator))] -[JsonDerivedType(typeof(AgentMessage))] -[JsonDerivedType(typeof(AgentTask))] -// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the -// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so -// we implement our own converter to handle that, with the discriminator logic implemented by-hand. -public abstract class A2AResponse(string kind) : A2AEvent(kind); - -internal class A2AEventConverterViaKindDiscriminator : BaseKindDiscriminatorConverter where T : A2AEvent -{ - protected override IReadOnlyDictionary KindToTypeMapping { get; } = new Dictionary - { - [A2AEventKind.Message] = typeof(AgentMessage), - [A2AEventKind.Task] = typeof(AgentTask), - [A2AEventKind.StatusUpdate] = typeof(TaskStatusUpdateEvent), - [A2AEventKind.ArtifactUpdate] = typeof(TaskArtifactUpdateEvent) - }; - - protected override string DisplayName { get; } = "event"; -} +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Base class for A2A events. +/// +/// The kind discriminator value +[JsonConverter(typeof(A2AEventConverterViaKindDiscriminator))] +[JsonDerivedType(typeof(TaskStatusUpdateEvent))] +[JsonDerivedType(typeof(TaskArtifactUpdateEvent))] +[JsonDerivedType(typeof(AgentMessage))] +[JsonDerivedType(typeof(AgentTask))] +// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the +// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so +// we implement our own converter to handle that, with the discriminator logic implemented by-hand. +public abstract class A2AEvent(string kind) +{ + /// + /// The 'kind' discriminator value + /// + [JsonRequired, JsonPropertyName(BaseKindDiscriminatorConverter.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)] + public string Kind { get; internal set; } = kind; +} + +/// +/// A2A response objects. +/// +/// The kind discriminator value +[JsonConverter(typeof(A2AEventConverterViaKindDiscriminator))] +[JsonDerivedType(typeof(AgentMessage))] +[JsonDerivedType(typeof(AgentTask))] +// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the +// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so +// we implement our own converter to handle that, with the discriminator logic implemented by-hand. +public abstract class A2AResponse(string kind) : A2AEvent(kind); + +internal class A2AEventConverterViaKindDiscriminator : BaseKindDiscriminatorConverter where T : A2AEvent +{ + protected override IReadOnlyDictionary KindToTypeMapping { get; } = new Dictionary + { + [A2AEventKind.Message] = typeof(AgentMessage), + [A2AEventKind.Task] = typeof(AgentTask), + [A2AEventKind.StatusUpdate] = typeof(TaskStatusUpdateEvent), + [A2AEventKind.ArtifactUpdate] = typeof(TaskArtifactUpdateEvent) + }; + + protected override string DisplayName { get; } = "event"; +} diff --git a/src/A2A.V0_3/Models/AgentCapabilities.cs b/src/A2A.V0_3/Models/AgentCapabilities.cs new file mode 100644 index 00000000..0ea4255b --- /dev/null +++ b/src/A2A.V0_3/Models/AgentCapabilities.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Defines optional capabilities supported by an agent. +/// +public sealed class AgentCapabilities +{ + /// + /// Gets or sets a value indicating whether the agent supports SSE. + /// + [JsonPropertyName("streaming")] + public bool Streaming { get; set; } + + /// + /// Gets or sets a value indicating whether the agent can notify updates to client. + /// + [JsonPropertyName("pushNotifications")] + public bool PushNotifications { get; set; } + + /// + /// Gets or sets a value indicating whether the agent exposes status change history for tasks. + /// + [JsonPropertyName("stateTransitionHistory")] + public bool StateTransitionHistory { get; set; } + + /// + /// Extensions supported by this agent. + /// + [JsonPropertyName("extensions")] + public List Extensions { get; set; } = []; +} diff --git a/src/A2A.V0_3/Models/AgentCard.cs b/src/A2A.V0_3/Models/AgentCard.cs new file mode 100644 index 00000000..ddbc33fa --- /dev/null +++ b/src/A2A.V0_3/Models/AgentCard.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// An AgentCard conveys key information about an agent. +/// +/// +/// - Overall details (version, name, description, uses) +/// - Skills: A set of capabilities the agent can perform +/// - Default modalities/content types supported by the agent. +/// - Authentication requirements. +/// +public sealed class AgentCard +{ + /// + /// Gets or sets the human readable name of the agent. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets a human-readable description of the agent. + /// + /// + /// Used to assist users and other agents in understanding what the agent can do. + /// CommonMark MAY be used for rich text formatting. + /// (e.g., "This agent helps users find recipes, plan meals, and get cooking instructions.") + /// + [JsonPropertyName("description")] + [JsonRequired] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets a URL to the address the agent is hosted at. + /// + /// + /// This represents the preferred endpoint as declared by the agent. + /// + [JsonPropertyName("url")] + [JsonRequired] + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets a URL to an icon for the agent. (e.g., `https://agent.example.com/icon.png`). + /// + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } + + /// + /// Gets or sets the service provider of the agent. + /// + [JsonPropertyName("provider")] + public AgentProvider? Provider { get; set; } + + /// + /// Gets or sets the version of the agent - format is up to the provider. + /// + [JsonPropertyName("version")] + [JsonRequired] + public string Version { get; set; } = string.Empty; + + /// + /// The version of the A2A protocol this agent supports. + /// + [JsonPropertyName("protocolVersion")] + [JsonRequired] + public string ProtocolVersion { get; set; } = "0.3.0"; + + /// + /// Gets or sets a URL to documentation for the agent. + /// + [JsonPropertyName("documentationUrl")] + public string? DocumentationUrl { get; set; } + + /// + /// Gets or sets the optional capabilities supported by the agent. + /// + [JsonPropertyName("capabilities")] + [JsonRequired] + public AgentCapabilities Capabilities { get; set; } = new AgentCapabilities(); + + /// + /// Gets or sets the security scheme details used for authenticating with this agent. + /// + [JsonPropertyName("securitySchemes")] + public Dictionary? SecuritySchemes { get; set; } + + /// + /// Gets or sets the security requirements for contacting the agent. + /// + [JsonPropertyName("security")] + public List>? Security { get; set; } + + /// + /// Gets or sets the set of interaction modes that the agent supports across all skills. + /// + /// + /// This can be overridden per-skill. Supported media types for input. + /// + [JsonPropertyName("defaultInputModes")] + [JsonRequired] + public List DefaultInputModes { get; set; } = ["text"]; + + /// + /// Gets or sets the supported media types for output. + /// + [JsonPropertyName("defaultOutputModes")] + [JsonRequired] + public List DefaultOutputModes { get; set; } = ["text"]; + + /// + /// Gets or sets the skills that are a unit of capability that an agent can perform. + /// + [JsonPropertyName("skills")] + [JsonRequired] + public List Skills { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the agent supports providing an extended agent card when the user is authenticated. + /// + /// + /// Defaults to false if not specified. + /// + [JsonPropertyName("supportsAuthenticatedExtendedCard")] + public bool SupportsAuthenticatedExtendedCard { get; set; } = false; + + /// + /// Announcement of additional supported transports. + /// + /// + /// The client can use any of the supported transports. + /// + [JsonPropertyName("additionalInterfaces")] + public List AdditionalInterfaces { get; set; } = []; + + /// + /// The transport of the preferred endpoint. + /// + /// + /// This property is required. It defaults to when an is instantiated in code. + /// + [JsonPropertyName("preferredTransport")] + [JsonRequired] + public AgentTransport PreferredTransport { get; set; } = AgentTransport.JsonRpc; + + /// + /// JSON Web Signatures computed for this AgentCard. + /// + [JsonPropertyName("signatures")] + public List? Signatures { get; set; } +} diff --git a/src/A2A.V0_3/Models/AgentCardSignature.cs b/src/A2A.V0_3/Models/AgentCardSignature.cs new file mode 100644 index 00000000..b79fed04 --- /dev/null +++ b/src/A2A.V0_3/Models/AgentCardSignature.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// AgentCardSignature represents a JWS signature of an AgentCard. +/// This follows the JSON format of an RFC 7515 JSON Web Signature (JWS). +/// +public sealed class AgentCardSignature +{ + /// + /// The protected JWS header for the signature. This is a Base64url-encoded + /// JSON object, as per RFC 7515. + /// + [JsonPropertyName("protected")] + [JsonRequired] + public string Protected { get; set; } = string.Empty; + + /// + /// The computed signature, Base64url-encoded. + /// + [JsonPropertyName("signature")] + [JsonRequired] + public string Signature { get; set; } = string.Empty; + + /// + /// The unprotected JWS header values. + /// + [JsonPropertyName("header")] + public Dictionary? Header { get; set; } +} \ No newline at end of file diff --git a/src/A2A.V0_3/Models/AgentExtension.cs b/src/A2A.V0_3/Models/AgentExtension.cs new file mode 100644 index 00000000..0e91ee94 --- /dev/null +++ b/src/A2A.V0_3/Models/AgentExtension.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// A declaration of an extension supported by an Agent. +/// +public sealed class AgentExtension +{ + /// + /// Gets or sets the URI of the extension. + /// + [JsonPropertyName("uri")] + [JsonRequired] + public string? Uri { get; set; } = string.Empty; + + /// + /// Gets or sets a description of how this agent uses this extension. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets whether the client must follow specific requirements of the extension. + /// + [JsonPropertyName("required")] + public bool Required { get; set; } = false; + + /// + /// Gets or sets optional configuration for the extension. + /// + [JsonPropertyName("params")] + public Dictionary? Params { get; set; } +} diff --git a/src/A2A.V0_3/Models/AgentInterface.cs b/src/A2A.V0_3/Models/AgentInterface.cs new file mode 100644 index 00000000..a110789d --- /dev/null +++ b/src/A2A.V0_3/Models/AgentInterface.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Provides a declaration of a combination of target URL and supported transport to interact with an agent. +/// +public sealed class AgentInterface +{ + /// + /// The transport supported by this URL. + /// + /// + /// This is an open form string, to be easily extended for many transport protocols. + /// The core ones officially supported are JSONRPC, GRPC, and HTTP+JSON. + /// + [JsonPropertyName("transport")] + [JsonRequired] + public required AgentTransport Transport { get; set; } + + /// + /// The target URL for the agent interface. + /// + [JsonPropertyName("url")] + [JsonRequired] + public required string Url { get; set; } +} \ No newline at end of file diff --git a/src/A2A/Models/AgentMessage.cs b/src/A2A.V0_3/Models/AgentMessage.cs similarity index 95% rename from src/A2A/Models/AgentMessage.cs rename to src/A2A.V0_3/Models/AgentMessage.cs index 0a9ff0e1..4d3979c3 100644 --- a/src/A2A/Models/AgentMessage.cs +++ b/src/A2A.V0_3/Models/AgentMessage.cs @@ -1,77 +1,77 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Message sender's role. -/// -[JsonConverter(typeof(KebabCaseLowerJsonStringEnumConverter))] -public enum MessageRole -{ - /// - /// User role. - /// - User, - /// - /// Agent role. - /// - Agent -} - -/// -/// Represents a single message exchanged between user and agent. -/// -public sealed class AgentMessage() : A2AResponse(A2AEventKind.Message) -{ - /// - /// Message sender's role. - /// - [JsonPropertyName("role")] - [JsonRequired] - public MessageRole Role { get; set; } = MessageRole.User; - - /// - /// Message content. - /// - [JsonPropertyName("parts")] - [JsonRequired] - public List Parts { get; set; } = []; - - /// - /// Extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - /// - /// List of tasks referenced as context by this message. - /// - [JsonPropertyName("referenceTaskIds")] - public List? ReferenceTaskIds { get; set; } - - /// - /// Identifier created by the message creator. - /// - [JsonPropertyName("messageId")] - [JsonRequired] - public string MessageId { get; set; } = string.Empty; - - /// - /// Identifier of task the message is related to. - /// - [JsonPropertyName("taskId")] - public string? TaskId { get; set; } - - /// - /// The context the message is associated with. - /// - [JsonPropertyName("contextId")] - public string? ContextId { get; set; } - - /// - /// The URIs of extensions that are present or contributed to this Message. - /// - [JsonPropertyName("extensions")] - public List? Extensions { get; set; } +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Message sender's role. +/// +[JsonConverter(typeof(KebabCaseLowerJsonStringEnumConverter))] +public enum MessageRole +{ + /// + /// User role. + /// + User, + /// + /// Agent role. + /// + Agent +} + +/// +/// Represents a single message exchanged between user and agent. +/// +public sealed class AgentMessage() : A2AResponse(A2AEventKind.Message) +{ + /// + /// Message sender's role. + /// + [JsonPropertyName("role")] + [JsonRequired] + public MessageRole Role { get; set; } = MessageRole.User; + + /// + /// Message content. + /// + [JsonPropertyName("parts")] + [JsonRequired] + public List Parts { get; set; } = []; + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + /// + /// List of tasks referenced as context by this message. + /// + [JsonPropertyName("referenceTaskIds")] + public List? ReferenceTaskIds { get; set; } + + /// + /// Identifier created by the message creator. + /// + [JsonPropertyName("messageId")] + [JsonRequired] + public string MessageId { get; set; } = string.Empty; + + /// + /// Identifier of task the message is related to. + /// + [JsonPropertyName("taskId")] + public string? TaskId { get; set; } + + /// + /// The context the message is associated with. + /// + [JsonPropertyName("contextId")] + public string? ContextId { get; set; } + + /// + /// The URIs of extensions that are present or contributed to this Message. + /// + [JsonPropertyName("extensions")] + public List? Extensions { get; set; } } \ No newline at end of file diff --git a/src/A2A.V0_3/Models/AgentProvider.cs b/src/A2A.V0_3/Models/AgentProvider.cs new file mode 100644 index 00000000..914b6950 --- /dev/null +++ b/src/A2A.V0_3/Models/AgentProvider.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents the service provider of an agent. +/// +public sealed class AgentProvider +{ + /// + /// Agent provider's organization name. + /// + [JsonPropertyName("organization")] + [JsonRequired] + public string Organization { get; set; } = string.Empty; + + /// + /// Agent provider's URL. + /// + [JsonPropertyName("url")] + [JsonRequired] + public string Url { get; set; } = string.Empty; +} diff --git a/src/A2A.V0_3/Models/AgentSkill.cs b/src/A2A.V0_3/Models/AgentSkill.cs new file mode 100644 index 00000000..38f6bc21 --- /dev/null +++ b/src/A2A.V0_3/Models/AgentSkill.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a unit of capability that an agent can perform. +/// +public sealed class AgentSkill +{ + /// + /// Unique identifier for the agent's skill. + /// + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; set; } = string.Empty; + + /// + /// Human readable name of the skill. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Description of the skill. + /// + /// + /// Will be used by the client or a human as a hint to understand what the skill does. + /// + [JsonPropertyName("description")] + [JsonRequired] + public string Description { get; set; } = string.Empty; + + /// + /// Set of tagwords describing classes of capabilities for this specific skill. + /// + [JsonPropertyName("tags")] + [JsonRequired] + public List Tags { get; set; } = []; + + /// + /// The set of example scenarios that the skill can perform. + /// + /// + /// Will be used by the client as a hint to understand how the skill can be used. + /// + [JsonPropertyName("examples")] + public List? Examples { get; set; } + + /// + /// The set of interaction modes that the skill supports (if different than the default). + /// + /// + /// Supported media types for input. + /// + [JsonPropertyName("inputModes")] + public List? InputModes { get; set; } + + /// + /// Supported media types for output. + /// + [JsonPropertyName("outputModes")] + public List? OutputModes { get; set; } + + /// + /// Security schemes necessary for the agent to leverage this skill. + /// + /// + /// As in the overall AgentCard.security, this list represents a logical OR of security + /// requirement objects. Each object is a set of security schemes that must be used together + /// (a logical AND). + /// + [JsonPropertyName("security")] + public List>? Security { get; set; } +} diff --git a/src/A2A.V0_3/Models/AgentTask.cs b/src/A2A.V0_3/Models/AgentTask.cs new file mode 100644 index 00000000..55caf653 --- /dev/null +++ b/src/A2A.V0_3/Models/AgentTask.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a task that can be processed by an agent. +/// +public sealed class AgentTask() : A2AResponse(A2AEventKind.Task) +{ + /// + /// Unique identifier for the task. + /// + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; set; } = string.Empty; + + /// + /// Server-generated id for contextual alignment across interactions. + /// + [JsonPropertyName("contextId")] + [JsonRequired] + public string ContextId { get; set; } = string.Empty; + + /// + /// Current status of the task. + /// + [JsonPropertyName("status")] + [JsonRequired] + public AgentTaskStatus Status { get; set; } = new AgentTaskStatus(); + + /// + /// Collection of artifacts created by the agent. + /// + [JsonPropertyName("artifacts")] + public List? Artifacts { get; set; } + + /// + /// Collection of messages in the task history. + /// + [JsonPropertyName("history")] + public List? History { get; set; } = []; + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} diff --git a/src/A2A/Models/AgentTaskStatus.cs b/src/A2A.V0_3/Models/AgentTaskStatus.cs similarity index 93% rename from src/A2A/Models/AgentTaskStatus.cs rename to src/A2A.V0_3/Models/AgentTaskStatus.cs index 5404838d..18253d56 100644 --- a/src/A2A/Models/AgentTaskStatus.cs +++ b/src/A2A.V0_3/Models/AgentTaskStatus.cs @@ -1,31 +1,31 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents the current status of an agent task. -/// -/// -/// Contains the TaskState and accompanying message. -/// -public struct AgentTaskStatus() -{ - /// - /// The current state of the task. - /// - [JsonPropertyName("state")] - [JsonRequired] - public TaskState State { get; set; } - - /// - /// Additional status updates for client. - /// - [JsonPropertyName("message")] - public AgentMessage? Message { get; set; } - - /// - /// ISO 8601 datetime string when the status was recorded. - /// - [JsonPropertyName("timestamp")] - public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents the current status of an agent task. +/// +/// +/// Contains the TaskState and accompanying message. +/// +public struct AgentTaskStatus() +{ + /// + /// The current state of the task. + /// + [JsonPropertyName("state")] + [JsonRequired] + public TaskState State { get; set; } + + /// + /// Additional status updates for client. + /// + [JsonPropertyName("message")] + public AgentMessage? Message { get; set; } + + /// + /// ISO 8601 datetime string when the status was recorded. + /// + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; } \ No newline at end of file diff --git a/src/A2A/Models/AgentTransport.cs b/src/A2A.V0_3/Models/AgentTransport.cs similarity index 97% rename from src/A2A/Models/AgentTransport.cs rename to src/A2A.V0_3/Models/AgentTransport.cs index c009046a..a83b2e12 100644 --- a/src/A2A/Models/AgentTransport.cs +++ b/src/A2A.V0_3/Models/AgentTransport.cs @@ -1,120 +1,120 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents the transport protocol for an AgentInterface. -/// -[JsonConverter(typeof(AgentTransportConverter))] -public readonly struct AgentTransport : IEquatable -{ - /// - /// JSON-RPC transport. - /// - public static AgentTransport JsonRpc { get; } = new("JSONRPC"); - - /// - /// Gets the label associated with this . - /// - public string Label { get; } - - /// - /// Creates a new instance with the provided label. - /// - /// The label to associate with this . - [JsonConstructor] - public AgentTransport(string label) - { - if (string.IsNullOrWhiteSpace(label)) - throw new ArgumentException("Transport label cannot be null or whitespace.", nameof(label)); - this.Label = label; - } - - /// - /// Determines whether two instances are equal. - /// - /// The first to compare. - /// The second to compare. - /// true if the instances are equal; otherwise, false. - public static bool operator ==(AgentTransport left, AgentTransport right) - => left.Equals(right); - - /// - /// Determines whether two instances are not equal. - /// - /// The first to compare. - /// The second to compare. - /// true if the instances are not equal; otherwise, false. - public static bool operator !=(AgentTransport left, AgentTransport right) - => !(left == right); - - /// - /// Determines whether the specified object is equal to the current . - /// - /// The object to compare with the current . - /// true if the specified object is equal to the current ; otherwise, false. - public override bool Equals(object? obj) - => obj is AgentTransport other && this == other; - - /// - /// Determines whether the specified is equal to the current . - /// - /// The to compare with the current . - /// true if the specified is equal to the current ; otherwise, false. - public bool Equals(AgentTransport other) - => string.Equals(this.Label, other.Label, StringComparison.OrdinalIgnoreCase); - - /// - /// Returns the hash code for this . - /// - /// A hash code for the current . - public override int GetHashCode() - => StringComparer.OrdinalIgnoreCase.GetHashCode(this.Label); - - /// - /// Returns the string representation of this . - /// - /// The label of this . - public override string ToString() => this.Label; - - /// - /// Custom JSON converter for that serializes it as a simple string value. - /// - internal sealed class AgentTransportConverter : JsonConverter - { - /// - /// Reads and converts the JSON to . - /// - /// The reader to read from. - /// The type to convert. - /// Serializer options. - /// The converted . - public override AgentTransport Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.String) - { - throw new JsonException("Expected a string for AgentTransport."); - } - - var label = reader.GetString(); - if (string.IsNullOrWhiteSpace(label)) - { - throw new JsonException("AgentTransport string value cannot be null or whitespace."); - } - - return new AgentTransport(label!); - } - - /// - /// Writes the as a JSON string. - /// - /// The writer to write to. - /// The value to convert. - /// Serializer options. - public override void Write(Utf8JsonWriter writer, AgentTransport value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.Label); - } - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents the transport protocol for an AgentInterface. +/// +[JsonConverter(typeof(AgentTransportConverter))] +public readonly struct AgentTransport : IEquatable +{ + /// + /// JSON-RPC transport. + /// + public static AgentTransport JsonRpc { get; } = new("JSONRPC"); + + /// + /// Gets the label associated with this . + /// + public string Label { get; } + + /// + /// Creates a new instance with the provided label. + /// + /// The label to associate with this . + [JsonConstructor] + public AgentTransport(string label) + { + if (string.IsNullOrWhiteSpace(label)) + throw new ArgumentException("Transport label cannot be null or whitespace.", nameof(label)); + this.Label = label; + } + + /// + /// Determines whether two instances are equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the instances are equal; otherwise, false. + public static bool operator ==(AgentTransport left, AgentTransport right) + => left.Equals(right); + + /// + /// Determines whether two instances are not equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the instances are not equal; otherwise, false. + public static bool operator !=(AgentTransport left, AgentTransport right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current . + /// true if the specified object is equal to the current ; otherwise, false. + public override bool Equals(object? obj) + => obj is AgentTransport other && this == other; + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// true if the specified is equal to the current ; otherwise, false. + public bool Equals(AgentTransport other) + => string.Equals(this.Label, other.Label, StringComparison.OrdinalIgnoreCase); + + /// + /// Returns the hash code for this . + /// + /// A hash code for the current . + public override int GetHashCode() + => StringComparer.OrdinalIgnoreCase.GetHashCode(this.Label); + + /// + /// Returns the string representation of this . + /// + /// The label of this . + public override string ToString() => this.Label; + + /// + /// Custom JSON converter for that serializes it as a simple string value. + /// + internal sealed class AgentTransportConverter : JsonConverter + { + /// + /// Reads and converts the JSON to . + /// + /// The reader to read from. + /// The type to convert. + /// Serializer options. + /// The converted . + public override AgentTransport Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected a string for AgentTransport."); + } + + var label = reader.GetString(); + if (string.IsNullOrWhiteSpace(label)) + { + throw new JsonException("AgentTransport string value cannot be null or whitespace."); + } + + return new AgentTransport(label!); + } + + /// + /// Writes the as a JSON string. + /// + /// The writer to write to. + /// The value to convert. + /// Serializer options. + public override void Write(Utf8JsonWriter writer, AgentTransport value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Label); + } + } +} diff --git a/src/A2A.V0_3/Models/Artifact.cs b/src/A2A.V0_3/Models/Artifact.cs new file mode 100644 index 00000000..dd2d7cd8 --- /dev/null +++ b/src/A2A.V0_3/Models/Artifact.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents an artifact generated for a task. +/// +public sealed class Artifact +{ + /// + /// Unique identifier for the artifact. + /// + [JsonPropertyName("artifactId")] + [JsonRequired] + public string ArtifactId { get; set; } = string.Empty; + + /// + /// Optional name for the artifact. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional description for the artifact. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Artifact parts. + /// + [JsonPropertyName("parts")] + [JsonRequired] + public List Parts { get; set; } = []; + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + /// + /// The URIs of extensions that are present or contributed to this Artifact. + /// + [JsonPropertyName("extensions")] + public List? Extensions { get; set; } +} \ No newline at end of file diff --git a/src/A2A/Models/DataPart.cs b/src/A2A.V0_3/Models/DataPart.cs similarity index 91% rename from src/A2A/Models/DataPart.cs rename to src/A2A.V0_3/Models/DataPart.cs index 540b4e0e..6b277604 100644 --- a/src/A2A/Models/DataPart.cs +++ b/src/A2A.V0_3/Models/DataPart.cs @@ -1,17 +1,17 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents a structured data segment within a message part. -/// -public sealed class DataPart() : Part(PartKind.Data) -{ - /// - /// Structured data content. - /// - [JsonPropertyName("data")] - [JsonRequired] - public Dictionary Data { get; set; } = []; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a structured data segment within a message part. +/// +public sealed class DataPart() : Part(PartKind.Data) +{ + /// + /// Structured data content. + /// + [JsonPropertyName("data")] + [JsonRequired] + public Dictionary Data { get; set; } = []; } \ No newline at end of file diff --git a/src/A2A/Models/FileContent.cs b/src/A2A.V0_3/Models/FileContent.cs similarity index 96% rename from src/A2A/Models/FileContent.cs rename to src/A2A.V0_3/Models/FileContent.cs index 9ada282e..89ab18a2 100644 --- a/src/A2A/Models/FileContent.cs +++ b/src/A2A.V0_3/Models/FileContent.cs @@ -1,119 +1,119 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents the base entity for FileParts. -/// According to the A2A spec, FileContent types are distinguished by the presence -/// of either "bytes" or "uri" properties, not by a discriminator. -/// -public class FileContent -{ - private string? _bytes; - private Uri? _uri; - - /// - /// Initializes a new instance of the class. - /// - [JsonConstructor(), Obsolete("Parameterless ctor is only for Json de/serialization. Use a constructor specifying 'bytes' or 'uri'.")] - public FileContent() { } - - /// - /// Initializes a new instance of the class with base64-encoded bytes. - /// - /// The base64-encoded string representing the file content. - /// Thrown when is null or whitespace. - public FileContent(string bytes) - { - if (string.IsNullOrWhiteSpace(bytes)) - { - throw new ArgumentNullException(nameof(bytes)); - } - - _bytes = bytes; - } - - /// - /// Initializes a new instance of the class from a byte sequence. - /// - /// The byte sequence representing the file content. - /// The encoding to use for converting bytes to a string. Defaults to UTF-8 if not specified. - public FileContent(IEnumerable bytes, Encoding? encoding = null) => _bytes = (encoding ?? Encoding.UTF8).GetString([.. bytes]); - - /// - /// Initializes a new instance of the class with a URI reference. - /// - /// The URI pointing to the file content. - public FileContent(Uri uri) => _uri = uri; - - /// - /// Optional name for the file. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Optional mimeType for the file. - /// - [JsonPropertyName("mimeType")] - public string? MimeType { get; set; } - - /// - /// base64 encoded content of the file. - /// - [JsonPropertyName("bytes")] - public string? Bytes - { - get => _bytes; - set - { - if (!string.IsNullOrWhiteSpace(_uri?.ToString())) - { - throw new A2AException("Only one of 'bytes' or 'uri' must be specified", A2AErrorCode.InvalidRequest); - } - - _bytes = value; - } - } - - /// - /// URL for the File content. - /// - [JsonPropertyName("uri")] - public Uri? Uri - { - get => _uri; - set - { - if (!string.IsNullOrWhiteSpace(_bytes)) - { - throw new A2AException("Only one of 'bytes' or 'uri' must be specified", A2AErrorCode.InvalidRequest); - } - - _uri = value; - } - } - - internal class Converter : A2AJsonConverter - { - protected override FileContent? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) - { - var root = document.RootElement; - - // Determine type based on presence of required properties - bool hasBytes = root.TryGetProperty("bytes", out var bytesProperty) && - bytesProperty.ValueKind == JsonValueKind.String; - bool hasUri = root.TryGetProperty("uri", out var uriProperty) && - uriProperty.ValueKind == JsonValueKind.String; - - if (!hasBytes && !hasUri) - { - throw new A2AException("FileContent must have either 'bytes' or 'uri' property", A2AErrorCode.InvalidRequest); - } - - return base.DeserializeImpl(typeof(FileContent), options, document); - } - } -} +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents the base entity for FileParts. +/// According to the A2A spec, FileContent types are distinguished by the presence +/// of either "bytes" or "uri" properties, not by a discriminator. +/// +public class FileContent +{ + private string? _bytes; + private Uri? _uri; + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor(), Obsolete("Parameterless ctor is only for Json de/serialization. Use a constructor specifying 'bytes' or 'uri'.")] + public FileContent() { } + + /// + /// Initializes a new instance of the class with base64-encoded bytes. + /// + /// The base64-encoded string representing the file content. + /// Thrown when is null or whitespace. + public FileContent(string bytes) + { + if (string.IsNullOrWhiteSpace(bytes)) + { + throw new ArgumentNullException(nameof(bytes)); + } + + _bytes = bytes; + } + + /// + /// Initializes a new instance of the class from a byte sequence. + /// + /// The byte sequence representing the file content. + /// The encoding to use for converting bytes to a string. Defaults to UTF-8 if not specified. + public FileContent(IEnumerable bytes, Encoding? encoding = null) => _bytes = (encoding ?? Encoding.UTF8).GetString([.. bytes]); + + /// + /// Initializes a new instance of the class with a URI reference. + /// + /// The URI pointing to the file content. + public FileContent(Uri uri) => _uri = uri; + + /// + /// Optional name for the file. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Optional mimeType for the file. + /// + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + /// + /// base64 encoded content of the file. + /// + [JsonPropertyName("bytes")] + public string? Bytes + { + get => _bytes; + set + { + if (!string.IsNullOrWhiteSpace(_uri?.ToString())) + { + throw new A2AException("Only one of 'bytes' or 'uri' must be specified", A2AErrorCode.InvalidRequest); + } + + _bytes = value; + } + } + + /// + /// URL for the File content. + /// + [JsonPropertyName("uri")] + public Uri? Uri + { + get => _uri; + set + { + if (!string.IsNullOrWhiteSpace(_bytes)) + { + throw new A2AException("Only one of 'bytes' or 'uri' must be specified", A2AErrorCode.InvalidRequest); + } + + _uri = value; + } + } + + internal class Converter : A2AJsonConverter + { + protected override FileContent? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) + { + var root = document.RootElement; + + // Determine type based on presence of required properties + bool hasBytes = root.TryGetProperty("bytes", out var bytesProperty) && + bytesProperty.ValueKind == JsonValueKind.String; + bool hasUri = root.TryGetProperty("uri", out var uriProperty) && + uriProperty.ValueKind == JsonValueKind.String; + + if (!hasBytes && !hasUri) + { + throw new A2AException("FileContent must have either 'bytes' or 'uri' property", A2AErrorCode.InvalidRequest); + } + + return base.DeserializeImpl(typeof(FileContent), options, document); + } + } +} diff --git a/src/A2A/Models/FilePart.cs b/src/A2A.V0_3/Models/FilePart.cs similarity index 90% rename from src/A2A/Models/FilePart.cs rename to src/A2A.V0_3/Models/FilePart.cs index fa3b1a1f..dd03a7a4 100644 --- a/src/A2A/Models/FilePart.cs +++ b/src/A2A.V0_3/Models/FilePart.cs @@ -1,16 +1,16 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents a File segment within parts. -/// -public sealed class FilePart() : Part(PartKind.File) -{ - /// - /// File content either as url or bytes. - /// - [JsonPropertyName("file")] - [JsonRequired] - required public FileContent File { get; set; } +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a File segment within parts. +/// +public sealed class FilePart() : Part(PartKind.File) +{ + /// + /// File content either as url or bytes. + /// + [JsonPropertyName("file")] + [JsonRequired] + required public FileContent File { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/GetTaskPushNotificationConfigParams.cs b/src/A2A.V0_3/Models/GetTaskPushNotificationConfigParams.cs similarity index 94% rename from src/A2A/Models/GetTaskPushNotificationConfigParams.cs rename to src/A2A.V0_3/Models/GetTaskPushNotificationConfigParams.cs index 69e7278b..62b8e946 100644 --- a/src/A2A/Models/GetTaskPushNotificationConfigParams.cs +++ b/src/A2A.V0_3/Models/GetTaskPushNotificationConfigParams.cs @@ -1,29 +1,29 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Parameters for fetching a pushNotificationConfiguration associated with a Task. -/// -public sealed class GetTaskPushNotificationConfigParams -{ - /// - /// Task id. - /// - [JsonPropertyName("id")] - [JsonRequired] - public string Id { get; set; } = string.Empty; - - /// - /// Extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - /// - /// Optional push notification configuration ID to retrieve a specific configuration. - /// - [JsonPropertyName("pushNotificationConfigId")] - public string? PushNotificationConfigId { get; set; } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Parameters for fetching a pushNotificationConfiguration associated with a Task. +/// +public sealed class GetTaskPushNotificationConfigParams +{ + /// + /// Task id. + /// + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; set; } = string.Empty; + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + /// + /// Optional push notification configuration ID to retrieve a specific configuration. + /// + [JsonPropertyName("pushNotificationConfigId")] + public string? PushNotificationConfigId { get; set; } +} diff --git a/src/A2A/Models/MessageSendParams.cs b/src/A2A.V0_3/Models/MessageSendParams.cs similarity index 95% rename from src/A2A/Models/MessageSendParams.cs rename to src/A2A.V0_3/Models/MessageSendParams.cs index f7a5a2de..12924f5c 100644 --- a/src/A2A/Models/MessageSendParams.cs +++ b/src/A2A.V0_3/Models/MessageSendParams.cs @@ -1,62 +1,62 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Parameters for sending a message request to an agent. -/// -/// -/// Sent by the client to the agent as a request. May create, continue or restart a task. -/// -public sealed class MessageSendParams -{ - /// - /// The message being sent to the server. - /// - [JsonRequired, JsonPropertyName("message")] - public AgentMessage Message { get; set; } = new(); - - /// - /// Send message configuration. - /// - [JsonPropertyName("configuration")] - public MessageSendConfiguration? Configuration { get; set; } - - /// - /// Extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } -} - -/// -/// Configuration for the send message request. -/// -public sealed class MessageSendConfiguration -{ - /// - /// Accepted output modalities by the client. - /// - [JsonPropertyName("acceptedOutputModes")] - [JsonRequired] - public List AcceptedOutputModes { get; set; } = []; - - /// - /// Where the server should send notifications when disconnected. - /// - [JsonPropertyName("pushNotificationConfig")] - public PushNotificationConfig? PushNotification { get; set; } - - /// - /// Number of recent messages to be retrieved. - /// - [JsonPropertyName("historyLength")] - public int? HistoryLength { get; set; } - - /// - /// If the server should treat the client as a blocking request. - /// - [JsonPropertyName("blocking")] - public bool Blocking { get; set; } = false; -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Parameters for sending a message request to an agent. +/// +/// +/// Sent by the client to the agent as a request. May create, continue or restart a task. +/// +public sealed class MessageSendParams +{ + /// + /// The message being sent to the server. + /// + [JsonRequired, JsonPropertyName("message")] + public AgentMessage Message { get; set; } = new(); + + /// + /// Send message configuration. + /// + [JsonPropertyName("configuration")] + public MessageSendConfiguration? Configuration { get; set; } + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// +/// Configuration for the send message request. +/// +public sealed class MessageSendConfiguration +{ + /// + /// Accepted output modalities by the client. + /// + [JsonPropertyName("acceptedOutputModes")] + [JsonRequired] + public List AcceptedOutputModes { get; set; } = []; + + /// + /// Where the server should send notifications when disconnected. + /// + [JsonPropertyName("pushNotificationConfig")] + public PushNotificationConfig? PushNotification { get; set; } + + /// + /// Number of recent messages to be retrieved. + /// + [JsonPropertyName("historyLength")] + public int? HistoryLength { get; set; } + + /// + /// If the server should treat the client as a blocking request. + /// + [JsonPropertyName("blocking")] + public bool Blocking { get; set; } = false; +} diff --git a/src/A2A.V0_3/Models/Part.cs b/src/A2A.V0_3/Models/Part.cs new file mode 100644 index 00000000..a7510b2e --- /dev/null +++ b/src/A2A.V0_3/Models/Part.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a part of a message, which can be text, a file, or structured data. +/// +/// The kind discriminator value +[JsonConverter(typeof(PartConverterViaKindDiscriminator))] +[JsonDerivedType(typeof(TextPart))] +[JsonDerivedType(typeof(FilePart))] +[JsonDerivedType(typeof(DataPart))] +// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the +// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so +// we implement our own converter to handle that, with the discriminator logic implemented by-hand. +public abstract class Part(string kind) +{ + /// + /// The 'kind' discriminator value + /// + [JsonRequired, JsonPropertyName(BaseKindDiscriminatorConverter.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)] + public string Kind { get; internal set; } = kind; + /// + /// Optional metadata associated with the part. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + /// + /// Casts this part to a TextPart. + /// + /// The part as a TextPart. + /// Thrown when the part is not a TextPart. + public TextPart AsTextPart() => this is TextPart textPart ? + textPart : + throw new InvalidCastException($"Cannot cast {GetType().Name} to TextPart."); + + /// + /// Casts this part to a FilePart. + /// + /// The part as a FilePart. + /// Thrown when the part is not a FilePart. + public FilePart AsFilePart() => this is FilePart filePart ? + filePart : + throw new InvalidCastException($"Cannot cast {GetType().Name} to FilePart."); + + /// + /// Casts this part to a DataPart. + /// + /// The part as a DataPart. + /// Thrown when the part is not a DataPart. + public DataPart AsDataPart() => this is DataPart dataPart ? + dataPart : + throw new InvalidCastException($"Cannot cast {GetType().Name} to DataPart."); +} + +internal class PartConverterViaKindDiscriminator : BaseKindDiscriminatorConverter where T : Part +{ + protected override IReadOnlyDictionary KindToTypeMapping { get; } = new Dictionary + { + [PartKind.Text] = typeof(TextPart), + [PartKind.File] = typeof(FilePart), + [PartKind.Data] = typeof(DataPart) + }; + + protected override string DisplayName { get; } = "part"; +} \ No newline at end of file diff --git a/src/A2A/Models/PartKind.cs b/src/A2A.V0_3/Models/PartKind.cs similarity index 93% rename from src/A2A/Models/PartKind.cs rename to src/A2A.V0_3/Models/PartKind.cs index e64d5b4d..992ab153 100644 --- a/src/A2A/Models/PartKind.cs +++ b/src/A2A.V0_3/Models/PartKind.cs @@ -1,28 +1,28 @@ -namespace A2A; - -/// -/// Defines the set of Part kinds used as the 'kind' discriminator in serialized payloads. -/// -/// -/// Values are serialized as lowercase kebab-case strings. -/// -internal static class PartKind -{ - /// - /// A text part containing plain textual content. - /// - /// - public const string Text = "text"; - - /// - /// A file part containing file content. - /// - /// - public const string File = "file"; - - /// - /// A data part containing structured JSON data. - /// - /// - public const string Data = "data"; +namespace A2A.V0_3; + +/// +/// Defines the set of Part kinds used as the 'kind' discriminator in serialized payloads. +/// +/// +/// Values are serialized as lowercase kebab-case strings. +/// +internal static class PartKind +{ + /// + /// A text part containing plain textual content. + /// + /// + public const string Text = "text"; + + /// + /// A file part containing file content. + /// + /// + public const string File = "file"; + + /// + /// A data part containing structured JSON data. + /// + /// + public const string Data = "data"; } \ No newline at end of file diff --git a/src/A2A/Models/PushNotificationAuthenticationInfo.cs b/src/A2A.V0_3/Models/PushNotificationAuthenticationInfo.cs similarity index 92% rename from src/A2A/Models/PushNotificationAuthenticationInfo.cs rename to src/A2A.V0_3/Models/PushNotificationAuthenticationInfo.cs index bde2a89d..41c37a59 100644 --- a/src/A2A/Models/PushNotificationAuthenticationInfo.cs +++ b/src/A2A.V0_3/Models/PushNotificationAuthenticationInfo.cs @@ -1,22 +1,22 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Defines authentication details for push notifications. -/// -public sealed class PushNotificationAuthenticationInfo -{ - /// - /// Supported authentication schemes - e.g. Basic, Bearer. - /// - [JsonPropertyName("schemes")] - [JsonRequired] - public List Schemes { get; set; } = []; - - /// - /// Optional credentials. - /// - [JsonPropertyName("credentials")] - public string? Credentials { get; set; } +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Defines authentication details for push notifications. +/// +public sealed class PushNotificationAuthenticationInfo +{ + /// + /// Supported authentication schemes - e.g. Basic, Bearer. + /// + [JsonPropertyName("schemes")] + [JsonRequired] + public List Schemes { get; set; } = []; + + /// + /// Optional credentials. + /// + [JsonPropertyName("credentials")] + public string? Credentials { get; set; } } \ No newline at end of file diff --git a/src/A2A.V0_3/Models/PushNotificationConfig.cs b/src/A2A.V0_3/Models/PushNotificationConfig.cs new file mode 100644 index 00000000..c115d893 --- /dev/null +++ b/src/A2A.V0_3/Models/PushNotificationConfig.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Configuration for setting up push notifications for task updates. +/// +public sealed class PushNotificationConfig +{ + /// + /// URL for sending the push notifications. + /// + [JsonPropertyName("url")] + [JsonRequired] + public string Url { get; set; } = string.Empty; + + /// + /// Optional server-generated identifier for the push notification configuration to support multiple callbacks. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Token unique to this task/session. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Authentication details for push notifications. + /// + [JsonPropertyName("authentication")] + public PushNotificationAuthenticationInfo? Authentication { get; set; } +} \ No newline at end of file diff --git a/src/A2A.V0_3/Models/SecurityScheme.cs b/src/A2A.V0_3/Models/SecurityScheme.cs new file mode 100644 index 00000000..217aa556 --- /dev/null +++ b/src/A2A.V0_3/Models/SecurityScheme.cs @@ -0,0 +1,341 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Mirrors the OpenAPI Security Scheme Object. +/// (https://swagger.io/specification/#security-scheme-object) +/// +/// +/// This is the base type for all supported OpenAPI security schemes. +/// The type property is used as a discriminator for polymorphic deserialization. +/// +/// +/// Initializes a new instance of the class. +/// +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ApiKeySecurityScheme), "apiKey")] +[JsonDerivedType(typeof(HttpAuthSecurityScheme), "http")] +[JsonDerivedType(typeof(OAuth2SecurityScheme), "oauth2")] +[JsonDerivedType(typeof(OpenIdConnectSecurityScheme), "openIdConnect")] +[JsonDerivedType(typeof(MutualTlsSecurityScheme), "mutualTLS")] +public abstract class SecurityScheme(string? description = null) +{ + /// + /// A short description for security scheme. CommonMark syntax MAY be used for rich text representation. + /// + [JsonPropertyName("description")] + public string? Description { get; init; } = description; +} + +/// +/// API Key security scheme. +/// +/// +/// Initializes a new instance of the class. +/// +/// The name of the header, query or cookie parameter to be used. +/// The location of the API key. Valid values are "query", "header", or "cookie". +/// +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +/// +public sealed class ApiKeySecurityScheme(string name, string keyLocation, string? description = "API key for authentication") : SecurityScheme(description) +{ + /// + /// The name of the header, query or cookie parameter to be used. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; init; } = name; + + /// + /// The location of the API key. Valid values are "query", "header", or "cookie". + /// + [JsonPropertyName("in")] + [JsonRequired] + public string KeyLocation { get; init; } = keyLocation; +} + +/// +/// HTTP Authentication security scheme. +/// +/// +/// Initializes a new instance of the class. +/// +/// The name of the HTTP Authentication scheme to be used in the Authorization header as defined in RFC7235. +/// A hint to the client to identify how the bearer token is formatted. +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +public sealed class HttpAuthSecurityScheme(string scheme, string? bearerFormat = null, string? description = null) : SecurityScheme(description) +{ + /// + /// The name of the HTTP Authentication scheme to be used in the Authorization header as defined in RFC7235. + /// + [JsonPropertyName("scheme")] + [JsonRequired] + public string Scheme { get; init; } = scheme; + + /// + /// A hint to the client to identify how the bearer token is formatted. + /// + [JsonPropertyName("bearerFormat")] + public string? BearerFormat { get; init; } = bearerFormat; +} + +/// +/// OAuth2.0 security scheme configuration. +/// +/// +/// Initializes a new instance of the class. +/// +/// An object containing configuration information for the flow types supported. +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +public sealed class OAuth2SecurityScheme(OAuthFlows flows, string? description = null) : SecurityScheme(description) +{ + /// + /// An object containing configuration information for the flow types supported. + /// + [JsonPropertyName("flows")] + [JsonRequired] + public OAuthFlows Flows { get; init; } = flows; +} + +/// +/// OpenID Connect security scheme configuration. +/// +/// +/// Initializes a new instance of the class. +/// +/// Well-known URL to discover the [[OpenID-Connect-Discovery]] provider metadata. +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +public sealed class OpenIdConnectSecurityScheme(Uri openIdConnectUrl, string? description = null) : SecurityScheme(description) +{ + /// + /// Well-known URL to discover the [[OpenID-Connect-Discovery]] provider metadata. + /// + [JsonPropertyName("openIdConnectUrl")] + [JsonRequired] + public Uri OpenIdConnectUrl { get; init; } = openIdConnectUrl; +} + +/// +/// Mutual TLS security scheme configuration. +/// +/// +/// Initializes a new instance of the class. +/// +/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. +public sealed class MutualTlsSecurityScheme(string? description = "Mutual TLS authentication") : SecurityScheme(description) +{ +} + +/// +/// Allows configuration of the supported OAuth Flows. +/// +/// +/// Initializes a new instance of the class. +/// +public sealed class OAuthFlows +{ + /// + /// Configuration for the OAuth Authorization Code flow. Previously called accessCode in OpenAPI 2.0. + /// + [JsonPropertyName("authorizationCode")] + public AuthorizationCodeOAuthFlow? AuthorizationCode { get; init; } + + /// + /// Configuration for the OAuth Client Credentials flow. Previously called application in OpenAPI 2.0. + /// + [JsonPropertyName("clientCredentials")] + public ClientCredentialsOAuthFlow? ClientCredentials { get; init; } + + /// + /// Configuration for the OAuth Resource Owner Password flow. + /// + [JsonPropertyName("password")] + public PasswordOAuthFlow? Password { get; init; } + + /// + /// Configuration for the OAuth Implicit flow. + /// + [JsonPropertyName("implicit")] + public ImplicitOAuthFlow? Implicit { get; init; } +} + +/// +/// Configuration details applicable to all OAuth Flows. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public abstract class BaseOauthFlow(IDictionary scopes, + Uri? refreshUrl = null) +{ + /// + /// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + /// + [JsonPropertyName("refreshUrl")] + public Uri? RefreshUrl { get; init; } = refreshUrl; + + /// + /// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. + /// + [JsonPropertyName("scopes")] + [JsonRequired] + public IDictionary Scopes { get; init; } = scopes; +} + +/// +/// Configuration details for an OAuth Flow requiring a Token URL. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public abstract class TokenUrlOauthFlow(Uri tokenUrl, + IDictionary scopes, + Uri? refreshUrl = null) : BaseOauthFlow(scopes, refreshUrl) +{ + /// + /// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + /// + [JsonPropertyName("tokenUrl")] + [JsonRequired] + public Uri TokenUrl { get; init; } = tokenUrl; +} + +/// +/// Configuration details for an OAuth Flow requiring an Authorization URL. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public abstract class AuthUrlOauthFlow(Uri authorizationUrl, + IDictionary scopes, + Uri? refreshUrl = null) : BaseOauthFlow(scopes, refreshUrl) +{ + /// + /// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + /// + [JsonPropertyName("authorizationUrl")] + [JsonRequired] + public Uri AuthorizationUrl { get; init; } = authorizationUrl; +} + +/// +/// Configuration details for a supported OAuth Authorization Code Flow. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public sealed class AuthorizationCodeOAuthFlow( + Uri authorizationUrl, + Uri tokenUrl, + IDictionary scopes, + Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) +{ + /// + /// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. + /// + [JsonPropertyName("authorizationUrl")] + [JsonRequired] + public Uri AuthorizationUrl { get; init; } = authorizationUrl; +} + +/// +/// Configuration details for a supported OAuth Client Credentials Flow. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public sealed class ClientCredentialsOAuthFlow( + Uri tokenUrl, + IDictionary scopes, + Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) +{ } + +/// +/// Configuration details for a supported OAuth Resource Owner Password Flow. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public sealed class PasswordOAuthFlow( + Uri tokenUrl, + IDictionary scopes, + Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) +{ } + +/// +/// Configuration details for a supported OAuth Implicit Flow. +/// +/// +/// Initializes a new instance of the class. +/// +/// +/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +/// +/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. +/// +/// +/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. +/// +public sealed class ImplicitOAuthFlow( + Uri authorizationUrl, + IDictionary scopes, + Uri? refreshUrl = null) : AuthUrlOauthFlow(authorizationUrl, scopes, refreshUrl) +{ } \ No newline at end of file diff --git a/src/A2A.V0_3/Models/TaskArtifactUpdateEvent.cs b/src/A2A.V0_3/Models/TaskArtifactUpdateEvent.cs new file mode 100644 index 00000000..728917e5 --- /dev/null +++ b/src/A2A.V0_3/Models/TaskArtifactUpdateEvent.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Sent by server during sendStream or subscribe requests. +/// +public sealed class TaskArtifactUpdateEvent() : TaskUpdateEvent(A2AEventKind.ArtifactUpdate) +{ + /// + /// Generated artifact. + /// + [JsonPropertyName("artifact")] + public Artifact Artifact { get; set; } = new Artifact(); + + /// + /// Indicates if this artifact appends to a previous one. + /// + [JsonPropertyName("append")] + public bool? Append { get; set; } + + /// + /// Indicates if this is the last chunk of the artifact. + /// + [JsonPropertyName("lastChunk")] + public bool? LastChunk { get; set; } +} \ No newline at end of file diff --git a/src/A2A/Models/TaskIdParams.cs b/src/A2A.V0_3/Models/TaskIdParams.cs similarity index 92% rename from src/A2A/Models/TaskIdParams.cs rename to src/A2A.V0_3/Models/TaskIdParams.cs index 53be2f03..de0eeb09 100644 --- a/src/A2A/Models/TaskIdParams.cs +++ b/src/A2A.V0_3/Models/TaskIdParams.cs @@ -1,23 +1,23 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Parameters containing only a task ID, used for simple task operations. -/// -public class TaskIdParams -{ - /// - /// Task id. - /// - [JsonPropertyName("id")] - [JsonRequired] - public string Id { get; set; } = string.Empty; - - /// - /// Extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Parameters containing only a task ID, used for simple task operations. +/// +public class TaskIdParams +{ + /// + /// Task id. + /// + [JsonPropertyName("id")] + [JsonRequired] + public string Id { get; set; } = string.Empty; + + /// + /// Extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } } \ No newline at end of file diff --git a/src/A2A.V0_3/Models/TaskPushNotificationConfig.cs b/src/A2A.V0_3/Models/TaskPushNotificationConfig.cs new file mode 100644 index 00000000..4a4c1a3f --- /dev/null +++ b/src/A2A.V0_3/Models/TaskPushNotificationConfig.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Parameters for setting or getting push notification configuration for a task. +/// +public sealed class TaskPushNotificationConfig +{ + /// + /// Task id. + /// + [JsonPropertyName("taskId")] + [JsonRequired] + public string TaskId { get; set; } = string.Empty; + + /// + /// Push notification configuration. + /// + [JsonPropertyName("pushNotificationConfig")] + [JsonRequired] + public PushNotificationConfig PushNotificationConfig { get; set; } = new PushNotificationConfig(); +} \ No newline at end of file diff --git a/src/A2A/Models/TaskQueryParams.cs b/src/A2A.V0_3/Models/TaskQueryParams.cs similarity index 91% rename from src/A2A/Models/TaskQueryParams.cs rename to src/A2A.V0_3/Models/TaskQueryParams.cs index e07bd58e..70d13b60 100644 --- a/src/A2A/Models/TaskQueryParams.cs +++ b/src/A2A.V0_3/Models/TaskQueryParams.cs @@ -1,15 +1,15 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Parameters for querying a task, including optional history length. -/// -public sealed class TaskQueryParams : TaskIdParams -{ - /// - /// Number of recent messages to be retrieved. - /// - [JsonPropertyName("historyLength")] - public int? HistoryLength { get; set; } +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Parameters for querying a task, including optional history length. +/// +public sealed class TaskQueryParams : TaskIdParams +{ + /// + /// Number of recent messages to be retrieved. + /// + [JsonPropertyName("historyLength")] + public int? HistoryLength { get; set; } } \ No newline at end of file diff --git a/src/A2A.V0_3/Models/TaskState.cs b/src/A2A.V0_3/Models/TaskState.cs new file mode 100644 index 00000000..8ec01ad5 --- /dev/null +++ b/src/A2A.V0_3/Models/TaskState.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents the possible states of a Task. +/// +[JsonConverter(typeof(KebabCaseLowerJsonStringEnumConverter))] +public enum TaskState +{ + /// + /// Indicates that the task has been submitted. + /// + Submitted, + + /// + /// Indicates that the task is currently being worked on. + /// + Working, + + /// + /// Indicates that the task requires input from the user. + /// + InputRequired, + + /// + /// Indicates that the task has been completed successfully. + /// + Completed, + + /// + /// Indicates that the task has been canceled. + /// + Canceled, + + /// + /// Indicates that the task has failed. + /// + Failed, + + /// + /// Indicates that the task has been rejected. + /// + Rejected, + + /// + /// Indicates that the task requires authentication. + /// + AuthRequired, + + /// + /// Indicates that the task state is unknown. + /// + Unknown +} \ No newline at end of file diff --git a/src/A2A.V0_3/Models/TaskStatusUpdateEvent.cs b/src/A2A.V0_3/Models/TaskStatusUpdateEvent.cs new file mode 100644 index 00000000..d348f2d0 --- /dev/null +++ b/src/A2A.V0_3/Models/TaskStatusUpdateEvent.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Event sent by server during sendStream or subscribe requests. +/// +public sealed class TaskStatusUpdateEvent() : TaskUpdateEvent(A2AEventKind.StatusUpdate) +{ + /// + /// Gets or sets the current status of the task. + /// + [JsonPropertyName("status")] + [JsonRequired] + public AgentTaskStatus Status { get; set; } = new(); + + /// + /// Gets or sets a value indicating whether this indicates the end of the event stream. + /// + [JsonPropertyName("final")] + public bool Final { get; set; } +} \ No newline at end of file diff --git a/src/A2A/Models/TaskUpdateEvent.cs b/src/A2A.V0_3/Models/TaskUpdateEvent.cs similarity index 94% rename from src/A2A/Models/TaskUpdateEvent.cs rename to src/A2A.V0_3/Models/TaskUpdateEvent.cs index 87c06f01..8726b5d5 100644 --- a/src/A2A/Models/TaskUpdateEvent.cs +++ b/src/A2A.V0_3/Models/TaskUpdateEvent.cs @@ -1,30 +1,30 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Base class for task update events. -/// -/// The kind discriminator value -public abstract class TaskUpdateEvent(string kind) : A2AEvent(kind) -{ - /// - /// Gets or sets the task ID. - /// - [JsonPropertyName("taskId")] - public string TaskId { get; set; } = string.Empty; - - /// - /// Gets or sets the context the task is associated with. - /// - [JsonPropertyName("contextId")] - [JsonRequired] - public string ContextId { get; set; } = string.Empty; - - /// - /// Gets or sets the extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Base class for task update events. +/// +/// The kind discriminator value +public abstract class TaskUpdateEvent(string kind) : A2AEvent(kind) +{ + /// + /// Gets or sets the task ID. + /// + [JsonPropertyName("taskId")] + public string TaskId { get; set; } = string.Empty; + + /// + /// Gets or sets the context the task is associated with. + /// + [JsonPropertyName("contextId")] + [JsonRequired] + public string ContextId { get; set; } = string.Empty; + + /// + /// Gets or sets the extension metadata. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} diff --git a/src/A2A/Models/TextPart.cs b/src/A2A.V0_3/Models/TextPart.cs similarity index 90% rename from src/A2A/Models/TextPart.cs rename to src/A2A.V0_3/Models/TextPart.cs index 069a8566..d0b4a14c 100644 --- a/src/A2A/Models/TextPart.cs +++ b/src/A2A.V0_3/Models/TextPart.cs @@ -1,16 +1,16 @@ -using System.Text.Json.Serialization; - -namespace A2A; - -/// -/// Represents a text segment within parts. -/// -public sealed class TextPart() : Part(PartKind.Text) -{ - /// - /// Gets or sets the text content. - /// - [JsonPropertyName("text")] - [JsonRequired] - public string Text { get; set; } = string.Empty; +using System.Text.Json.Serialization; + +namespace A2A.V0_3; + +/// +/// Represents a text segment within parts. +/// +public sealed class TextPart() : Part(PartKind.Text) +{ + /// + /// Gets or sets the text content. + /// + [JsonPropertyName("text")] + [JsonRequired] + public string Text { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/A2A/Server/ITaskManager.cs b/src/A2A.V0_3/Server/ITaskManager.cs similarity index 98% rename from src/A2A/Server/ITaskManager.cs rename to src/A2A.V0_3/Server/ITaskManager.cs index 0cf0910c..11e4ee57 100644 --- a/src/A2A/Server/ITaskManager.cs +++ b/src/A2A.V0_3/Server/ITaskManager.cs @@ -1,179 +1,179 @@ -namespace A2A; - -/// -/// Interface for managing agent tasks and their lifecycle. -/// -/// -/// Responsible for retrieving, saving, and updating Task objects based on events received from the agent. -/// -public interface ITaskManager -{ - /// - /// Gets or sets the handler for when a message is received. - /// - /// - /// The handler needs to return a or an . - /// - /// For more details about choosing or refer to: - /// . - /// - /// - Func>? OnMessageReceived { get; set; } - - /// - /// Gets or sets the handler for when a task is created. - /// - /// - /// Called after a new task object is created and persisted. - /// - Func OnTaskCreated { get; set; } - - /// - /// Gets or sets the handler for when a task is cancelled. - /// - /// - /// Called after a task's status is updated to Canceled. - /// - Func OnTaskCancelled { get; set; } - - /// - /// Gets or sets the handler for when a task is updated. - /// - /// - /// Called after an existing task's history or status is modified. - /// - Func OnTaskUpdated { get; set; } - - /// - /// Gets or sets the handler for when an agent card is queried. - /// - /// - /// Returns agent capability information for a given agent URL. - /// - Func> OnAgentCardQuery { get; set; } - - /// - /// Creates a new agent task with a unique ID and initial status. - /// - /// - /// The task is immediately persisted to the task store. - /// - /// - /// Optional context ID for the task. If null, a new GUID is generated. - /// - /// - /// Optional task ID for the task. If null, a new GUID is generated. - /// - /// A cancellation token that can be used to cancel the operation. - /// - /// The created with status and unique identifiers. - /// - Task CreateTaskAsync(string? contextId = null, string? taskId = null, CancellationToken cancellationToken = default); - - /// - /// Adds an artifact to a task and notifies any active event streams. - /// - /// - /// The artifact is appended to the task's artifacts collection and persisted. - /// - /// The ID of the task to add the artifact to. - /// The artifact to add to the task. - /// A cancellation token that can be used to cancel the operation. - /// A task representing the asynchronous operation. - Task ReturnArtifactAsync(string taskId, Artifact artifact, CancellationToken cancellationToken = default); - - /// - /// Updates the status of a task and optionally adds a message to its history. - /// - /// - /// Notifies any active event streams about the status change. - /// - /// The ID of the task to update. - /// The new task status to set. - /// Optional message to add to the task history along with the status update. - /// Whether this is a final status update that should close any active streams. - /// A cancellation token that can be used to cancel the operation. - /// A task representing the asynchronous operation. - Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, bool final = false, CancellationToken cancellationToken = default); - - /// - /// Cancels a task by setting its status to Canceled and invoking the cancellation handler. - /// - /// - /// Retrieves the task from the store, updates its status, and notifies the cancellation handler. - /// It fails if the task has already been cancelled. - /// - /// Parameters containing the task ID to cancel. - /// A cancellation token that can be used to cancel the operation. - /// The canceled task with updated status, or null if not found. - Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); - - /// - /// Retrieves a task by its ID from the task store. - /// - /// - /// Looks up the task in the persistent store and returns the current state and history. - /// - /// Parameters containing the task ID to retrieve. - /// A cancellation token that can be used to cancel the operation. - /// The task if found in the store, null otherwise. - Task GetTaskAsync(TaskQueryParams taskIdParams, CancellationToken cancellationToken = default); - - /// - /// Processes a message request and returns a response, either from an existing task or by creating a new one. - /// - /// - /// If the message contains a task ID, it updates the existing task's history. If no task ID is provided, - /// it either delegates to the OnMessageReceived handler or creates a new task. - /// - /// The message parameters containing the message content and optional task/context IDs. - /// A cancellation token that can be used to cancel the operation. - /// The agent's response as either or a direct from the handler. - Task SendMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken = default); - - /// - /// Processes a message request and returns a stream of events as they occur. - /// - /// - /// Creates or updates a task and establishes an event stream that yields , , - /// TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects as they are generated. - /// - /// The message parameters containing the message content and optional task/context IDs. - /// A cancellation token that can be used to cancel the operation. - /// An async enumerable that yields events as they are produced by the agent. - IAsyncEnumerable SendMessageStreamingAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken = default); - - /// - /// Resubscribes to an existing task's event stream to receive ongoing updates. - /// - /// - /// Returns the event enumerator that was previously established for the task, - /// allowing clients to reconnect to an active task stream. - /// - /// Parameters containing the task ID to resubscribe to. - /// A cancellation token that can be used to cancel the operation. - /// An async enumerable of events for the specified task. - IAsyncEnumerable SubscribeToTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); - - /// - /// Sets or updates the push notification configuration for a specific task. - /// - /// - /// Configures callback URLs and authentication for receiving task updates via HTTP notifications. - /// - /// The push notification configuration containing callback URL and authentication details. - /// A cancellation token that can be used to cancel the operation. - /// The configured push notification settings with confirmation. - Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); - - /// - /// Retrieves the push notification configuration for a specific task. - /// - /// - /// Returns the callback URL and authentication settings configured for receiving task update notifications. - /// - /// Parameters containing the task ID and optional push notification config ID. - /// A cancellation token that can be used to cancel the operation. - /// The push notification configuration if found, null otherwise. - Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams? notificationConfigParams, CancellationToken cancellationToken = default); -} +namespace A2A.V0_3; + +/// +/// Interface for managing agent tasks and their lifecycle. +/// +/// +/// Responsible for retrieving, saving, and updating Task objects based on events received from the agent. +/// +public interface ITaskManager +{ + /// + /// Gets or sets the handler for when a message is received. + /// + /// + /// The handler needs to return a or an . + /// + /// For more details about choosing or refer to: + /// . + /// + /// + Func>? OnMessageReceived { get; set; } + + /// + /// Gets or sets the handler for when a task is created. + /// + /// + /// Called after a new task object is created and persisted. + /// + Func OnTaskCreated { get; set; } + + /// + /// Gets or sets the handler for when a task is cancelled. + /// + /// + /// Called after a task's status is updated to Canceled. + /// + Func OnTaskCancelled { get; set; } + + /// + /// Gets or sets the handler for when a task is updated. + /// + /// + /// Called after an existing task's history or status is modified. + /// + Func OnTaskUpdated { get; set; } + + /// + /// Gets or sets the handler for when an agent card is queried. + /// + /// + /// Returns agent capability information for a given agent URL. + /// + Func> OnAgentCardQuery { get; set; } + + /// + /// Creates a new agent task with a unique ID and initial status. + /// + /// + /// The task is immediately persisted to the task store. + /// + /// + /// Optional context ID for the task. If null, a new GUID is generated. + /// + /// + /// Optional task ID for the task. If null, a new GUID is generated. + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// The created with status and unique identifiers. + /// + Task CreateTaskAsync(string? contextId = null, string? taskId = null, CancellationToken cancellationToken = default); + + /// + /// Adds an artifact to a task and notifies any active event streams. + /// + /// + /// The artifact is appended to the task's artifacts collection and persisted. + /// + /// The ID of the task to add the artifact to. + /// The artifact to add to the task. + /// A cancellation token that can be used to cancel the operation. + /// A task representing the asynchronous operation. + Task ReturnArtifactAsync(string taskId, Artifact artifact, CancellationToken cancellationToken = default); + + /// + /// Updates the status of a task and optionally adds a message to its history. + /// + /// + /// Notifies any active event streams about the status change. + /// + /// The ID of the task to update. + /// The new task status to set. + /// Optional message to add to the task history along with the status update. + /// Whether this is a final status update that should close any active streams. + /// A cancellation token that can be used to cancel the operation. + /// A task representing the asynchronous operation. + Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, bool final = false, CancellationToken cancellationToken = default); + + /// + /// Cancels a task by setting its status to Canceled and invoking the cancellation handler. + /// + /// + /// Retrieves the task from the store, updates its status, and notifies the cancellation handler. + /// It fails if the task has already been cancelled. + /// + /// Parameters containing the task ID to cancel. + /// A cancellation token that can be used to cancel the operation. + /// The canceled task with updated status, or null if not found. + Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); + + /// + /// Retrieves a task by its ID from the task store. + /// + /// + /// Looks up the task in the persistent store and returns the current state and history. + /// + /// Parameters containing the task ID to retrieve. + /// A cancellation token that can be used to cancel the operation. + /// The task if found in the store, null otherwise. + Task GetTaskAsync(TaskQueryParams taskIdParams, CancellationToken cancellationToken = default); + + /// + /// Processes a message request and returns a response, either from an existing task or by creating a new one. + /// + /// + /// If the message contains a task ID, it updates the existing task's history. If no task ID is provided, + /// it either delegates to the OnMessageReceived handler or creates a new task. + /// + /// The message parameters containing the message content and optional task/context IDs. + /// A cancellation token that can be used to cancel the operation. + /// The agent's response as either or a direct from the handler. + Task SendMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken = default); + + /// + /// Processes a message request and returns a stream of events as they occur. + /// + /// + /// Creates or updates a task and establishes an event stream that yields , , + /// TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects as they are generated. + /// + /// The message parameters containing the message content and optional task/context IDs. + /// A cancellation token that can be used to cancel the operation. + /// An async enumerable that yields events as they are produced by the agent. + IAsyncEnumerable SendMessageStreamingAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken = default); + + /// + /// Resubscribes to an existing task's event stream to receive ongoing updates. + /// + /// + /// Returns the event enumerator that was previously established for the task, + /// allowing clients to reconnect to an active task stream. + /// + /// Parameters containing the task ID to resubscribe to. + /// A cancellation token that can be used to cancel the operation. + /// An async enumerable of events for the specified task. + IAsyncEnumerable SubscribeToTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); + + /// + /// Sets or updates the push notification configuration for a specific task. + /// + /// + /// Configures callback URLs and authentication for receiving task updates via HTTP notifications. + /// + /// The push notification configuration containing callback URL and authentication details. + /// A cancellation token that can be used to cancel the operation. + /// The configured push notification settings with confirmation. + Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); + + /// + /// Retrieves the push notification configuration for a specific task. + /// + /// + /// Returns the callback URL and authentication settings configured for receiving task update notifications. + /// + /// Parameters containing the task ID and optional push notification config ID. + /// A cancellation token that can be used to cancel the operation. + /// The push notification configuration if found, null otherwise. + Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams? notificationConfigParams, CancellationToken cancellationToken = default); +} diff --git a/src/A2A.V0_3/Server/ITaskStore.cs b/src/A2A.V0_3/Server/ITaskStore.cs new file mode 100644 index 00000000..76ff4849 --- /dev/null +++ b/src/A2A.V0_3/Server/ITaskStore.cs @@ -0,0 +1,58 @@ +namespace A2A.V0_3; + +/// +/// Interface for storing and retrieving agent tasks. +/// +public interface ITaskStore +{ + /// + /// Retrieves a task by its ID. + /// + /// The ID of the task to retrieve. + /// A cancellation token that can be used to cancel the operation. + /// The task if found, null otherwise. + Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); + + /// + /// Retrieves push notification configuration for a task. + /// + /// The ID of the task. + /// The ID of the push notification configuration. + /// A cancellation token that can be used to cancel the operation. + /// The push notification configuration if found, null otherwise. + Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default); + + /// + /// Updates the status of a task. + /// + /// The ID of the task. + /// The new status. + /// Optional message associated with the status. + /// A cancellation token that can be used to cancel the operation. + /// The updated task status. + Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default); + + /// + /// Stores or updates a task. + /// + /// The task to store. + /// A cancellation token that can be used to cancel the operation. + /// A task representing the operation. + Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default); + + /// + /// Stores push notification configuration for a task. + /// + /// The push notification configuration. + /// A cancellation token that can be used to cancel the operation. + /// A task representing the operation. + Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); + + /// + /// Retrieves push notifications for a task. + /// + /// The ID of the task. + /// A cancellation token that can be used to cancel the operation. + /// A list of push notification configurations for the task. + Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/A2A.V0_3/Server/InMemoryTaskStore.cs b/src/A2A.V0_3/Server/InMemoryTaskStore.cs new file mode 100644 index 00000000..88a69fce --- /dev/null +++ b/src/A2A.V0_3/Server/InMemoryTaskStore.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; + +namespace A2A.V0_3; + +/// +/// In-memory implementation of task store for development and testing. +/// +public sealed class InMemoryTaskStore : ITaskStore +{ + private readonly ConcurrentDictionary _taskCache = []; + // PushNotificationConfig.Id is optional, so there can be multiple configs with no Id. + // Since we want to maintain order of insertion and thread safety, we use a ConcurrentQueue. + private readonly ConcurrentDictionary> _pushNotificationCache = []; + + /// + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return string.IsNullOrEmpty(taskId) + ? Task.FromException(new ArgumentNullException(nameof(taskId))) + : Task.FromResult(_taskCache.TryGetValue(taskId, out var task) ? task : null); + } + + /// + public Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (string.IsNullOrEmpty(taskId)) + { + return Task.FromException(new ArgumentNullException(nameof(taskId))); + } + + if (!_pushNotificationCache.TryGetValue(taskId, out var pushNotificationConfigs)) + { + return Task.FromResult(null); + } + + var pushNotificationConfig = pushNotificationConfigs.FirstOrDefault(config => config.PushNotificationConfig.Id == notificationConfigId); + + return Task.FromResult(pushNotificationConfig); + } + + /// + public Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (string.IsNullOrEmpty(taskId)) + { + return Task.FromException(new A2AException("Invalid task ID", new ArgumentNullException(nameof(taskId)), A2AErrorCode.InvalidParams)); + } + + if (!_taskCache.TryGetValue(taskId, out var task)) + { + return Task.FromException(new A2AException("Task not found.", A2AErrorCode.TaskNotFound)); + } + + return Task.FromResult(task.Status = task.Status with + { + Message = message, + State = status, + Timestamp = DateTimeOffset.UtcNow + }); + } + + /// + public Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (task is null) + { + return Task.FromException(new ArgumentNullException(nameof(task))); + } + + if (string.IsNullOrEmpty(task.Id)) + { + return Task.FromException(new A2AException("Invalid task ID", A2AErrorCode.InvalidParams)); + } + + _taskCache[task.Id] = task; + return Task.CompletedTask; + } + + /// + public Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (pushNotificationConfig is null) + { + return Task.FromException(new ArgumentNullException(nameof(pushNotificationConfig))); + } + + var pushNotificationConfigs = _pushNotificationCache.GetOrAdd(pushNotificationConfig.TaskId, _ => []); + pushNotificationConfigs.Enqueue(pushNotificationConfig); + + return Task.CompletedTask; + } + + /// + public Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled>(cancellationToken); + } + + if (!_pushNotificationCache.TryGetValue(taskId, out var pushNotificationConfigs)) + { + return Task.FromResult>([]); + } + + return Task.FromResult>(pushNotificationConfigs); + } +} \ No newline at end of file diff --git a/src/A2A/Server/TaskManager.cs b/src/A2A.V0_3/Server/TaskManager.cs similarity index 96% rename from src/A2A/Server/TaskManager.cs rename to src/A2A.V0_3/Server/TaskManager.cs index 47d2099c..02ad7dd5 100644 --- a/src/A2A/Server/TaskManager.cs +++ b/src/A2A.V0_3/Server/TaskManager.cs @@ -1,9 +1,9 @@ -using A2A.Extensions; +using A2A.V0_3.Extensions; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.CompilerServices; -namespace A2A; +namespace A2A.V0_3; /// /// Implementation of task manager for handling agent tasks and their lifecycle. @@ -110,6 +110,9 @@ public async Task CreateTaskAsync(string? contextId = null, string? t } await _taskStore.UpdateStatusAsync(task.Id, TaskState.Canceled, cancellationToken: cancellationToken).ConfigureAwait(false); + task = await _taskStore.GetTaskAsync(task.Id, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException("Task not found after cancellation.", A2AErrorCode.TaskNotFound); + await OnTaskCancelled(task, cancellationToken).ConfigureAwait(false); return task; } @@ -356,7 +359,7 @@ public IAsyncEnumerable SubscribeToTaskAsync(TaskIdParams taskIdParams return pushNotificationConfig; } - /// + /// public async Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, bool final = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/A2A.V0_3/Server/TaskUpdateEventEnumerator.cs b/src/A2A.V0_3/Server/TaskUpdateEventEnumerator.cs new file mode 100644 index 00000000..968921bc --- /dev/null +++ b/src/A2A.V0_3/Server/TaskUpdateEventEnumerator.cs @@ -0,0 +1,68 @@ +using System.Threading.Channels; + +namespace A2A.V0_3; + +/// +/// Enumerator for streaming task update events to clients. +/// +public sealed class TaskUpdateEventEnumerator : IAsyncEnumerable, IDisposable, IAsyncDisposable +{ + private readonly Channel _channel = Channel.CreateUnbounded(); + + /// + /// Gets or sets the processing task to prevent garbage collection. + /// + public Task? ProcessingTask { get; set; } + + /// + /// Notifies of a new event in the task stream. + /// + /// The event to notify. + public void NotifyEvent(A2AEvent taskUpdateEvent) + { + if (taskUpdateEvent is null) + { + throw new ArgumentNullException(nameof(taskUpdateEvent)); + } + + if (!_channel.Writer.TryWrite(taskUpdateEvent)) + { + throw new InvalidOperationException("Unable to write to the event channel."); + } + } + + /// + /// Notifies of the final event in the task stream. + /// + /// The final event to notify. + public void NotifyFinalEvent(A2AEvent taskUpdateEvent) + { + if (taskUpdateEvent is null) + { + throw new ArgumentNullException(nameof(taskUpdateEvent)); + } + + if (!_channel.Writer.TryWrite(taskUpdateEvent)) + { + throw new InvalidOperationException("Unable to write to the event channel."); + } + + _channel.Writer.TryComplete(); + } + + /// + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => _channel.Reader.ReadAllAsync(cancellationToken).GetAsyncEnumerator(cancellationToken); + + /// + public void Dispose() + { + _channel.Writer.TryComplete(); + } + + /// + public ValueTask DisposeAsync() + { + this.Dispose(); + return default; + } +} \ No newline at end of file diff --git a/src/A2A/A2A.csproj b/src/A2A/A2A.csproj index d898c143..66360106 100644 --- a/src/A2A/A2A.csproj +++ b/src/A2A/A2A.csproj @@ -1,7 +1,8 @@  - net9.0;net8.0;netstandard2.0 + net10.0;net8.0 + true A2A @@ -11,17 +12,8 @@ true - - true - - - - - - - - - + + @@ -35,14 +27,18 @@ - - all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/A2A/A2AErrorCode.cs b/src/A2A/A2AErrorCode.cs index b4528e46..188aa523 100644 --- a/src/A2A/A2AErrorCode.cs +++ b/src/A2A/A2AErrorCode.cs @@ -30,6 +30,26 @@ public enum A2AErrorCode /// ContentTypeNotSupported = -32005, + /// + /// Invalid agent response - The agent returned an invalid response. + /// + InvalidAgentResponse = -32006, + + /// + /// Extended agent card not configured - The extended agent card feature is not configured. + /// + ExtendedAgentCardNotConfigured = -32007, + + /// + /// Extension support required - The requested extension is required but not supported. + /// + ExtensionSupportRequired = -32008, + + /// + /// Version not supported - The requested protocol version is not supported. + /// + VersionNotSupported = -32009, + /// /// Invalid request - The JSON is not a valid Request object. /// diff --git a/src/A2A/A2AJsonUtilities.cs b/src/A2A/A2AJsonUtilities.cs index 6999ba14..6ff6dfbb 100644 --- a/src/A2A/A2AJsonUtilities.cs +++ b/src/A2A/A2AJsonUtilities.cs @@ -36,16 +36,12 @@ public static partial class A2AJsonUtilities // Clone source-generated options so we can customize var opts = new JsonSerializerOptions(JsonContext.Default.Options) { - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // optional: keep '+' unescaped + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; // Chain with all supported types from MEAI. opts.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); - // Register custom converters at options-level (not attributes) - opts.Converters.Add(new A2AJsonConverter()); - opts.Converters.Add(new FileContent.Converter()); - opts.MakeReadOnly(); return opts; }); @@ -63,19 +59,69 @@ public static partial class A2AJsonUtilities [JsonSerializable(typeof(JsonRpcResponse))] [JsonSerializable(typeof(Dictionary))] - // A2A - [JsonSerializable(typeof(A2AEvent))] - [JsonSerializable(typeof(A2AResponse))] - [JsonSerializable(typeof(AgentCard))] + // Core types [JsonSerializable(typeof(AgentTask))] - [JsonSerializable(typeof(GetTaskPushNotificationConfigParams))] - [JsonSerializable(typeof(List))] - [JsonSerializable(typeof(MessageSendParams))] - [JsonSerializable(typeof(PushNotificationAuthenticationInfo))] + [JsonSerializable(typeof(Message))] + [JsonSerializable(typeof(Part))] + [JsonSerializable(typeof(Artifact))] + [JsonSerializable(typeof(TaskStatus))] + [JsonSerializable(typeof(TaskState))] + [JsonSerializable(typeof(Role))] + + // Event types + [JsonSerializable(typeof(TaskStatusUpdateEvent))] + [JsonSerializable(typeof(TaskArtifactUpdateEvent))] + + // Response types + [JsonSerializable(typeof(SendMessageResponse))] + [JsonSerializable(typeof(StreamResponse))] + [JsonSerializable(typeof(ListTasksResponse))] + [JsonSerializable(typeof(ListTaskPushNotificationConfigResponse))] + + // Agent discovery + [JsonSerializable(typeof(AgentCard))] + [JsonSerializable(typeof(AgentInterface))] + [JsonSerializable(typeof(AgentCapabilities))] + [JsonSerializable(typeof(AgentProvider))] + [JsonSerializable(typeof(AgentSkill))] + [JsonSerializable(typeof(AgentExtension))] + [JsonSerializable(typeof(AgentCardSignature))] + + // Security + [JsonSerializable(typeof(SecurityScheme))] + [JsonSerializable(typeof(ApiKeySecurityScheme))] + [JsonSerializable(typeof(HttpAuthSecurityScheme))] + [JsonSerializable(typeof(OAuth2SecurityScheme))] + [JsonSerializable(typeof(OpenIdConnectSecurityScheme))] + [JsonSerializable(typeof(MutualTlsSecurityScheme))] + [JsonSerializable(typeof(OAuthFlows))] + [JsonSerializable(typeof(AuthorizationCodeOAuthFlow))] + [JsonSerializable(typeof(ClientCredentialsOAuthFlow))] + [JsonSerializable(typeof(DeviceCodeOAuthFlow))] +#pragma warning disable CS0618 // Obsolete types + [JsonSerializable(typeof(ImplicitOAuthFlow))] + [JsonSerializable(typeof(PasswordOAuthFlow))] +#pragma warning restore CS0618 + [JsonSerializable(typeof(SecurityRequirement))] + [JsonSerializable(typeof(StringList))] + + // Request types + [JsonSerializable(typeof(SendMessageRequest))] + [JsonSerializable(typeof(SendMessageConfiguration))] + [JsonSerializable(typeof(GetTaskRequest))] + [JsonSerializable(typeof(ListTasksRequest))] + [JsonSerializable(typeof(CancelTaskRequest))] + [JsonSerializable(typeof(SubscribeToTaskRequest))] + [JsonSerializable(typeof(CreateTaskPushNotificationConfigRequest))] + [JsonSerializable(typeof(GetTaskPushNotificationConfigRequest))] + [JsonSerializable(typeof(ListTaskPushNotificationConfigRequest))] + [JsonSerializable(typeof(DeleteTaskPushNotificationConfigRequest))] + [JsonSerializable(typeof(GetExtendedAgentCardRequest))] + + // Push notification types [JsonSerializable(typeof(PushNotificationConfig))] - [JsonSerializable(typeof(TaskIdParams))] + [JsonSerializable(typeof(AuthenticationInfo))] [JsonSerializable(typeof(TaskPushNotificationConfig))] - [JsonSerializable(typeof(TaskQueryParams))] [ExcludeFromCodeCoverage] internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/A2A/Client/A2ACardResolver.cs b/src/A2A/Client/A2ACardResolver.cs index b8330d12..d098f879 100644 --- a/src/A2A/Client/A2ACardResolver.cs +++ b/src/A2A/Client/A2ACardResolver.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; using System.Net; using System.Text.Json; @@ -53,6 +54,9 @@ public async Task GetAgentCardAsync(CancellationToken cancellationTok { cancellationToken.ThrowIfCancellationRequested(); + using var activity = A2ADiagnostics.Source.StartActivity("A2ACardResolver.GetAgentCard", ActivityKind.Client); + activity?.SetTag("url.full", _agentCardPath.ToString()); + if (_logger.IsEnabled(LogLevel.Information)) { _logger.FetchingAgentCardFromUrl(_agentCardPath); @@ -64,27 +68,21 @@ public async Task GetAgentCardAsync(CancellationToken cancellationTok response.EnsureSuccessStatusCode(); - using var responseStream = await response.Content.ReadAsStreamAsync( -#if NET - cancellationToken -#endif - ).ConfigureAwait(false); + using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.AgentCard, cancellationToken).ConfigureAwait(false) ?? throw new A2AException("Failed to parse agent card JSON."); } catch (JsonException ex) { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); _logger.FailedToParseAgentCardJson(ex); throw new A2AException($"Failed to parse JSON: {ex.Message}"); } catch (HttpRequestException ex) { - HttpStatusCode statusCode = -#if NET - ex.StatusCode ?? -#endif - HttpStatusCode.InternalServerError; + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + HttpStatusCode statusCode = ex.StatusCode ?? HttpStatusCode.InternalServerError; _logger.HttpRequestFailedWithStatusCode(ex, statusCode); throw new A2AException("HTTP request failed", ex); diff --git a/src/A2A/Client/A2AClient.cs b/src/A2A/Client/A2AClient.cs index 7573f8d0..a1e0027b 100644 --- a/src/A2A/Client/A2AClient.cs +++ b/src/A2A/Client/A2AClient.cs @@ -1,202 +1,230 @@ -using System.Net.ServerSentEvents; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; - -namespace A2A; - -/// -/// Implementation of A2A client for communicating with agents. -/// -public sealed class A2AClient : IA2AClient -{ - internal static readonly HttpClient s_sharedClient = new(); - private readonly HttpClient _httpClient; - private readonly Uri _baseUri; - - /// - /// Initializes a new instance of . - /// - /// The base url of the agent's hosting service. - /// The HTTP client to use for requests. - public A2AClient(Uri baseUrl, HttpClient? httpClient = null) - { - if (baseUrl is null) - { - throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null."); - } - - _baseUri = baseUrl; - - _httpClient = httpClient ?? s_sharedClient; - } - - /// - public Task SendMessageAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) => - SendRpcRequestAsync( - taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)), - A2AMethods.MessageSend, - A2AJsonUtilities.JsonContext.Default.MessageSendParams, - A2AJsonUtilities.JsonContext.Default.A2AResponse, - cancellationToken); - - /// - public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) => - SendRpcRequestAsync( - new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId }, - A2AMethods.TaskGet, - A2AJsonUtilities.JsonContext.Default.TaskIdParams, - A2AJsonUtilities.JsonContext.Default.AgentTask, - cancellationToken); - - /// - public Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default) => - SendRpcRequestAsync( - taskIdParams ?? throw new ArgumentNullException(nameof(taskIdParams)), - A2AMethods.TaskCancel, - A2AJsonUtilities.JsonContext.Default.TaskIdParams, - A2AJsonUtilities.JsonContext.Default.AgentTask, - cancellationToken); - - /// - public Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) => - SendRpcRequestAsync( - pushNotificationConfig ?? throw new ArgumentNullException(nameof(pushNotificationConfig)), - A2AMethods.TaskPushNotificationConfigSet, - A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, - A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, - cancellationToken); - - /// - public Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams notificationConfigParams, CancellationToken cancellationToken = default) => - SendRpcRequestAsync( - notificationConfigParams ?? throw new ArgumentNullException(nameof(notificationConfigParams)), - A2AMethods.TaskPushNotificationConfigGet, - A2AJsonUtilities.JsonContext.Default.GetTaskPushNotificationConfigParams, - A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig, - cancellationToken); - - /// - public IAsyncEnumerable> SendMessageStreamingAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) => - SendRpcSseRequestAsync( - taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)), - A2AMethods.MessageStream, - A2AJsonUtilities.JsonContext.Default.MessageSendParams, - A2AJsonUtilities.JsonContext.Default.A2AEvent, - cancellationToken); - - /// - public IAsyncEnumerable> SubscribeToTaskAsync(string taskId, CancellationToken cancellationToken = default) => - SendRpcSseRequestAsync( - new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId }, - A2AMethods.TaskSubscribe, - A2AJsonUtilities.JsonContext.Default.TaskIdParams, - A2AJsonUtilities.JsonContext.Default.A2AEvent, - cancellationToken); - - private async Task SendRpcRequestAsync( - TInput jsonRpcParams, - string method, - JsonTypeInfo inputTypeInfo, - JsonTypeInfo outputTypeInfo, - CancellationToken cancellationToken) where TOutput : class - { - cancellationToken.ThrowIfCancellationRequested(); - - using var responseStream = await SendAndReadResponseStreamAsync( - jsonRpcParams, - method, - inputTypeInfo, - "application/json", - cancellationToken).ConfigureAwait(false); - - var responseObject = await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse, cancellationToken).ConfigureAwait(false); - - if (responseObject?.Error is { } error) - { - throw new A2AException(error.Message, (A2AErrorCode)error.Code); - } - - return responseObject?.Result?.Deserialize(outputTypeInfo) ?? - throw new InvalidOperationException("Response does not contain a result."); - } - - private async IAsyncEnumerable> SendRpcSseRequestAsync( - TInput jsonRpcParams, - string method, - JsonTypeInfo inputTypeInfo, - JsonTypeInfo outputTypeInfo, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - using var responseStream = await SendAndReadResponseStreamAsync( - jsonRpcParams, - method, - inputTypeInfo, - "text/event-stream", - cancellationToken).ConfigureAwait(false); - - var sseParser = SseParser.Create(responseStream, (_, data) => - { - var reader = new Utf8JsonReader(data); - - var responseObject = JsonSerializer.Deserialize(ref reader, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse); - - if (responseObject?.Error is { } error) - { - throw new A2AException(error.Message, (A2AErrorCode)error.Code); - } - - if (responseObject?.Result is null) - { - throw new InvalidOperationException("Failed to deserialize the event: Result is null."); - } - - return responseObject.Result.Deserialize(outputTypeInfo) ?? - throw new InvalidOperationException("Failed to deserialize the event."); - }); - - await foreach (var item in sseParser.EnumerateAsync(cancellationToken)) - { - yield return item; - } - } - - private async ValueTask SendAndReadResponseStreamAsync( - TInput jsonRpcParams, - string method, - JsonTypeInfo inputTypeInfo, - string expectedContentType, - CancellationToken cancellationToken) - { - var response = await _httpClient.SendAsync(new(HttpMethod.Post, _baseUri) - { - Content = new JsonRpcContent(new JsonRpcRequest() - { - Id = Guid.NewGuid().ToString(), - Method = method, - Params = JsonSerializer.SerializeToElement(jsonRpcParams, inputTypeInfo), - }) - }, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - try - { - response.EnsureSuccessStatusCode(); - - if (response.Content.Headers.ContentType?.MediaType != expectedContentType) - { - throw new InvalidOperationException($"Invalid content type. Expected '{expectedContentType}' but got '{response.Content.Headers.ContentType?.MediaType}'."); - } - - return await response.Content.ReadAsStreamAsync( -#if NET - cancellationToken -#endif - ).ConfigureAwait(false); - } - catch - { - response.Dispose(); - throw; - } - } +namespace A2A; + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; + +/// Client for communicating with an A2A agent via JSON-RPC over HTTP. +public sealed class A2AClient : IA2AClient, IDisposable +{ + internal static readonly HttpClient s_sharedClient = new(); + private readonly HttpClient _httpClient; + private readonly string _url; + + /// Initializes a new instance of the class. + /// The base url of the agent's hosting service. + /// The HTTP client to use for requests. + public A2AClient(Uri baseUrl, HttpClient? httpClient = null) + { + if (baseUrl is null) + { + throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null."); + } + + _url = baseUrl.ToString(); + _httpClient = httpClient ?? s_sharedClient; + } + + /// + public async Task SendMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default) + { + var rpcResponse = await SendJsonRpcRequestAsync(A2AMethods.SendMessage, request, cancellationToken).ConfigureAwait(false); + return rpcResponse; + } + + /// + public async IAsyncEnumerable SendStreamingMessageAsync(SendMessageRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var item in SendStreamingJsonRpcRequestAsync(A2AMethods.SendStreamingMessage, request, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + /// + public async Task GetTaskAsync(GetTaskRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.GetTask, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.ListTasks, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CancelTaskAsync(CancelTaskRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.CancelTask, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable SubscribeToTaskAsync(SubscribeToTaskRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var item in SendStreamingJsonRpcRequestAsync(A2AMethods.SubscribeToTask, request, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + /// + public async Task CreateTaskPushNotificationConfigAsync(CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.CreateTaskPushNotificationConfig, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetTaskPushNotificationConfigAsync(GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.GetTaskPushNotificationConfig, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task ListTaskPushNotificationConfigAsync(ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.ListTaskPushNotificationConfig, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task DeleteTaskPushNotificationConfigAsync(DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + await SendJsonRpcRequestAsync(A2AMethods.DeleteTaskPushNotificationConfig, request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetExtendedAgentCardAsync(GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default) + { + return await SendJsonRpcRequestAsync(A2AMethods.GetExtendedAgentCard, request, cancellationToken).ConfigureAwait(false); + } + + /// + /// No-op. The HttpClient is either shared or externally owned. + public void Dispose() + { + // HttpClient lifetime is managed externally or via the shared static instance. + GC.SuppressFinalize(this); + } + + [UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "All types are registered in source-generated JsonContext.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "All types are registered in source-generated JsonContext.")] + private async Task SendJsonRpcRequestAsync(string method, object? @params, CancellationToken cancellationToken) + { + using var activity = A2ADiagnostics.Source.StartActivity($"A2AClient/{method}", ActivityKind.Client); + var stopwatch = Stopwatch.StartNew(); + + var rpcRequest = new JsonRpcRequest + { + Method = method, + Id = new JsonRpcId(Guid.NewGuid().ToString()), + Params = @params is not null ? JsonSerializer.SerializeToElement(@params, A2AJsonUtilities.DefaultOptions) : null, + }; + + activity?.SetTag("rpc.system", "jsonrpc"); + activity?.SetTag("rpc.method", method); + activity?.SetTag("url.full", _url); + activity?.SetTag("rpc.jsonrpc.request_id", rpcRequest.Id.ToString()); + + try + { + A2ADiagnostics.ClientRequestCount.Add(1); + + using var content = new JsonRpcContent(rpcRequest); + using var response = await _httpClient.PostAsync(_url, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var rpcResponse = await JsonSerializer.DeserializeAsync(stream, A2AJsonUtilities.DefaultOptions, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException("Failed to deserialize JSON-RPC response.", A2AErrorCode.InternalError); + + if (rpcResponse.Error is { } error) + { + throw new A2AException(error.Message, (A2AErrorCode)error.Code); + } + + return rpcResponse.Result.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Failed to deserialize JSON-RPC result.", A2AErrorCode.InternalError); + } + catch (Exception ex) + { + A2ADiagnostics.ClientErrorCount.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + A2ADiagnostics.ClientRequestDuration.Record(stopwatch.Elapsed.TotalMilliseconds); + } + } + + [UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "All types are registered in source-generated JsonContext.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "All types are registered in source-generated JsonContext.")] + private async IAsyncEnumerable SendStreamingJsonRpcRequestAsync(string method, object? @params, [EnumeratorCancellation] CancellationToken cancellationToken) + { + using var activity = A2ADiagnostics.Source.StartActivity($"A2AClient/{method}", ActivityKind.Client); + A2ADiagnostics.ClientRequestCount.Add(1); + int eventCount = 0; + + var rpcRequest = new JsonRpcRequest + { + Method = method, + Id = new JsonRpcId(Guid.NewGuid().ToString()), + Params = @params is not null ? JsonSerializer.SerializeToElement(@params, A2AJsonUtilities.DefaultOptions) : null, + }; + + activity?.SetTag("rpc.system", "jsonrpc"); + activity?.SetTag("rpc.method", method); + activity?.SetTag("url.full", _url); + activity?.SetTag("rpc.jsonrpc.request_id", rpcRequest.Id.ToString()); + + HttpResponseMessage? response = null; + Stream? stream = null; + + try + { + using var content = new JsonRpcContent(rpcRequest); + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, _url) + { + Content = content, + }; + requestMessage.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); + + response = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + A2ADiagnostics.ClientErrorCount.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + response?.Dispose(); + throw; + } + + using (response) + using (stream) + { + await foreach (var sseItem in SseParser.Create(stream).EnumerateAsync(cancellationToken).ConfigureAwait(false)) + { + var rpcResponse = JsonSerializer.Deserialize(sseItem.Data, A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Failed to deserialize streaming JSON-RPC response.", A2AErrorCode.InternalError); + + if (rpcResponse.Error is { } error) + { + throw new A2AException(error.Message, (A2AErrorCode)error.Code); + } + + var result = rpcResponse.Result.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Failed to deserialize streaming JSON-RPC result.", A2AErrorCode.InternalError); + + eventCount++; + yield return result; + } + } + + A2ADiagnostics.ClientStreamEventCount.Record(eventCount); + } } \ No newline at end of file diff --git a/src/A2A/Client/A2AClientExtensions.cs b/src/A2A/Client/A2AClientExtensions.cs index 760a15a5..6d7fde41 100644 --- a/src/A2A/Client/A2AClientExtensions.cs +++ b/src/A2A/Client/A2AClientExtensions.cs @@ -1,132 +1,61 @@ -using System.Net.ServerSentEvents; -using System.Text.Json; - namespace A2A; -/// -/// Extension methods for the class making its API more -/// convenient for certain use-cases. -/// +/// Provides extension methods for . public static class A2AClientExtensions { - /// - public static Task SendMessageAsync( - this A2AClient client, - AgentMessage message, - MessageSendConfiguration? configuration = null, - Dictionary? metadata = null, + /// Sends a text message to the agent and returns the response. + /// The A2A client. + /// The text message to send. + /// The role of the message sender. + /// An optional context identifier. + /// A cancellation token. + /// The send message response. + public static Task SendMessageAsync( + this IA2AClient client, + string text, + Role role = Role.User, + string? contextId = null, CancellationToken cancellationToken = default) { - if (client is null) + var request = new SendMessageRequest { - throw new ArgumentNullException(nameof(client)); - } - - return client.SendMessageAsync( - new MessageSendParams + Message = new Message { - Message = message, - Configuration = configuration, - Metadata = metadata + Role = role, + Parts = [Part.FromText(text)], + ContextId = contextId, + MessageId = Guid.NewGuid().ToString("N"), }, - cancellationToken); - } - - /// - public static Task CancelTaskAsync( - this A2AClient client, - string taskId, - Dictionary? metadata = null, - CancellationToken cancellationToken = default) - { - if (client is null) - { - throw new ArgumentNullException(nameof(client)); - } - - return client.CancelTaskAsync( - new TaskIdParams - { - Id = taskId, - Metadata = metadata - }, - cancellationToken); - } - - /// - public static Task SetPushNotificationAsync( - this A2AClient client, - string taskId, - string url, - string? configId = null, - string? token = null, - PushNotificationAuthenticationInfo? authentication = null, - CancellationToken cancellationToken = default) - { - if (client is null) - { - throw new ArgumentNullException(nameof(client)); - } + }; - return client.SetPushNotificationAsync( - new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Id = configId, - Url = url, - Token = token, - Authentication = authentication - } - }, - cancellationToken); + return client.SendMessageAsync(request, cancellationToken); } - /// - public static Task GetPushNotificationAsync( - this A2AClient client, - string taskId, - string configId, - Dictionary? metadata = null, + /// Sends a streaming text message to the agent. + /// The A2A client. + /// The text message to send. + /// The role of the message sender. + /// An optional context identifier. + /// A cancellation token. + /// An async enumerable of streaming response events. + public static IAsyncEnumerable SendStreamingMessageAsync( + this IA2AClient client, + string text, + Role role = Role.User, + string? contextId = null, CancellationToken cancellationToken = default) { - if (client is null) + var request = new SendMessageRequest { - throw new ArgumentNullException(nameof(client)); - } - - return client.GetPushNotificationAsync( - new GetTaskPushNotificationConfigParams + Message = new Message { - Id = taskId, - PushNotificationConfigId = configId, - Metadata = metadata + Role = role, + Parts = [Part.FromText(text)], + ContextId = contextId, + MessageId = Guid.NewGuid().ToString("N"), }, - cancellationToken); - } + }; - /// - public static IAsyncEnumerable> SendMessageStreamingAsync( - this A2AClient client, - AgentMessage message, - MessageSendConfiguration? configuration = null, - Dictionary? metadata = null, - CancellationToken cancellationToken = default) - - { - if (client is null) - { - throw new ArgumentNullException(nameof(client)); - } - - return client.SendMessageStreamingAsync( - new MessageSendParams - { - Message = message, - Configuration = configuration, - Metadata = metadata - }, - cancellationToken); + return client.SendStreamingMessageAsync(request, cancellationToken); } } diff --git a/src/A2A/Client/IA2AClient.cs b/src/A2A/Client/IA2AClient.cs index eedc3c22..2ee87869 100644 --- a/src/A2A/Client/IA2AClient.cs +++ b/src/A2A/Client/IA2AClient.cs @@ -1,68 +1,71 @@ -using System.Net.ServerSentEvents; - namespace A2A; -/// -/// Interface for A2A client operations for interacting with an A2A agent. -/// +/// Defines the client interface for communicating with an A2A agent. public interface IA2AClient { - /// - /// Sends a non-streaming message request to the agent. - /// - /// The message parameters containing the message and configuration. - /// A cancellation token to cancel the operation. - /// The agent's response containing a Task or Message. - Task SendMessageAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default); + /// Sends a message to the agent. + /// The send message request. + /// A cancellation token. + /// The send message response. + Task SendMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default); + + /// Sends a streaming message to the agent. + /// The send message request. + /// A cancellation token. + /// An asynchronous enumerable of streaming responses. + IAsyncEnumerable SendStreamingMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default); + + /// Gets a task by ID. + /// The get task request. + /// A cancellation token. + /// The agent task. + Task GetTaskAsync(GetTaskRequest request, CancellationToken cancellationToken = default); + + /// Lists tasks with pagination. + /// The list tasks request. + /// A cancellation token. + /// The list tasks response. + Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default); + + /// Cancels a task. + /// The cancel task request. + /// A cancellation token. + /// The canceled agent task. + Task CancelTaskAsync(CancelTaskRequest request, CancellationToken cancellationToken = default); - /// - /// Retrieves the current state and history of a specific task. - /// - /// The ID of the task to retrieve. - /// A cancellation token to cancel the operation. - /// The requested task with its current state and history. - Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); + /// Subscribes to task updates. + /// The subscribe to task request. + /// A cancellation token. + /// An asynchronous enumerable of streaming responses. + IAsyncEnumerable SubscribeToTaskAsync(SubscribeToTaskRequest request, CancellationToken cancellationToken = default); - /// - /// Requests the agent to cancel a specific task. - /// - /// Parameters containing the task ID to cancel. - /// A cancellation token to cancel the operation. - /// The updated task with canceled status. - Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default); + /// Creates a push notification configuration. + /// The create push notification config request. + /// A cancellation token. + /// The created push notification configuration. + Task CreateTaskPushNotificationConfigAsync(CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); - /// - /// Sends a streaming message request to the agent and yields responses as they arrive. - /// - /// - /// This method uses Server-Sent Events (SSE) to receive a stream of updates from the agent. - /// - /// The message parameters containing the message and configuration. - /// A cancellation token to cancel the operation. - /// An async enumerable of server-sent events containing Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent. - IAsyncEnumerable> SendMessageStreamingAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default); + /// Gets a push notification configuration. + /// The get push notification config request. + /// A cancellation token. + /// The push notification configuration. + Task GetTaskPushNotificationConfigAsync(GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); - /// - /// Subscribes to a task's event stream to receive ongoing updates. - /// - /// The ID of the task to subscribe to. - /// A cancellation token to cancel the operation. - /// An async enumerable of server-sent events containing task updates. - IAsyncEnumerable> SubscribeToTaskAsync(string taskId, CancellationToken cancellationToken = default); + /// Lists push notification configurations. + /// The list push notification configs request. + /// A cancellation token. + /// The list push notification config response. + Task ListTaskPushNotificationConfigAsync(ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); - /// - /// Sets or updates the push notification configuration for a specific task. - /// - /// The push notification configuration to set. - /// A cancellation token to cancel the operation. - /// The configured push notification settings with confirmation. - Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); + /// Deletes a push notification configuration. + /// The delete push notification config request. + /// A cancellation token. + /// A task representing the asynchronous operation. + Task DeleteTaskPushNotificationConfigAsync(DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); - /// - /// Retrieves the push notification configuration for a specific task. - /// - /// Parameters containing the task ID and optional push notification config ID. - /// A cancellation token to cancel the operation. - /// The push notification configuration for the specified task. - Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams notificationConfigParams, CancellationToken cancellationToken = default); + /// Gets the extended agent card. + /// The get extended agent card request. + /// A cancellation token. + /// The extended agent card. + Task GetExtendedAgentCardAsync(GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default); } diff --git a/src/A2A/Client/JsonRpcContent.cs b/src/A2A/Client/JsonRpcContent.cs index 30fad8a7..9b7a9b53 100644 --- a/src/A2A/Client/JsonRpcContent.cs +++ b/src/A2A/Client/JsonRpcContent.cs @@ -51,7 +51,6 @@ protected override bool TryComputeLength(out long length) protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => JsonSerializer.SerializeAsync(stream, _contentToSerialize, _contentTypeInfo); -#if NET8_0_OR_GREATER /// protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => JsonSerializer.SerializeAsync(stream, _contentToSerialize, _contentTypeInfo, cancellationToken); @@ -59,5 +58,4 @@ protected override Task SerializeToStreamAsync(Stream stream, TransportContext? /// protected override void SerializeToStream(Stream stream, TransportContext? context, CancellationToken cancellationToken) => JsonSerializer.Serialize(stream, _contentToSerialize, _contentTypeInfo); -#endif } \ No newline at end of file diff --git a/src/A2A/Diagnostics/A2ADiagnostics.cs b/src/A2A/Diagnostics/A2ADiagnostics.cs new file mode 100644 index 00000000..75f34c10 --- /dev/null +++ b/src/A2A/Diagnostics/A2ADiagnostics.cs @@ -0,0 +1,60 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace A2A; + +/// +/// Centralized diagnostics for the A2A SDK. Follows Azure SDK conventions: +/// single ActivitySource and Meter per library, named after the NuGet package. +/// +internal static class A2ADiagnostics +{ + private static readonly string Version = + typeof(A2ADiagnostics).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + /// Activity source for all A2A core library spans. + internal static readonly ActivitySource Source = new("A2A", Version); + + /// Meter for all A2A core library metrics. + internal static readonly Meter Meter = new("A2A", Version); + + // ─── Metrics Instruments ─── + + internal static readonly Counter RequestCount = + Meter.CreateCounter("a2a.server.request.count", + description: "Number of A2A requests processed"); + + internal static readonly Histogram RequestDuration = + Meter.CreateHistogram("a2a.server.request.duration", + "ms", "Duration of A2A request processing"); + + internal static readonly Counter ErrorCount = + Meter.CreateCounter("a2a.server.error.count", + description: "Number of A2A errors"); + + internal static readonly Counter TaskCreatedCount = + Meter.CreateCounter("a2a.server.task.created", + description: "Number of tasks created"); + + internal static readonly Histogram StreamEventCount = + Meter.CreateHistogram("a2a.server.stream.event.count", + description: "Events per streaming request"); + + // ─── Client Metrics ─── + + internal static readonly Counter ClientRequestCount = + Meter.CreateCounter("a2a.client.request.count", + description: "Number of A2A client requests sent"); + + internal static readonly Histogram ClientRequestDuration = + Meter.CreateHistogram("a2a.client.request.duration", + "ms", "Duration of A2A client request processing"); + + internal static readonly Counter ClientErrorCount = + Meter.CreateCounter("a2a.client.error.count", + description: "Number of A2A client errors"); + + internal static readonly Histogram ClientStreamEventCount = + Meter.CreateHistogram("a2a.client.stream.event.count", + description: "Events per streaming client request"); +} diff --git a/src/A2A/Extensions/AIContentExtensions.cs b/src/A2A/Extensions/AIContentExtensions.cs index c77d956f..7eb83f38 100644 --- a/src/A2A/Extensions/AIContentExtensions.cs +++ b/src/A2A/Extensions/AIContentExtensions.cs @@ -12,56 +12,56 @@ namespace Microsoft.Extensions.AI; /// public static class AIContentExtensions { - /// Creates a from the A2A . - /// The agent message to convert to an . - /// The created to represent the . - /// is . - public static ChatMessage ToChatMessage(this AgentMessage agentMessage) + /// Creates a from the A2A . + /// The message to convert to an . + /// The created to represent the . + /// is . + public static ChatMessage ToChatMessage(this Message message) { - if (agentMessage is null) + if (message is null) { - throw new ArgumentNullException(nameof(agentMessage)); + throw new ArgumentNullException(nameof(message)); } return new() { - AdditionalProperties = agentMessage.Metadata.ToAdditionalProperties(), - Contents = agentMessage.Parts.ConvertAll(p => p.ToAIContent()), - MessageId = agentMessage.MessageId, - RawRepresentation = agentMessage, - Role = agentMessage.Role switch + AdditionalProperties = message.Metadata.ToAdditionalProperties(), + Contents = message.Parts.ConvertAll(p => p.ToAIContent()), + MessageId = message.MessageId, + RawRepresentation = message, + Role = message.Role switch { - MessageRole.Agent => ChatRole.Assistant, + Role.Agent => ChatRole.Assistant, _ => ChatRole.User, }, }; } - /// Creates an A2A from the Microsoft.Extensions.AI . - /// The chat message to convert to an . - /// The created to represent the . + /// Creates an A2A from the Microsoft.Extensions.AI . + /// The chat message to convert to an . + /// The created to represent the . /// is . /// - /// If the 's is already a , + /// If the 's is already a , /// that existing instance is returned. /// - public static AgentMessage ToAgentMessage(this ChatMessage chatMessage) + public static Message ToA2AMessage(this ChatMessage chatMessage) { if (chatMessage is null) { throw new ArgumentNullException(nameof(chatMessage)); } - if (chatMessage.RawRepresentation is AgentMessage existingAgentMessage) + if (chatMessage.RawRepresentation is Message existingMessage) { - return existingAgentMessage; + return existingMessage; } - return new AgentMessage + return new Message { MessageId = chatMessage.MessageId ?? Guid.NewGuid().ToString("N"), Parts = chatMessage.Contents.Select(ToPart).Where(p => p is not null).ToList()!, - Role = chatMessage.Role == ChatRole.Assistant ? MessageRole.Agent : MessageRole.User, + Role = chatMessage.Role == ChatRole.Assistant ? Role.Agent : Role.User, }; } @@ -77,31 +77,27 @@ public static AIContent ToAIContent(this Part part) } AIContent? content = null; - switch (part) - { - case TextPart textPart: - content = new TextContent(textPart.Text); - break; - - case FilePart { File: { } file }: - if (file.Uri is not null) - { - content = new UriContent(file.Uri, file.MimeType ?? "application/octet-stream"); - } - else if (file.Bytes is not null) - { - content = new DataContent(Convert.FromBase64String(file.Bytes), file.MimeType ?? "application/octet-stream") - { - Name = file.Name, - }; - } - break; - case DataPart dataPart: - content = new DataContent( - JsonSerializer.SerializeToUtf8Bytes(dataPart.Data, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(Dictionary))), - "application/json"); - break; + if (part.Text is not null) + { + content = new TextContent(part.Text); + } + else if (part.Url is not null) + { + content = new UriContent(part.Url, part.MediaType ?? "application/octet-stream"); + } + else if (part.Raw is not null) + { + content = new DataContent(part.Raw, part.MediaType ?? "application/octet-stream") + { + Name = part.Filename, + }; + } + else if (part.Data is { } data) + { + content = new DataContent( + JsonSerializer.SerializeToUtf8Bytes(data, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement))), + "application/json"); } content ??= new AIContent(); @@ -136,21 +132,15 @@ public static AIContent ToAIContent(this Part part) switch (content) { case TextContent textContent: - part = new TextPart { Text = textContent.Text }; + part = Part.FromText(textContent.Text); break; case UriContent uriContent: - part = new FilePart - { - File = new FileContent(uriContent.Uri) { MimeType = uriContent.MediaType }, - }; + part = Part.FromUrl(uriContent.Uri.ToString(), uriContent.MediaType); break; case DataContent dataContent: - part = new FilePart - { - File = new FileContent(dataContent.Base64Data.ToString()) { MimeType = dataContent.MediaType }, - }; + part = Part.FromRaw(dataContent.Data.ToArray(), dataContent.MediaType); break; } diff --git a/src/A2A/Extensions/TaskStateExtensions.cs b/src/A2A/Extensions/TaskStateExtensions.cs new file mode 100644 index 00000000..edc75119 --- /dev/null +++ b/src/A2A/Extensions/TaskStateExtensions.cs @@ -0,0 +1,18 @@ +namespace A2A; + +/// +/// Extension methods for . +/// +public static class TaskStateExtensions +{ + /// + /// Returns true if the state is terminal (completed, failed, canceled, rejected). + /// Terminal tasks cannot accept further messages per spec §3.1.1. + /// + /// The task state to check. + public static bool IsTerminal(this TaskState state) => + state is TaskState.Completed + or TaskState.Failed + or TaskState.Canceled + or TaskState.Rejected; +} diff --git a/src/A2A/JsonRpc/A2AMethods.cs b/src/A2A/JsonRpc/A2AMethods.cs index 904cd8c9..e81221f9 100644 --- a/src/A2A/JsonRpc/A2AMethods.cs +++ b/src/A2A/JsonRpc/A2AMethods.cs @@ -1,56 +1,66 @@ -namespace A2A; - -/// -/// Constants for A2A JSON-RPC method names. -/// -public static class A2AMethods -{ - /// - /// Method for sending messages to agents. - /// - public const string MessageSend = "message/send"; - - /// - /// Method for streaming messages from agents. - /// - public const string MessageStream = "message/stream"; - - /// - /// Method for retrieving task information. - /// - public const string TaskGet = "tasks/get"; - - /// - /// Method for canceling tasks. - /// - public const string TaskCancel = "tasks/cancel"; - - /// - /// Method for subscribing to task updates. - /// - public const string TaskSubscribe = "tasks/resubscribe"; - - /// - /// Method for setting push notification configuration. - /// - public const string TaskPushNotificationConfigSet = "tasks/pushNotificationConfig/set"; - - /// - /// Method for getting push notification configuration. - /// - public const string TaskPushNotificationConfigGet = "tasks/pushNotificationConfig/get"; - - /// - /// Determines if a method requires streaming response handling. - /// - /// The method name to check. - /// True if the method requires streaming, false otherwise. - public static bool IsStreamingMethod(string method) => method is MessageStream or TaskSubscribe; - - /// - /// Determines if a method name is valid for A2A JSON-RPC. - /// - /// The method name to validate. - /// True if the method is valid, false otherwise. - public static bool IsValidMethod(string method) => method is MessageSend or MessageStream or TaskGet or TaskCancel or TaskSubscribe or TaskPushNotificationConfigSet or TaskPushNotificationConfigGet; +namespace A2A; + +/// Defines the A2A protocol method name constants. +public static class A2AMethods +{ + /// Send a message to an agent. + public const string SendMessage = "SendMessage"; + + /// Send a streaming message to an agent. + public const string SendStreamingMessage = "SendStreamingMessage"; + + /// Get a task by ID. + public const string GetTask = "GetTask"; + + /// List tasks with pagination. + public const string ListTasks = "ListTasks"; + + /// Cancel a task. + public const string CancelTask = "CancelTask"; + + /// Subscribe to task updates. + public const string SubscribeToTask = "SubscribeToTask"; + + /// Create a push notification configuration. + public const string CreateTaskPushNotificationConfig = "CreateTaskPushNotificationConfig"; + + /// Get a push notification configuration. + public const string GetTaskPushNotificationConfig = "GetTaskPushNotificationConfig"; + + /// List push notification configurations. + public const string ListTaskPushNotificationConfig = "ListTaskPushNotificationConfig"; + + /// Delete a push notification configuration. + public const string DeleteTaskPushNotificationConfig = "DeleteTaskPushNotificationConfig"; + + /// Get extended agent card. + public const string GetExtendedAgentCard = "GetExtendedAgentCard"; + + /// + /// Determines if a method requires streaming response handling. + /// + /// The method name to check. + /// True if the method requires streaming, false otherwise. + public static bool IsStreamingMethod(string method) => method is SendStreamingMessage or SubscribeToTask; + + /// + /// Determines if a method is a push notification method. + /// + /// The method name to check. + /// True if the method is a push notification method, false otherwise. + public static bool IsPushNotificationMethod(string method) => method is CreateTaskPushNotificationConfig or GetTaskPushNotificationConfig or ListTaskPushNotificationConfig or DeleteTaskPushNotificationConfig; + + /// + /// Determines if a method name is valid for A2A JSON-RPC. + /// + /// The method name to validate. + /// True if the method is valid, false otherwise. + public static bool IsValidMethod(string method) => + method is SendMessage + or GetTask + or ListTasks + or CancelTask + or GetExtendedAgentCard || + IsStreamingMethod(method) || + IsPushNotificationMethod(method); } \ No newline at end of file diff --git a/src/A2A/Models/AgentCapabilities.cs b/src/A2A/Models/AgentCapabilities.cs index 4c410cca..59f2c641 100644 --- a/src/A2A/Models/AgentCapabilities.cs +++ b/src/A2A/Models/AgentCapabilities.cs @@ -1,33 +1,23 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Defines optional capabilities supported by an agent. -/// +using System.Text.Json.Serialization; + +/// Represents the capabilities of an agent. public sealed class AgentCapabilities { - /// - /// Gets or sets a value indicating whether the agent supports SSE. - /// + /// Gets or sets whether the agent supports streaming. [JsonPropertyName("streaming")] - public bool Streaming { get; set; } + public bool? Streaming { get; set; } - /// - /// Gets or sets a value indicating whether the agent can notify updates to client. - /// + /// Gets or sets whether the agent supports push notifications. [JsonPropertyName("pushNotifications")] - public bool PushNotifications { get; set; } - - /// - /// Gets or sets a value indicating whether the agent exposes status change history for tasks. - /// - [JsonPropertyName("stateTransitionHistory")] - public bool StateTransitionHistory { get; set; } + public bool? PushNotifications { get; set; } - /// - /// Extensions supported by this agent. - /// + /// Gets or sets the extensions supported by this agent. [JsonPropertyName("extensions")] - public List Extensions { get; set; } = []; + public List? Extensions { get; set; } + + /// Gets or sets whether the agent supports extended agent card. + [JsonPropertyName("extendedAgentCard")] + public bool? ExtendedAgentCard { get; set; } } diff --git a/src/A2A/Models/AgentCard.cs b/src/A2A/Models/AgentCard.cs index 38079802..f6489862 100644 --- a/src/A2A/Models/AgentCard.cs +++ b/src/A2A/Models/AgentCard.cs @@ -1,153 +1,63 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// An AgentCard conveys key information about an agent. -/// -/// -/// - Overall details (version, name, description, uses) -/// - Skills: A set of capabilities the agent can perform -/// - Default modalities/content types supported by the agent. -/// - Authentication requirements. -/// +using System.Text.Json.Serialization; + +/// Represents an agent card containing agent metadata and capabilities. public sealed class AgentCard { - /// - /// Gets or sets the human readable name of the agent. - /// - [JsonPropertyName("name")] - [JsonRequired] + /// Gets or sets the agent name. + [JsonPropertyName("name"), JsonRequired] public string Name { get; set; } = string.Empty; - /// - /// Gets or sets a human-readable description of the agent. - /// - /// - /// Used to assist users and other agents in understanding what the agent can do. - /// CommonMark MAY be used for rich text formatting. - /// (e.g., "This agent helps users find recipes, plan meals, and get cooking instructions.") - /// - [JsonPropertyName("description")] - [JsonRequired] + /// Gets or sets the agent description. + [JsonPropertyName("description"), JsonRequired] public string Description { get; set; } = string.Empty; - /// - /// Gets or sets a URL to the address the agent is hosted at. - /// - /// - /// This represents the preferred endpoint as declared by the agent. - /// - [JsonPropertyName("url")] - [JsonRequired] - public string Url { get; set; } = string.Empty; - - /// - /// Gets or sets a URL to an icon for the agent. (e.g., `https://agent.example.com/icon.png`). - /// - [JsonPropertyName("iconUrl")] - public string? IconUrl { get; set; } - - /// - /// Gets or sets the service provider of the agent. - /// - [JsonPropertyName("provider")] - public AgentProvider? Provider { get; set; } - - /// - /// Gets or sets the version of the agent - format is up to the provider. - /// - [JsonPropertyName("version")] - [JsonRequired] + /// Version of the agent. + [JsonPropertyName("version"), JsonRequired] public string Version { get; set; } = string.Empty; - /// - /// The version of the A2A protocol this agent supports. - /// - [JsonPropertyName("protocolVersion")] - [JsonRequired] - public string ProtocolVersion { get; set; } = "0.3.0"; - - /// - /// Gets or sets a URL to documentation for the agent. - /// + /// URL for the agent's documentation. [JsonPropertyName("documentationUrl")] public string? DocumentationUrl { get; set; } - /// - /// Gets or sets the optional capabilities supported by the agent. - /// - [JsonPropertyName("capabilities")] - [JsonRequired] - public AgentCapabilities Capabilities { get; set; } = new AgentCapabilities(); - - /// - /// Gets or sets the security scheme details used for authenticating with this agent. - /// - [JsonPropertyName("securitySchemes")] - public Dictionary? SecuritySchemes { get; set; } + /// URL for the agent's icon. + [JsonPropertyName("iconUrl")] + public string? IconUrl { get; set; } - /// - /// Gets or sets the security requirements for contacting the agent. - /// - [JsonPropertyName("security")] - public List>? Security { get; set; } + /// Gets or sets the supported interfaces for this agent. + [JsonPropertyName("supportedInterfaces"), JsonRequired] + public List SupportedInterfaces { get; set; } = []; - /// - /// Gets or sets the set of interaction modes that the agent supports across all skills. - /// - /// - /// This can be overridden per-skill. Supported media types for input. - /// - [JsonPropertyName("defaultInputModes")] - [JsonRequired] - public List DefaultInputModes { get; set; } = ["text"]; + /// Gets or sets the agent capabilities. + [JsonPropertyName("capabilities"), JsonRequired] + public AgentCapabilities Capabilities { get; set; } = new(); - /// - /// Gets or sets the supported media types for output. - /// - [JsonPropertyName("defaultOutputModes")] - [JsonRequired] - public List DefaultOutputModes { get; set; } = ["text"]; + /// Gets or sets the agent provider information. + [JsonPropertyName("provider")] + public AgentProvider? Provider { get; set; } - /// - /// Gets or sets the skills that are a unit of capability that an agent can perform. - /// - [JsonPropertyName("skills")] - [JsonRequired] + /// Gets or sets the skills offered by this agent. + [JsonPropertyName("skills"), JsonRequired] public List Skills { get; set; } = []; - /// - /// Gets or sets a value indicating whether the agent supports providing an extended agent card when the user is authenticated. - /// - /// - /// Defaults to false if not specified. - /// - [JsonPropertyName("supportsAuthenticatedExtendedCard")] - public bool SupportsAuthenticatedExtendedCard { get; set; } = false; + /// Gets or sets the default input modes. + [JsonPropertyName("defaultInputModes"), JsonRequired] + public List DefaultInputModes { get; set; } = []; + + /// Gets or sets the default output modes. + [JsonPropertyName("defaultOutputModes"), JsonRequired] + public List DefaultOutputModes { get; set; } = []; - /// - /// Announcement of additional supported transports. - /// - /// - /// The client can use any of the supported transports. - /// - [JsonPropertyName("additionalInterfaces")] - public List AdditionalInterfaces { get; set; } = []; + /// Gets or sets the security schemes available for this agent. + [JsonPropertyName("securitySchemes")] + public Dictionary? SecuritySchemes { get; set; } - /// - /// The transport of the preferred endpoint. - /// - /// - /// This property is required. It defaults to when an is instantiated in code. - /// - [JsonPropertyName("preferredTransport")] - [JsonRequired] - public AgentTransport PreferredTransport { get; set; } = AgentTransport.JsonRpc; + /// Gets or sets the security requirements for this agent. + [JsonPropertyName("securityRequirements")] + public List? SecurityRequirements { get; set; } - /// - /// JSON Web Signatures computed for this AgentCard. - /// + /// Gets or sets the signatures for this agent card. [JsonPropertyName("signatures")] public List? Signatures { get; set; } } diff --git a/src/A2A/Models/AgentCardSignature.cs b/src/A2A/Models/AgentCardSignature.cs index 9cfaa4c1..e81d68b0 100644 --- a/src/A2A/Models/AgentCardSignature.cs +++ b/src/A2A/Models/AgentCardSignature.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace A2A; @@ -27,5 +28,5 @@ public sealed class AgentCardSignature /// The unprotected JWS header values. /// [JsonPropertyName("header")] - public Dictionary? Header { get; set; } + public Dictionary? Header { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/AgentExtension.cs b/src/A2A/Models/AgentExtension.cs index 64272c0b..8b60084d 100644 --- a/src/A2A/Models/AgentExtension.cs +++ b/src/A2A/Models/AgentExtension.cs @@ -1,35 +1,24 @@ +namespace A2A; + using System.Text.Json; using System.Text.Json.Serialization; -namespace A2A; - -/// -/// A declaration of an extension supported by an Agent. -/// +/// Represents an extension supported by an agent. public sealed class AgentExtension { - /// - /// Gets or sets the URI of the extension. - /// - [JsonPropertyName("uri")] - [JsonRequired] - public string? Uri { get; set; } = string.Empty; + /// Gets or sets the URI identifying this extension. + [JsonPropertyName("uri"), JsonRequired] + public string Uri { get; set; } = string.Empty; - /// - /// Gets or sets a description of how this agent uses this extension. - /// + /// Gets or sets the description of this extension. [JsonPropertyName("description")] public string? Description { get; set; } - /// - /// Gets or sets whether the client must follow specific requirements of the extension. - /// + /// Gets or sets whether this extension is required. [JsonPropertyName("required")] - public bool Required { get; set; } = false; + public bool? Required { get; set; } - /// - /// Gets or sets optional configuration for the extension. - /// + /// Gets or sets the parameters for this extension. [JsonPropertyName("params")] - public Dictionary? Params { get; set; } + public JsonElement? Params { get; set; } } diff --git a/src/A2A/Models/AgentInterface.cs b/src/A2A/Models/AgentInterface.cs index 263c98bc..b44fac38 100644 --- a/src/A2A/Models/AgentInterface.cs +++ b/src/A2A/Models/AgentInterface.cs @@ -1,27 +1,23 @@ -using System.Text.Json.Serialization; +namespace A2A; -namespace A2A; +using System.Text.Json.Serialization; -/// -/// Provides a declaration of a combination of target URL and supported transport to interact with an agent. -/// +/// Represents an interface supported by an agent. public sealed class AgentInterface { - /// - /// The transport supported by this URL. - /// - /// - /// This is an open form string, to be easily extended for many transport protocols. - /// The core ones officially supported are JSONRPC, GRPC, and HTTP+JSON. - /// - [JsonPropertyName("transport")] - [JsonRequired] - public required AgentTransport Transport { get; set; } + /// Gets or sets the URL for this interface. + [JsonPropertyName("url"), JsonRequired] + public string Url { get; set; } = string.Empty; - /// - /// The target URL for the agent interface. - /// - [JsonPropertyName("url")] - [JsonRequired] - public required string Url { get; set; } + /// Gets or sets the protocol binding. + [JsonPropertyName("protocolBinding"), JsonRequired] + public string ProtocolBinding { get; set; } = "JSONRPC"; + + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the protocol version. + [JsonPropertyName("protocolVersion"), JsonRequired] + public string ProtocolVersion { get; set; } = "1.0"; } \ No newline at end of file diff --git a/src/A2A/Models/AgentSkill.cs b/src/A2A/Models/AgentSkill.cs index 90dfc049..927e88af 100644 --- a/src/A2A/Models/AgentSkill.cs +++ b/src/A2A/Models/AgentSkill.cs @@ -1,75 +1,39 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Represents a unit of capability that an agent can perform. -/// +using System.Text.Json.Serialization; + +/// Represents a skill offered by an agent. public sealed class AgentSkill { - /// - /// Unique identifier for the agent's skill. - /// - [JsonPropertyName("id")] - [JsonRequired] + /// Gets or sets the skill identifier. + [JsonPropertyName("id"), JsonRequired] public string Id { get; set; } = string.Empty; - /// - /// Human readable name of the skill. - /// - [JsonPropertyName("name")] - [JsonRequired] + /// Gets or sets the skill name. + [JsonPropertyName("name"), JsonRequired] public string Name { get; set; } = string.Empty; - /// - /// Description of the skill. - /// - /// - /// Will be used by the client or a human as a hint to understand what the skill does. - /// - [JsonPropertyName("description")] - [JsonRequired] + /// Gets or sets the skill description. + [JsonPropertyName("description"), JsonRequired] public string Description { get; set; } = string.Empty; - /// - /// Set of tagwords describing classes of capabilities for this specific skill. - /// - [JsonPropertyName("tags")] - [JsonRequired] + /// Tags categorizing the skill. + [JsonPropertyName("tags"), JsonRequired] public List Tags { get; set; } = []; - /// - /// The set of example scenarios that the skill can perform. - /// - /// - /// Will be used by the client as a hint to understand how the skill can be used. - /// + /// Gets or sets the examples for this skill. [JsonPropertyName("examples")] public List? Examples { get; set; } - /// - /// The set of interaction modes that the skill supports (if different than the default). - /// - /// - /// Supported media types for input. - /// + /// Gets or sets the input modes supported by this skill. [JsonPropertyName("inputModes")] public List? InputModes { get; set; } - /// - /// Supported media types for output. - /// + /// Gets or sets the output modes supported by this skill. [JsonPropertyName("outputModes")] public List? OutputModes { get; set; } - /// - /// Security schemes necessary for the agent to leverage this skill. - /// - /// - /// As in the overall AgentCard.security, this list represents a logical OR of security - /// requirement objects. Each object is a set of security schemes that must be used together - /// (a logical AND). - /// - [JsonPropertyName("security")] - public List>? Security { get; set; } + /// Gets or sets the security requirements for this skill. + [JsonPropertyName("securityRequirements")] + public List? SecurityRequirements { get; set; } } diff --git a/src/A2A/Models/AgentTask.cs b/src/A2A/Models/AgentTask.cs index b20f07c5..22c2d6f5 100644 --- a/src/A2A/Models/AgentTask.cs +++ b/src/A2A/Models/AgentTask.cs @@ -1,49 +1,32 @@ +namespace A2A; + using System.Text.Json; using System.Text.Json.Serialization; -namespace A2A; - -/// -/// Represents a task that can be processed by an agent. -/// -public sealed class AgentTask() : A2AResponse(A2AEventKind.Task) +/// Represents a task in the A2A protocol. +public sealed class AgentTask { - /// - /// Unique identifier for the task. - /// - [JsonPropertyName("id")] - [JsonRequired] + /// Gets or sets the unique task identifier. + [JsonPropertyName("id"), JsonRequired] public string Id { get; set; } = string.Empty; - /// - /// Server-generated id for contextual alignment across interactions. - /// - [JsonPropertyName("contextId")] - [JsonRequired] + /// Gets or sets the context identifier. + [JsonPropertyName("contextId"), JsonRequired] public string ContextId { get; set; } = string.Empty; - /// - /// Current status of the task. - /// - [JsonPropertyName("status")] - [JsonRequired] - public AgentTaskStatus Status { get; set; } = new AgentTaskStatus(); + /// Gets or sets the current status of the task. + [JsonPropertyName("status"), JsonRequired] + public TaskStatus Status { get; set; } = new(); - /// - /// Collection of artifacts created by the agent. - /// + /// Gets or sets the history of messages for this task. + [JsonPropertyName("history")] + public List? History { get; set; } + + /// Gets or sets the artifacts produced by this task. [JsonPropertyName("artifacts")] public List? Artifacts { get; set; } - /// - /// Collection of messages in the task history. - /// - [JsonPropertyName("history")] - public List? History { get; set; } = []; - - /// - /// Extension metadata. - /// + /// Gets or sets the metadata associated with this task. [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } } diff --git a/src/A2A/Models/ApiKeySecurityScheme.cs b/src/A2A/Models/ApiKeySecurityScheme.cs new file mode 100644 index 00000000..f0b5ab66 --- /dev/null +++ b/src/A2A/Models/ApiKeySecurityScheme.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an API key security scheme. +public sealed class ApiKeySecurityScheme +{ + /// Gets or sets the name of the API key. + [JsonPropertyName("name"), JsonRequired] + public string Name { get; set; } = string.Empty; + + /// Description of the API key security scheme. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Location of the API key (query, header, or cookie). + [JsonPropertyName("location"), JsonRequired] + public string Location { get; set; } = string.Empty; +} diff --git a/src/A2A/Models/Artifact.cs b/src/A2A/Models/Artifact.cs index 79ba0f2e..c0507b44 100644 --- a/src/A2A/Models/Artifact.cs +++ b/src/A2A/Models/Artifact.cs @@ -1,48 +1,32 @@ +namespace A2A; + using System.Text.Json; using System.Text.Json.Serialization; -namespace A2A; - -/// -/// Represents an artifact generated for a task. -/// +/// Represents an artifact produced by a task. public sealed class Artifact { - /// - /// Unique identifier for the artifact. - /// - [JsonPropertyName("artifactId")] - [JsonRequired] + /// Gets or sets the artifact identifier. + [JsonPropertyName("artifactId"), JsonRequired] public string ArtifactId { get; set; } = string.Empty; - /// - /// Optional name for the artifact. - /// + /// Gets or sets the name of the artifact. [JsonPropertyName("name")] public string? Name { get; set; } - /// - /// Optional description for the artifact. - /// + /// Gets or sets the description of the artifact. [JsonPropertyName("description")] public string? Description { get; set; } - /// - /// Artifact parts. - /// - [JsonPropertyName("parts")] - [JsonRequired] + /// Gets or sets the parts comprising this artifact. + [JsonPropertyName("parts"), JsonRequired] public List Parts { get; set; } = []; - /// - /// Extension metadata. - /// - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - /// - /// The URIs of extensions that are present or contributed to this Artifact. - /// + /// Gets or sets the extensions applied to this artifact. [JsonPropertyName("extensions")] public List? Extensions { get; set; } + + /// Gets or sets the metadata associated with this artifact. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/AuthenticationInfo.cs b/src/A2A/Models/AuthenticationInfo.cs new file mode 100644 index 00000000..741ee447 --- /dev/null +++ b/src/A2A/Models/AuthenticationInfo.cs @@ -0,0 +1,15 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents authentication information for push notifications. +public sealed class AuthenticationInfo +{ + /// Gets or sets the authentication scheme. + [JsonPropertyName("scheme"), JsonRequired] + public string Scheme { get; set; } = string.Empty; + + /// Gets or sets the credentials. + [JsonPropertyName("credentials")] + public string? Credentials { get; set; } +} diff --git a/src/A2A/Models/AuthorizationCodeOAuthFlow.cs b/src/A2A/Models/AuthorizationCodeOAuthFlow.cs new file mode 100644 index 00000000..bf07a767 --- /dev/null +++ b/src/A2A/Models/AuthorizationCodeOAuthFlow.cs @@ -0,0 +1,27 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 authorization code flow. +public sealed class AuthorizationCodeOAuthFlow +{ + /// Gets or sets the authorization URL. + [JsonPropertyName("authorizationUrl"), JsonRequired] + public string AuthorizationUrl { get; set; } = string.Empty; + + /// Gets or sets the token URL. + [JsonPropertyName("tokenUrl"), JsonRequired] + public string TokenUrl { get; set; } = string.Empty; + + /// Gets or sets the refresh URL. + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + /// Gets or sets the available scopes. + [JsonPropertyName("scopes"), JsonRequired] + public Dictionary Scopes { get; set; } = new(); + + /// Whether PKCE is required for the authorization code flow. + [JsonPropertyName("pkceRequired")] + public bool? PkceRequired { get; set; } +} diff --git a/src/A2A/Models/CancelTaskRequest.cs b/src/A2A/Models/CancelTaskRequest.cs new file mode 100644 index 00000000..9f41f4de --- /dev/null +++ b/src/A2A/Models/CancelTaskRequest.cs @@ -0,0 +1,20 @@ +namespace A2A; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// Represents a request to cancel a task. +public sealed class CancelTaskRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the metadata associated with this request. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} diff --git a/src/A2A/Models/ClientCredentialsOAuthFlow.cs b/src/A2A/Models/ClientCredentialsOAuthFlow.cs new file mode 100644 index 00000000..9139d2c5 --- /dev/null +++ b/src/A2A/Models/ClientCredentialsOAuthFlow.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 client credentials flow. +public sealed class ClientCredentialsOAuthFlow +{ + /// Gets or sets the token URL. + [JsonPropertyName("tokenUrl"), JsonRequired] + public string TokenUrl { get; set; } = string.Empty; + + /// Gets or sets the refresh URL. + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + /// Gets or sets the available scopes. + [JsonPropertyName("scopes"), JsonRequired] + public Dictionary Scopes { get; set; } = new(); +} diff --git a/src/A2A/Models/CreateTaskPushNotificationConfigRequest.cs b/src/A2A/Models/CreateTaskPushNotificationConfigRequest.cs new file mode 100644 index 00000000..ef7c74a1 --- /dev/null +++ b/src/A2A/Models/CreateTaskPushNotificationConfigRequest.cs @@ -0,0 +1,23 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to create a push notification configuration. +public sealed class CreateTaskPushNotificationConfigRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; + + /// Unique identifier for the configuration. + [JsonPropertyName("configId"), JsonRequired] + public string ConfigId { get; set; } = string.Empty; + + /// Gets or sets the push notification configuration. + [JsonPropertyName("config"), JsonRequired] + public PushNotificationConfig Config { get; set; } = new(); +} diff --git a/src/A2A/Models/DeleteTaskPushNotificationConfigRequest.cs b/src/A2A/Models/DeleteTaskPushNotificationConfigRequest.cs new file mode 100644 index 00000000..bd85eab8 --- /dev/null +++ b/src/A2A/Models/DeleteTaskPushNotificationConfigRequest.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to delete a push notification configuration. +public sealed class DeleteTaskPushNotificationConfigRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the push notification configuration identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; +} diff --git a/src/A2A/Models/DeviceCodeOAuthFlow.cs b/src/A2A/Models/DeviceCodeOAuthFlow.cs new file mode 100644 index 00000000..5d1b8a7f --- /dev/null +++ b/src/A2A/Models/DeviceCodeOAuthFlow.cs @@ -0,0 +1,23 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 device code flow. +public sealed class DeviceCodeOAuthFlow +{ + /// Gets or sets the device authorization URL. + [JsonPropertyName("deviceAuthorizationUrl"), JsonRequired] + public string DeviceAuthorizationUrl { get; set; } = string.Empty; + + /// Gets or sets the token URL. + [JsonPropertyName("tokenUrl"), JsonRequired] + public string TokenUrl { get; set; } = string.Empty; + + /// Gets or sets the refresh URL. + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + /// Gets or sets the available scopes. + [JsonPropertyName("scopes"), JsonRequired] + public Dictionary Scopes { get; set; } = new(); +} diff --git a/src/A2A/Models/GetExtendedAgentCardRequest.cs b/src/A2A/Models/GetExtendedAgentCardRequest.cs new file mode 100644 index 00000000..390c3706 --- /dev/null +++ b/src/A2A/Models/GetExtendedAgentCardRequest.cs @@ -0,0 +1,11 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to get an extended agent card. +public sealed class GetExtendedAgentCardRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } +} diff --git a/src/A2A/Models/GetTaskPushNotificationConfigRequest.cs b/src/A2A/Models/GetTaskPushNotificationConfigRequest.cs new file mode 100644 index 00000000..464aace1 --- /dev/null +++ b/src/A2A/Models/GetTaskPushNotificationConfigRequest.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to get a push notification configuration. +public sealed class GetTaskPushNotificationConfigRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the push notification configuration identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; +} diff --git a/src/A2A/Models/GetTaskRequest.cs b/src/A2A/Models/GetTaskRequest.cs new file mode 100644 index 00000000..d4ee2e36 --- /dev/null +++ b/src/A2A/Models/GetTaskRequest.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to get a task by ID. +public sealed class GetTaskRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the history length to include. + [JsonPropertyName("historyLength")] + public int? HistoryLength { get; set; } +} diff --git a/src/A2A/Models/HttpAuthSecurityScheme.cs b/src/A2A/Models/HttpAuthSecurityScheme.cs new file mode 100644 index 00000000..1d981d9c --- /dev/null +++ b/src/A2A/Models/HttpAuthSecurityScheme.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an HTTP authentication security scheme. +public sealed class HttpAuthSecurityScheme +{ + /// Description of the HTTP auth security scheme. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Gets or sets the authentication scheme (e.g., bearer, basic). + [JsonPropertyName("scheme"), JsonRequired] + public string Scheme { get; set; } = string.Empty; + + /// Gets or sets the bearer format. + [JsonPropertyName("bearerFormat")] + public string? BearerFormat { get; set; } +} diff --git a/src/A2A/Models/ImplicitOAuthFlow.cs b/src/A2A/Models/ImplicitOAuthFlow.cs new file mode 100644 index 00000000..c3918564 --- /dev/null +++ b/src/A2A/Models/ImplicitOAuthFlow.cs @@ -0,0 +1,20 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 implicit flow. +[Obsolete("Implicit flow is deprecated.")] +public sealed class ImplicitOAuthFlow +{ + /// Gets or sets the authorization URL. + [JsonPropertyName("authorizationUrl"), JsonRequired] + public string AuthorizationUrl { get; set; } = string.Empty; + + /// Gets or sets the refresh URL. + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + /// Gets or sets the available scopes. + [JsonPropertyName("scopes"), JsonRequired] + public Dictionary Scopes { get; set; } = new(); +} diff --git a/src/A2A/Models/ListTaskPushNotificationConfigRequest.cs b/src/A2A/Models/ListTaskPushNotificationConfigRequest.cs new file mode 100644 index 00000000..dec7fe0a --- /dev/null +++ b/src/A2A/Models/ListTaskPushNotificationConfigRequest.cs @@ -0,0 +1,23 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to list push notification configurations. +public sealed class ListTaskPushNotificationConfigRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; + + /// Maximum number of configs to return. + [JsonPropertyName("pageSize")] + public int? PageSize { get; set; } + + /// Token for cursor-based pagination. + [JsonPropertyName("pageToken")] + public string? PageToken { get; set; } +} diff --git a/src/A2A/Models/ListTaskPushNotificationConfigResponse.cs b/src/A2A/Models/ListTaskPushNotificationConfigResponse.cs new file mode 100644 index 00000000..1d9280c5 --- /dev/null +++ b/src/A2A/Models/ListTaskPushNotificationConfigResponse.cs @@ -0,0 +1,15 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents the response to a list push notification config request. +public sealed class ListTaskPushNotificationConfigResponse +{ + /// Gets or sets the list of push notification configurations. + [JsonPropertyName("configs")] + public List? Configs { get; set; } + + /// Gets or sets the token for the next page of results. + [JsonPropertyName("nextPageToken")] + public string? NextPageToken { get; set; } +} diff --git a/src/A2A/Models/ListTasksRequest.cs b/src/A2A/Models/ListTasksRequest.cs new file mode 100644 index 00000000..c86d0a73 --- /dev/null +++ b/src/A2A/Models/ListTasksRequest.cs @@ -0,0 +1,39 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to list tasks with pagination. +public sealed class ListTasksRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the context identifier filter. + [JsonPropertyName("contextId")] + public string? ContextId { get; set; } + + /// Gets or sets the status filter. + [JsonPropertyName("status")] + public TaskState? Status { get; set; } + + /// Gets or sets the page size. + [JsonPropertyName("pageSize")] + public int? PageSize { get; set; } + + /// Gets or sets the page token for cursor-based pagination. + [JsonPropertyName("pageToken")] + public string? PageToken { get; set; } + + /// Gets or sets the history length to include. + [JsonPropertyName("historyLength")] + public int? HistoryLength { get; set; } + + /// Gets or sets a filter for tasks with status timestamps after this value. + [JsonPropertyName("statusTimestampAfter")] + public DateTimeOffset? StatusTimestampAfter { get; set; } + + /// Gets or sets whether to include artifacts in the response. + [JsonPropertyName("includeArtifacts")] + public bool? IncludeArtifacts { get; set; } +} diff --git a/src/A2A/Models/ListTasksResponse.cs b/src/A2A/Models/ListTasksResponse.cs new file mode 100644 index 00000000..6abf4e16 --- /dev/null +++ b/src/A2A/Models/ListTasksResponse.cs @@ -0,0 +1,23 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents the response to a list tasks request with pagination support. +public sealed class ListTasksResponse +{ + /// List of tasks matching the query. + [JsonPropertyName("tasks"), JsonRequired] + public List Tasks { get; set; } = []; + + /// Token for the next page. Empty string when no more results. + [JsonPropertyName("nextPageToken"), JsonRequired] + public string NextPageToken { get; set; } = string.Empty; + + /// Number of tasks in this page. + [JsonPropertyName("pageSize"), JsonRequired] + public int PageSize { get; set; } + + /// Total number of matching tasks across all pages. + [JsonPropertyName("totalSize"), JsonRequired] + public int TotalSize { get; set; } +} diff --git a/src/A2A/Models/Message.cs b/src/A2A/Models/Message.cs new file mode 100644 index 00000000..9fa9892c --- /dev/null +++ b/src/A2A/Models/Message.cs @@ -0,0 +1,40 @@ +namespace A2A; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// Represents a message in the A2A protocol. +public sealed class Message +{ + /// Gets or sets the role of the message sender. + [JsonPropertyName("role"), JsonRequired] + public Role Role { get; set; } + + /// Gets or sets the parts of this message. + [JsonPropertyName("parts"), JsonRequired] + public List Parts { get; set; } = []; + + /// Unique identifier for the message. + [JsonPropertyName("messageId"), JsonRequired] + public string MessageId { get; set; } = string.Empty; + + /// Gets or sets the context identifier. + [JsonPropertyName("contextId")] + public string? ContextId { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId")] + public string? TaskId { get; set; } + + /// Gets or sets the list of referenced task identifiers. + [JsonPropertyName("referenceTaskIds")] + public List? ReferenceTaskIds { get; set; } + + /// Gets or sets the extensions associated with this message. + [JsonPropertyName("extensions")] + public List? Extensions { get; set; } + + /// Gets or sets the metadata associated with this message. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} diff --git a/src/A2A/Models/MutualTlsSecurityScheme.cs b/src/A2A/Models/MutualTlsSecurityScheme.cs new file mode 100644 index 00000000..58c6ce04 --- /dev/null +++ b/src/A2A/Models/MutualTlsSecurityScheme.cs @@ -0,0 +1,11 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a mutual TLS security scheme. +public sealed class MutualTlsSecurityScheme +{ + /// Gets or sets the description of the mutual TLS scheme. + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/src/A2A/Models/OAuth2SecurityScheme.cs b/src/A2A/Models/OAuth2SecurityScheme.cs new file mode 100644 index 00000000..1d676a6d --- /dev/null +++ b/src/A2A/Models/OAuth2SecurityScheme.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 security scheme. +public sealed class OAuth2SecurityScheme +{ + /// Description of the OAuth2 security scheme. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Gets or sets the OAuth2 flows. + [JsonPropertyName("flows"), JsonRequired] + public OAuthFlows Flows { get; set; } = new(); + + /// URL for OAuth2 metadata discovery. + [JsonPropertyName("oauth2MetadataUrl")] + public string? OAuth2MetadataUrl { get; set; } +} diff --git a/src/A2A/Models/OAuthFlows.cs b/src/A2A/Models/OAuthFlows.cs new file mode 100644 index 00000000..dce8f24c --- /dev/null +++ b/src/A2A/Models/OAuthFlows.cs @@ -0,0 +1,56 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Identifies which OAuth flow is set. +public enum OAuthFlowCase +{ + /// No flow is set. + None, + /// Authorization code flow. + AuthorizationCode, + /// Client credentials flow. + ClientCredentials, + /// Implicit flow (deprecated). + Implicit, + /// Password flow (deprecated). + Password, + /// Device code flow. + DeviceCode, +} + +/// Represents OAuth2 flow configurations. Uses field-presence to indicate which flows are available. +public sealed class OAuthFlows +{ + /// Gets or sets the authorization code flow. + [JsonPropertyName("authorizationCode")] + public AuthorizationCodeOAuthFlow? AuthorizationCode { get; set; } + + /// Gets or sets the client credentials flow. + [JsonPropertyName("clientCredentials")] + public ClientCredentialsOAuthFlow? ClientCredentials { get; set; } + + /// Gets or sets the implicit flow. + [JsonPropertyName("implicit"), Obsolete("Implicit flow is deprecated.")] + public ImplicitOAuthFlow? Implicit { get; set; } + + /// Gets or sets the password flow. + [JsonPropertyName("password"), Obsolete("Password flow is deprecated.")] + public PasswordOAuthFlow? Password { get; set; } + + /// Gets or sets the device code flow. + [JsonPropertyName("deviceCode")] + public DeviceCodeOAuthFlow? DeviceCode { get; set; } + + /// Gets which OAuth flow is currently set. + [JsonIgnore] + public OAuthFlowCase FlowCase => + AuthorizationCode is not null ? OAuthFlowCase.AuthorizationCode : + ClientCredentials is not null ? OAuthFlowCase.ClientCredentials : +#pragma warning disable CS0618 // Obsolete members must still be inspected + Implicit is not null ? OAuthFlowCase.Implicit : + Password is not null ? OAuthFlowCase.Password : +#pragma warning restore CS0618 + DeviceCode is not null ? OAuthFlowCase.DeviceCode : + OAuthFlowCase.None; +} diff --git a/src/A2A/Models/OpenIdConnectSecurityScheme.cs b/src/A2A/Models/OpenIdConnectSecurityScheme.cs new file mode 100644 index 00000000..ec1b7ba5 --- /dev/null +++ b/src/A2A/Models/OpenIdConnectSecurityScheme.cs @@ -0,0 +1,15 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OpenID Connect security scheme. +public sealed class OpenIdConnectSecurityScheme +{ + /// Description of the OpenID Connect security scheme. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// Gets or sets the OpenID Connect URL. + [JsonPropertyName("openIdConnectUrl"), JsonRequired] + public string OpenIdConnectUrl { get; set; } = string.Empty; +} diff --git a/src/A2A/Models/Part.cs b/src/A2A/Models/Part.cs index 6ff27393..d4be2a8a 100644 --- a/src/A2A/Models/Part.cs +++ b/src/A2A/Models/Part.cs @@ -1,68 +1,86 @@ +namespace A2A; + using System.Text.Json; using System.Text.Json.Serialization; -namespace A2A; +/// Identifies which content field is set on a Part. +public enum PartContentCase +{ + /// No content field is set. + None, + /// Text content. + Text, + /// Raw binary content (base64 encoded). + Raw, + /// URL content. + Url, + /// Structured data content. + Data, +} -/// -/// Represents a part of a message, which can be text, a file, or structured data. -/// -/// The kind discriminator value -[JsonConverter(typeof(PartConverterViaKindDiscriminator))] -[JsonDerivedType(typeof(TextPart))] -[JsonDerivedType(typeof(FilePart))] -[JsonDerivedType(typeof(DataPart))] -// You might be wondering why we don't use JsonPolymorphic here. The reason is that it automatically throws a NotSupportedException if the -// discriminator isn't present or accounted for. In the case of A2A, we want to throw a more specific A2AException with an error code, so -// we implement our own converter to handle that, with the discriminator logic implemented by-hand. -public abstract class Part(string kind) +/// Represents a content part in the A2A protocol. Uses field-presence to indicate the content type. +public sealed class Part { - /// - /// The 'kind' discriminator value - /// - [JsonRequired, JsonPropertyName(BaseKindDiscriminatorConverter.DiscriminatorPropertyName), JsonInclude, JsonPropertyOrder(int.MinValue)] - public string Kind { get; internal set; } = kind; - /// - /// Optional metadata associated with the part. - /// + /// Gets or sets the text content. + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// Gets or sets the raw binary content. + [JsonPropertyName("raw")] + public byte[]? Raw { get; set; } + + /// Gets or sets the URL reference to content. + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// Gets or sets the structured data content. + [JsonPropertyName("data")] + public JsonElement? Data { get; set; } + + /// Gets or sets the metadata associated with this part. [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } - /// - /// Casts this part to a TextPart. - /// - /// The part as a TextPart. - /// Thrown when the part is not a TextPart. - public TextPart AsTextPart() => this is TextPart textPart ? - textPart : - throw new InvalidCastException($"Cannot cast {GetType().Name} to TextPart."); + /// Gets or sets the filename associated with this part. + [JsonPropertyName("filename")] + public string? Filename { get; set; } - /// - /// Casts this part to a FilePart. - /// - /// The part as a FilePart. - /// Thrown when the part is not a FilePart. - public FilePart AsFilePart() => this is FilePart filePart ? - filePart : - throw new InvalidCastException($"Cannot cast {GetType().Name} to FilePart."); + /// Gets or sets the media type of the content. + [JsonPropertyName("mediaType")] + public string? MediaType { get; set; } - /// - /// Casts this part to a DataPart. - /// - /// The part as a DataPart. - /// Thrown when the part is not a DataPart. - public DataPart AsDataPart() => this is DataPart dataPart ? - dataPart : - throw new InvalidCastException($"Cannot cast {GetType().Name} to DataPart."); -} + /// Gets which content field is currently set. + [JsonIgnore] + public PartContentCase ContentCase => + Text is not null ? PartContentCase.Text : + Raw is not null ? PartContentCase.Raw : + Url is not null ? PartContentCase.Url : + Data is not null ? PartContentCase.Data : + PartContentCase.None; -internal class PartConverterViaKindDiscriminator : BaseKindDiscriminatorConverter where T : Part -{ - protected override IReadOnlyDictionary KindToTypeMapping { get; } = new Dictionary - { - [PartKind.Text] = typeof(TextPart), - [PartKind.File] = typeof(FilePart), - [PartKind.Data] = typeof(DataPart) - }; + /// Creates a text part. + /// The text content. + /// A new with the text field set. + public static Part FromText(string text) => new() { Text = text }; + + /// Creates a part from raw binary data. + /// The raw binary content. + /// The media type of the content. + /// An optional filename. + /// A new with the raw field set. + public static Part FromRaw(byte[] raw, string? mediaType = null, string? filename = null) => + new() { Raw = raw, MediaType = mediaType, Filename = filename }; + + /// Creates a part from a URL reference. + /// The URL reference. + /// The media type of the content. + /// An optional filename. + /// A new with the URL field set. + public static Part FromUrl(string url, string? mediaType = null, string? filename = null) => + new() { Url = url, MediaType = mediaType, Filename = filename }; - protected override string DisplayName { get; } = "part"; + /// Creates a part from structured data. + /// The structured data. + /// A new with the data field set. + public static Part FromData(JsonElement data) => new() { Data = data }; } \ No newline at end of file diff --git a/src/A2A/Models/PasswordOAuthFlow.cs b/src/A2A/Models/PasswordOAuthFlow.cs new file mode 100644 index 00000000..0a0789a2 --- /dev/null +++ b/src/A2A/Models/PasswordOAuthFlow.cs @@ -0,0 +1,20 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents an OAuth2 password flow. +[Obsolete("Password flow is deprecated.")] +public sealed class PasswordOAuthFlow +{ + /// Gets or sets the token URL. + [JsonPropertyName("tokenUrl"), JsonRequired] + public string TokenUrl { get; set; } = string.Empty; + + /// Gets or sets the refresh URL. + [JsonPropertyName("refreshUrl")] + public string? RefreshUrl { get; set; } + + /// Gets or sets the available scopes. + [JsonPropertyName("scopes"), JsonRequired] + public Dictionary Scopes { get; set; } = new(); +} diff --git a/src/A2A/Models/PushNotificationConfig.cs b/src/A2A/Models/PushNotificationConfig.cs index fc140b64..8a2bf271 100644 --- a/src/A2A/Models/PushNotificationConfig.cs +++ b/src/A2A/Models/PushNotificationConfig.cs @@ -1,34 +1,23 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Configuration for setting up push notifications for task updates. -/// +using System.Text.Json.Serialization; + +/// Represents a push notification configuration. public sealed class PushNotificationConfig { - /// - /// URL for sending the push notifications. - /// - [JsonPropertyName("url")] - [JsonRequired] - public string Url { get; set; } = string.Empty; - - /// - /// Optional server-generated identifier for the push notification configuration to support multiple callbacks. - /// + /// Unique identifier for the push notification configuration. [JsonPropertyName("id")] public string? Id { get; set; } - /// - /// Token unique to this task/session. - /// - [JsonPropertyName("token")] - public string? Token { get; set; } + /// Gets or sets the URL for push notifications. + [JsonPropertyName("url"), JsonRequired] + public string Url { get; set; } = string.Empty; - /// - /// Authentication details for push notifications. - /// + /// Gets or sets the authentication information. [JsonPropertyName("authentication")] - public PushNotificationAuthenticationInfo? Authentication { get; set; } + public AuthenticationInfo? Authentication { get; set; } + + /// Gets or sets the token for push notifications. + [JsonPropertyName("token")] + public string? Token { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/Role.cs b/src/A2A/Models/Role.cs new file mode 100644 index 00000000..b8c5e9ec --- /dev/null +++ b/src/A2A/Models/Role.cs @@ -0,0 +1,20 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents the role of a message sender in the A2A protocol. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Role +{ + /// Unspecified role. + [JsonStringEnumMemberName("ROLE_UNSPECIFIED")] + Unspecified = 0, + + /// User role. + [JsonStringEnumMemberName("ROLE_USER")] + User = 1, + + /// Agent role. + [JsonStringEnumMemberName("ROLE_AGENT")] + Agent = 2, +} diff --git a/src/A2A/Models/SecurityRequirement.cs b/src/A2A/Models/SecurityRequirement.cs new file mode 100644 index 00000000..38079f45 --- /dev/null +++ b/src/A2A/Models/SecurityRequirement.cs @@ -0,0 +1,11 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a security requirement for an agent or skill. +public sealed class SecurityRequirement +{ + /// Gets or sets the security schemes and their required scopes. + [JsonPropertyName("schemes")] + public Dictionary? Schemes { get; set; } +} diff --git a/src/A2A/Models/SecurityScheme.cs b/src/A2A/Models/SecurityScheme.cs index 8da5ffa8..e8ea8df5 100644 --- a/src/A2A/Models/SecurityScheme.cs +++ b/src/A2A/Models/SecurityScheme.cs @@ -1,341 +1,54 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Mirrors the OpenAPI Security Scheme Object. -/// (https://swagger.io/specification/#security-scheme-object) -/// -/// -/// This is the base type for all supported OpenAPI security schemes. -/// The type property is used as a discriminator for polymorphic deserialization. -/// -/// -/// Initializes a new instance of the class. -/// -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(ApiKeySecurityScheme), "apiKey")] -[JsonDerivedType(typeof(HttpAuthSecurityScheme), "http")] -[JsonDerivedType(typeof(OAuth2SecurityScheme), "oauth2")] -[JsonDerivedType(typeof(OpenIdConnectSecurityScheme), "openIdConnect")] -[JsonDerivedType(typeof(MutualTlsSecurityScheme), "mutualTLS")] -public abstract class SecurityScheme(string? description = null) -{ - /// - /// A short description for security scheme. CommonMark syntax MAY be used for rich text representation. - /// - [JsonPropertyName("description")] - public string? Description { get; init; } = description; -} - -/// -/// API Key security scheme. -/// -/// -/// Initializes a new instance of the class. -/// -/// The name of the header, query or cookie parameter to be used. -/// The location of the API key. Valid values are "query", "header", or "cookie". -/// -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -/// -public sealed class ApiKeySecurityScheme(string name, string keyLocation, string? description = "API key for authentication") : SecurityScheme(description) -{ - /// - /// The name of the header, query or cookie parameter to be used. - /// - [JsonPropertyName("name")] - [JsonRequired] - public string Name { get; init; } = name; - - /// - /// The location of the API key. Valid values are "query", "header", or "cookie". - /// - [JsonPropertyName("in")] - [JsonRequired] - public string KeyLocation { get; init; } = keyLocation; -} - -/// -/// HTTP Authentication security scheme. -/// -/// -/// Initializes a new instance of the class. -/// -/// The name of the HTTP Authentication scheme to be used in the Authorization header as defined in RFC7235. -/// A hint to the client to identify how the bearer token is formatted. -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -public sealed class HttpAuthSecurityScheme(string scheme, string? bearerFormat = null, string? description = null) : SecurityScheme(description) -{ - /// - /// The name of the HTTP Authentication scheme to be used in the Authorization header as defined in RFC7235. - /// - [JsonPropertyName("scheme")] - [JsonRequired] - public string Scheme { get; init; } = scheme; - - /// - /// A hint to the client to identify how the bearer token is formatted. - /// - [JsonPropertyName("bearerFormat")] - public string? BearerFormat { get; init; } = bearerFormat; -} - -/// -/// OAuth2.0 security scheme configuration. -/// -/// -/// Initializes a new instance of the class. -/// -/// An object containing configuration information for the flow types supported. -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -public sealed class OAuth2SecurityScheme(OAuthFlows flows, string? description = null) : SecurityScheme(description) -{ - /// - /// An object containing configuration information for the flow types supported. - /// - [JsonPropertyName("flows")] - [JsonRequired] - public OAuthFlows Flows { get; init; } = flows; -} - -/// -/// OpenID Connect security scheme configuration. -/// -/// -/// Initializes a new instance of the class. -/// -/// Well-known URL to discover the [[OpenID-Connect-Discovery]] provider metadata. -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -public sealed class OpenIdConnectSecurityScheme(Uri openIdConnectUrl, string? description = null) : SecurityScheme(description) -{ - /// - /// Well-known URL to discover the [[OpenID-Connect-Discovery]] provider metadata. - /// - [JsonPropertyName("openIdConnectUrl")] - [JsonRequired] - public Uri OpenIdConnectUrl { get; init; } = openIdConnectUrl; -} - -/// -/// Mutual TLS security scheme configuration. -/// -/// -/// Initializes a new instance of the class. -/// -/// A short description for the security scheme. CommonMark syntax MAY be used for rich text representation. -public sealed class MutualTlsSecurityScheme(string? description = "Mutual TLS authentication") : SecurityScheme(description) -{ -} - -/// -/// Allows configuration of the supported OAuth Flows. -/// -/// -/// Initializes a new instance of the class. -/// -public sealed class OAuthFlows -{ - /// - /// Configuration for the OAuth Authorization Code flow. Previously called accessCode in OpenAPI 2.0. - /// - [JsonPropertyName("authorizationCode")] - public AuthorizationCodeOAuthFlow? AuthorizationCode { get; init; } - - /// - /// Configuration for the OAuth Client Credentials flow. Previously called application in OpenAPI 2.0. - /// - [JsonPropertyName("clientCredentials")] - public ClientCredentialsOAuthFlow? ClientCredentials { get; init; } - - /// - /// Configuration for the OAuth Resource Owner Password flow. - /// - [JsonPropertyName("password")] - public PasswordOAuthFlow? Password { get; init; } - - /// - /// Configuration for the OAuth Implicit flow. - /// - [JsonPropertyName("implicit")] - public ImplicitOAuthFlow? Implicit { get; init; } -} - -/// -/// Configuration details applicable to all OAuth Flows. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public abstract class BaseOauthFlow(IDictionary scopes, - Uri? refreshUrl = null) -{ - /// - /// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. - /// - [JsonPropertyName("refreshUrl")] - public Uri? RefreshUrl { get; init; } = refreshUrl; - - /// - /// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. - /// - [JsonPropertyName("scopes")] - [JsonRequired] - public IDictionary Scopes { get; init; } = scopes; -} - -/// -/// Configuration details for an OAuth Flow requiring a Token URL. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public abstract class TokenUrlOauthFlow(Uri tokenUrl, - IDictionary scopes, - Uri? refreshUrl = null) : BaseOauthFlow(scopes, refreshUrl) -{ - /// - /// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. - /// - [JsonPropertyName("tokenUrl")] - [JsonRequired] - public Uri TokenUrl { get; init; } = tokenUrl; -} - -/// -/// Configuration details for an OAuth Flow requiring an Authorization URL. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public abstract class AuthUrlOauthFlow(Uri authorizationUrl, - IDictionary scopes, - Uri? refreshUrl = null) : BaseOauthFlow(scopes, refreshUrl) -{ - /// - /// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. - /// - [JsonPropertyName("authorizationUrl")] - [JsonRequired] - public Uri AuthorizationUrl { get; init; } = authorizationUrl; -} - -/// -/// Configuration details for a supported OAuth Authorization Code Flow. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public sealed class AuthorizationCodeOAuthFlow( - Uri authorizationUrl, - Uri tokenUrl, - IDictionary scopes, - Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) -{ - /// - /// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. - /// - [JsonPropertyName("authorizationUrl")] - [JsonRequired] - public Uri AuthorizationUrl { get; init; } = authorizationUrl; -} - -/// -/// Configuration details for a supported OAuth Client Credentials Flow. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public sealed class ClientCredentialsOAuthFlow( - Uri tokenUrl, - IDictionary scopes, - Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) -{ } - -/// -/// Configuration details for a supported OAuth Resource Owner Password Flow. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The token URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public sealed class PasswordOAuthFlow( - Uri tokenUrl, - IDictionary scopes, - Uri? refreshUrl = null) : TokenUrlOauthFlow(tokenUrl, scopes, refreshUrl) -{ } +using System.Text.Json.Serialization; -/// -/// Configuration details for a supported OAuth Implicit Flow. -/// -/// -/// Initializes a new instance of the class. -/// -/// -/// The authorization URL to be used for this flow. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -/// -/// The available scopes for the OAuth2 security scheme. A map between the scope name and a short description for it. The map MAY be empty. -/// -/// -/// The URL to be used for obtaining refresh tokens. This MUST be in the form of a URL. The OAuth2 standard requires the use of TLS. -/// -public sealed class ImplicitOAuthFlow( - Uri authorizationUrl, - IDictionary scopes, - Uri? refreshUrl = null) : AuthUrlOauthFlow(authorizationUrl, scopes, refreshUrl) -{ } \ No newline at end of file +/// Identifies which security scheme type is set. +public enum SecuritySchemeCase +{ + /// No scheme is set. + None, + /// API key security scheme. + ApiKey, + /// HTTP auth security scheme. + HttpAuth, + /// OAuth2 security scheme. + OAuth2, + /// OpenID Connect security scheme. + OpenIdConnect, + /// Mutual TLS security scheme. + Mtls, +} + +/// Represents a security scheme. Uses field-presence to indicate the scheme type. +public sealed class SecurityScheme +{ + /// Gets or sets the API key security scheme. + [JsonPropertyName("apiKeySecurityScheme")] + public ApiKeySecurityScheme? ApiKeySecurityScheme { get; set; } + + /// Gets or sets the HTTP auth security scheme. + [JsonPropertyName("httpAuthSecurityScheme")] + public HttpAuthSecurityScheme? HttpAuthSecurityScheme { get; set; } + + /// Gets or sets the OAuth2 security scheme. + [JsonPropertyName("oauth2SecurityScheme")] + public OAuth2SecurityScheme? OAuth2SecurityScheme { get; set; } + + /// Gets or sets the OpenID Connect security scheme. + [JsonPropertyName("openIdConnectSecurityScheme")] + public OpenIdConnectSecurityScheme? OpenIdConnectSecurityScheme { get; set; } + + /// Gets or sets the mutual TLS security scheme. + [JsonPropertyName("mtlsSecurityScheme")] + public MutualTlsSecurityScheme? MtlsSecurityScheme { get; set; } + + /// Gets which security scheme type is currently set. + [JsonIgnore] + public SecuritySchemeCase SchemeCase => + ApiKeySecurityScheme is not null ? SecuritySchemeCase.ApiKey : + HttpAuthSecurityScheme is not null ? SecuritySchemeCase.HttpAuth : + OAuth2SecurityScheme is not null ? SecuritySchemeCase.OAuth2 : + OpenIdConnectSecurityScheme is not null ? SecuritySchemeCase.OpenIdConnect : + MtlsSecurityScheme is not null ? SecuritySchemeCase.Mtls : + SecuritySchemeCase.None; +} \ No newline at end of file diff --git a/src/A2A/Models/SendMessageConfiguration.cs b/src/A2A/Models/SendMessageConfiguration.cs new file mode 100644 index 00000000..f416faa6 --- /dev/null +++ b/src/A2A/Models/SendMessageConfiguration.cs @@ -0,0 +1,23 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents configuration options for a send message request. +public sealed class SendMessageConfiguration +{ + /// Gets or sets the accepted output modes. + [JsonPropertyName("acceptedOutputModes")] + public List? AcceptedOutputModes { get; set; } + + /// Gets or sets the push notification configuration. + [JsonPropertyName("pushNotificationConfig")] + public PushNotificationConfig? PushNotificationConfig { get; set; } + + /// Gets or sets the history length to include. + [JsonPropertyName("historyLength")] + public int? HistoryLength { get; set; } + + /// Gets or sets whether the request is blocking. + [JsonPropertyName("blocking")] + public bool Blocking { get; set; } +} diff --git a/src/A2A/Models/SendMessageRequest.cs b/src/A2A/Models/SendMessageRequest.cs new file mode 100644 index 00000000..d99397f8 --- /dev/null +++ b/src/A2A/Models/SendMessageRequest.cs @@ -0,0 +1,24 @@ +namespace A2A; + +using System.Text.Json; +using System.Text.Json.Serialization; + +/// Represents a request to send a message. +public sealed class SendMessageRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the message to send. + [JsonPropertyName("message"), JsonRequired] + public Message Message { get; set; } = new(); + + /// Gets or sets the configuration for the request. + [JsonPropertyName("configuration")] + public SendMessageConfiguration? Configuration { get; set; } + + /// Gets or sets the metadata associated with this request. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} diff --git a/src/A2A/Models/SendMessageResponse.cs b/src/A2A/Models/SendMessageResponse.cs new file mode 100644 index 00000000..1738c1a7 --- /dev/null +++ b/src/A2A/Models/SendMessageResponse.cs @@ -0,0 +1,33 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Identifies which payload field is set on a SendMessageResponse. +public enum SendMessageResponseCase +{ + /// No payload field is set. + None, + /// Task payload. + Task, + /// Message payload. + Message, +} + +/// Represents the response to a send message request. Uses field-presence to indicate whether the result is a task or a message. +public sealed class SendMessageResponse +{ + /// Gets or sets the task result. + [JsonPropertyName("task")] + public AgentTask? Task { get; set; } + + /// Gets or sets the message result. + [JsonPropertyName("message")] + public Message? Message { get; set; } + + /// Gets which payload field is currently set. + [JsonIgnore] + public SendMessageResponseCase PayloadCase => + Task is not null ? SendMessageResponseCase.Task : + Message is not null ? SendMessageResponseCase.Message : + SendMessageResponseCase.None; +} diff --git a/src/A2A/Models/StreamResponse.cs b/src/A2A/Models/StreamResponse.cs new file mode 100644 index 00000000..4f8da813 --- /dev/null +++ b/src/A2A/Models/StreamResponse.cs @@ -0,0 +1,47 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Identifies which payload field is set on a StreamResponse. +public enum StreamResponseCase +{ + /// No payload field is set. + None, + /// Task payload. + Task, + /// Message payload. + Message, + /// Task status update event. + StatusUpdate, + /// Task artifact update event. + ArtifactUpdate, +} + +/// Represents a streaming response event. Uses field-presence to indicate the event type. +public sealed class StreamResponse +{ + /// Gets or sets the task result. + [JsonPropertyName("task")] + public AgentTask? Task { get; set; } + + /// Gets or sets the message result. + [JsonPropertyName("message")] + public Message? Message { get; set; } + + /// Gets or sets the task status update event. + [JsonPropertyName("statusUpdate")] + public TaskStatusUpdateEvent? StatusUpdate { get; set; } + + /// Gets or sets the task artifact update event. + [JsonPropertyName("artifactUpdate")] + public TaskArtifactUpdateEvent? ArtifactUpdate { get; set; } + + /// Gets which payload field is currently set. + [JsonIgnore] + public StreamResponseCase PayloadCase => + Task is not null ? StreamResponseCase.Task : + Message is not null ? StreamResponseCase.Message : + StatusUpdate is not null ? StreamResponseCase.StatusUpdate : + ArtifactUpdate is not null ? StreamResponseCase.ArtifactUpdate : + StreamResponseCase.None; +} diff --git a/src/A2A/Models/StringList.cs b/src/A2A/Models/StringList.cs new file mode 100644 index 00000000..ccd6d6c9 --- /dev/null +++ b/src/A2A/Models/StringList.cs @@ -0,0 +1,11 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a list of strings, used for security requirement scope values. +public sealed class StringList +{ + /// Gets or sets the list of string values. + [JsonPropertyName("list")] + public List List { get; set; } = []; +} diff --git a/src/A2A/Models/SubscribeToTaskRequest.cs b/src/A2A/Models/SubscribeToTaskRequest.cs new file mode 100644 index 00000000..7df234e3 --- /dev/null +++ b/src/A2A/Models/SubscribeToTaskRequest.cs @@ -0,0 +1,15 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents a request to subscribe to task updates. +public sealed class SubscribeToTaskRequest +{ + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } + + /// Gets or sets the task identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; +} diff --git a/src/A2A/Models/TaskArtifactUpdateEvent.cs b/src/A2A/Models/TaskArtifactUpdateEvent.cs index 941002b8..09d4840a 100644 --- a/src/A2A/Models/TaskArtifactUpdateEvent.cs +++ b/src/A2A/Models/TaskArtifactUpdateEvent.cs @@ -1,27 +1,32 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Sent by server during sendStream or subscribe requests. -/// -public sealed class TaskArtifactUpdateEvent() : TaskUpdateEvent(A2AEventKind.ArtifactUpdate) +using System.Text.Json; +using System.Text.Json.Serialization; + +/// Represents a task artifact update event in the A2A protocol. +public sealed class TaskArtifactUpdateEvent { - /// - /// Generated artifact. - /// - [JsonPropertyName("artifact")] - public Artifact Artifact { get; set; } = new Artifact(); + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; - /// - /// Indicates if this artifact appends to a previous one. - /// + /// Gets or sets the context identifier. + [JsonPropertyName("contextId"), JsonRequired] + public string ContextId { get; set; } = string.Empty; + + /// Gets or sets the artifact being updated. + [JsonPropertyName("artifact"), JsonRequired] + public Artifact Artifact { get; set; } = new(); + + /// Gets or sets whether this update appends to the existing artifact. [JsonPropertyName("append")] - public bool? Append { get; set; } + public bool Append { get; set; } - /// - /// Indicates if this is the last chunk of the artifact. - /// + /// Gets or sets whether this is the last chunk of the artifact. [JsonPropertyName("lastChunk")] - public bool? LastChunk { get; set; } + public bool LastChunk { get; set; } + + /// Gets or sets the metadata associated with this event. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/TaskPushNotificationConfig.cs b/src/A2A/Models/TaskPushNotificationConfig.cs index c3c68fa0..25be3408 100644 --- a/src/A2A/Models/TaskPushNotificationConfig.cs +++ b/src/A2A/Models/TaskPushNotificationConfig.cs @@ -1,23 +1,23 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Parameters for setting or getting push notification configuration for a task. -/// +using System.Text.Json.Serialization; + +/// Represents a task-specific push notification configuration. public sealed class TaskPushNotificationConfig { - /// - /// Task id. - /// - [JsonPropertyName("taskId")] - [JsonRequired] + /// Gets or sets the configuration identifier. + [JsonPropertyName("id"), JsonRequired] + public string Id { get; set; } = string.Empty; + + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] public string TaskId { get; set; } = string.Empty; - /// - /// Push notification configuration. - /// - [JsonPropertyName("pushNotificationConfig")] - [JsonRequired] - public PushNotificationConfig PushNotificationConfig { get; set; } = new PushNotificationConfig(); + /// Gets or sets the push notification configuration. + [JsonPropertyName("pushNotificationConfig"), JsonRequired] + public PushNotificationConfig PushNotificationConfig { get; set; } = new(); + + /// Gets or sets the tenant identifier. + [JsonPropertyName("tenant")] + public string? Tenant { get; set; } } \ No newline at end of file diff --git a/src/A2A/Models/TaskState.cs b/src/A2A/Models/TaskState.cs index 3fc5f8c0..118027f6 100644 --- a/src/A2A/Models/TaskState.cs +++ b/src/A2A/Models/TaskState.cs @@ -1,55 +1,44 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Represents the possible states of a Task. -/// -[JsonConverter(typeof(KebabCaseLowerJsonStringEnumConverter))] +using System.Text.Json.Serialization; + +/// Represents the state of a task in the A2A protocol. +[JsonConverter(typeof(JsonStringEnumConverter))] public enum TaskState { - /// - /// Indicates that the task has been submitted. - /// - Submitted, - - /// - /// Indicates that the task is currently being worked on. - /// - Working, - - /// - /// Indicates that the task requires input from the user. - /// - InputRequired, - - /// - /// Indicates that the task has been completed successfully. - /// - Completed, - - /// - /// Indicates that the task has been canceled. - /// - Canceled, - - /// - /// Indicates that the task has failed. - /// - Failed, - - /// - /// Indicates that the task has been rejected. - /// - Rejected, - - /// - /// Indicates that the task requires authentication. - /// - AuthRequired, - - /// - /// Indicates that the task state is unknown. - /// - Unknown + /// Unspecified task state. + [JsonStringEnumMemberName("TASK_STATE_UNSPECIFIED")] + Unspecified = 0, + + /// Task has been submitted. + [JsonStringEnumMemberName("TASK_STATE_SUBMITTED")] + Submitted = 1, + + /// Task is being worked on. + [JsonStringEnumMemberName("TASK_STATE_WORKING")] + Working = 2, + + /// Task has completed successfully. + [JsonStringEnumMemberName("TASK_STATE_COMPLETED")] + Completed = 3, + + /// Task has failed. + [JsonStringEnumMemberName("TASK_STATE_FAILED")] + Failed = 4, + + /// Task has been canceled. + [JsonStringEnumMemberName("TASK_STATE_CANCELED")] + Canceled = 5, + + /// Task requires additional input. + [JsonStringEnumMemberName("TASK_STATE_INPUT_REQUIRED")] + InputRequired = 6, + + /// Task has been rejected. + [JsonStringEnumMemberName("TASK_STATE_REJECTED")] + Rejected = 7, + + /// Task requires authentication. + [JsonStringEnumMemberName("TASK_STATE_AUTH_REQUIRED")] + AuthRequired = 8, } \ No newline at end of file diff --git a/src/A2A/Models/TaskStatus.cs b/src/A2A/Models/TaskStatus.cs new file mode 100644 index 00000000..101501b5 --- /dev/null +++ b/src/A2A/Models/TaskStatus.cs @@ -0,0 +1,19 @@ +namespace A2A; + +using System.Text.Json.Serialization; + +/// Represents the status of a task in the A2A protocol. +public sealed class TaskStatus +{ + /// Gets or sets the state of the task. + [JsonPropertyName("state"), JsonRequired] + public TaskState State { get; set; } + + /// Gets or sets the message associated with this status. + [JsonPropertyName("message")] + public Message? Message { get; set; } + + /// Gets or sets the timestamp of this status. + [JsonPropertyName("timestamp")] + public DateTimeOffset? Timestamp { get; set; } +} diff --git a/src/A2A/Models/TaskStatusUpdateEvent.cs b/src/A2A/Models/TaskStatusUpdateEvent.cs index 548d0f6b..27ce9dc9 100644 --- a/src/A2A/Models/TaskStatusUpdateEvent.cs +++ b/src/A2A/Models/TaskStatusUpdateEvent.cs @@ -1,22 +1,24 @@ -using System.Text.Json.Serialization; - namespace A2A; -/// -/// Event sent by server during sendStream or subscribe requests. -/// -public sealed class TaskStatusUpdateEvent() : TaskUpdateEvent(A2AEventKind.StatusUpdate) +using System.Text.Json; +using System.Text.Json.Serialization; + +/// Represents a task status update event in the A2A protocol. +public sealed class TaskStatusUpdateEvent { - /// - /// Gets or sets the current status of the task. - /// - [JsonPropertyName("status")] - [JsonRequired] - public AgentTaskStatus Status { get; set; } = new(); + /// Gets or sets the task identifier. + [JsonPropertyName("taskId"), JsonRequired] + public string TaskId { get; set; } = string.Empty; + + /// Gets or sets the context identifier. + [JsonPropertyName("contextId"), JsonRequired] + public string ContextId { get; set; } = string.Empty; + + /// Gets or sets the updated task status. + [JsonPropertyName("status"), JsonRequired] + public TaskStatus Status { get; set; } = new(); - /// - /// Gets or sets a value indicating whether this indicates the end of the event stream. - /// - [JsonPropertyName("final")] - public bool Final { get; set; } + /// Gets or sets the metadata associated with this event. + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } } \ No newline at end of file diff --git a/src/A2A/Polyfills/System/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs b/src/A2A/Polyfills/System/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs deleted file mode 100644 index 666335db..00000000 --- a/src/A2A/Polyfills/System/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET8_0_OR_GREATER -namespace System.Diagnostics.CodeAnalysis; - -/// -/// Suppresses reporting of a specific rule violation, allowing multiple suppressions on a -/// single code artifact. -/// -/// -/// is different than -/// in that it doesn't have a -/// . So it is always preserved in the compiled assembly. -/// -[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)] -internal sealed class UnconditionalSuppressMessageAttribute : Attribute -{ - /// - /// Initializes a new instance of the - /// class, specifying the category of the tool and the identifier for an analysis rule. - /// - /// The category for the attribute. - /// The identifier of the analysis rule the attribute applies to. - public UnconditionalSuppressMessageAttribute(string category, string checkId) - { - Category = category; - CheckId = checkId; - } - - /// - /// Gets the category identifying the classification of the attribute. - /// - /// - /// The property describes the tool or tool analysis category - /// for which a message suppression attribute applies. - /// - public string Category { get; } - - /// - /// Gets the identifier of the analysis tool rule to be suppressed. - /// - /// - /// Concatenated together, the and - /// properties form a unique check identifier. - /// - public string CheckId { get; } - - /// - /// Gets or sets the scope of the code that is relevant for the attribute. - /// - /// - /// The Scope property is an optional argument that specifies the metadata scope for which - /// the attribute is relevant. - /// - public string? Scope { get; set; } - - /// - /// Gets or sets a fully qualified path that represents the target of the attribute. - /// - /// - /// The property is an optional argument identifying the analysis target - /// of the attribute. An example value is "System.IO.Stream.ctor():System.Void". - /// Because it is fully qualified, it can be long, particularly for targets such as parameters. - /// The analysis tool user interface should be capable of automatically formatting the parameter. - /// - public string? Target { get; set; } - - /// - /// Gets or sets an optional argument expanding on exclusion criteria. - /// - /// - /// The property is an optional argument that specifies additional - /// exclusion where the literal metadata target is not sufficiently precise. For example, - /// the cannot be applied within a method, - /// and it may be desirable to suppress a violation against a statement in the method that will - /// give a rule violation, but not against all statements in the method. - /// - public string? MessageId { get; set; } - - /// - /// Gets or sets the justification for suppressing the code analysis message. - /// - public string? Justification { get; set; } -} -#endif \ No newline at end of file diff --git a/src/A2A/Polyfills/System/Runtime/CompilerServices/CompilerFeatureRequiredAttribute.cs b/src/A2A/Polyfills/System/Runtime/CompilerServices/CompilerFeatureRequiredAttribute.cs deleted file mode 100644 index 51f93ce3..00000000 --- a/src/A2A/Polyfills/System/Runtime/CompilerServices/CompilerFeatureRequiredAttribute.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NETCOREAPP -namespace System.Runtime.CompilerServices; - -[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] -internal sealed class CompilerFeatureRequiredAttribute : Attribute -{ - public CompilerFeatureRequiredAttribute(string featureName) - { - this.FeatureName = featureName; - } - - /// - /// The name of the compiler feature. - /// - public string FeatureName { get; } - - /// - /// If true, the compiler can choose to allow access to the location where this attribute is applied if it does not understand . - /// - public bool IsOptional { get; init; } - - /// - /// The used for the ref structs C# feature. - /// - public const string RefStructs = nameof(RefStructs); - - /// - /// The used for the required members C# feature. - /// - public const string RequiredMembers = nameof(RequiredMembers); -} -#endif \ No newline at end of file diff --git a/src/A2A/Polyfills/System/Runtime/CompilerServices/IsExternalInit.cs b/src/A2A/Polyfills/System/Runtime/CompilerServices/IsExternalInit.cs deleted file mode 100644 index 00246684..00000000 --- a/src/A2A/Polyfills/System/Runtime/CompilerServices/IsExternalInit.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NETCOREAPP -namespace System.Runtime.CompilerServices; - -/// -/// Reserved to be used by the compiler for tracking metadata. -/// This class should not be used by developers in source code. -/// -internal static class IsExternalInit; -#endif \ No newline at end of file diff --git a/src/A2A/Polyfills/System/Runtime/CompilerServices/RequiredMemberAttribute.cs b/src/A2A/Polyfills/System/Runtime/CompilerServices/RequiredMemberAttribute.cs deleted file mode 100644 index 73403d8d..00000000 --- a/src/A2A/Polyfills/System/Runtime/CompilerServices/RequiredMemberAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NETCOREAPP -namespace System.Runtime.CompilerServices; - -/// -/// Specifies that a type has required members or that a member is required. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] -internal sealed class RequiredMemberAttribute : Attribute; -#endif diff --git a/src/A2A/Server/A2AServer.cs b/src/A2A/Server/A2AServer.cs new file mode 100644 index 00000000..d31bea7f --- /dev/null +++ b/src/A2A/Server/A2AServer.cs @@ -0,0 +1,429 @@ +using A2A.Extensions; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Channels; + +namespace A2A; + +/// +/// A2A server: orchestrates request lifecycle, context resolution, task persistence, +/// history management, terminal state guards, cancel support, and observability. +/// Implements for the easy path where agent authors +/// provide an and the SDK handles everything else. +/// +public class A2AServer : IA2ARequestHandler +{ + private readonly IAgentHandler _handler; + private readonly ITaskStore _taskStore; + private readonly ChannelEventNotifier _notifier; + private readonly ILogger _logger; + private readonly A2AServerOptions _options; + + /// Initializes a new instance of the class. + /// The agent handler that provides execution logic. + /// The task store used for persistence. + /// The event notifier for live event streaming and per-task locking. + /// The logger instance. + /// Optional configuration options. + public A2AServer(IAgentHandler handler, ITaskStore taskStore, + ChannelEventNotifier notifier, ILogger logger, + A2AServerOptions? options = null) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + _taskStore = taskStore ?? throw new ArgumentNullException(nameof(taskStore)); + _notifier = notifier ?? throw new ArgumentNullException(nameof(notifier)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? new A2AServerOptions(); + } + + /// + public virtual async Task SendMessageAsync( + SendMessageRequest request, CancellationToken cancellationToken = default) + { + using var activity = A2ADiagnostics.Source.StartActivity("A2AServer.SendMessage", ActivityKind.Internal); + var stopwatch = Stopwatch.StartNew(); + + try + { + A2ADiagnostics.RequestCount.Add(1); + + var context = await ResolveContextAsync(request, streamingResponse: false, cancellationToken).ConfigureAwait(false); + TagActivity(activity, context); + GuardTerminalState(context); + + if (context.IsContinuation && _options.AutoAppendHistory) + { + await ApplyEventAsync( + new StreamResponse { Message = request.Message }, + context, cancellationToken).ConfigureAwait(false); + } + + var eventQueue = new AgentEventQueue(); + var agentTask = Task.Run(async () => + { + try + { + await _handler.ExecuteAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + finally + { + eventQueue.Complete(); + } + }, cancellationToken); + + var result = await MaterializeResponseAsync(eventQueue, context, cancellationToken).ConfigureAwait(false); + await agentTask.ConfigureAwait(false); // surface handler exceptions + + return result; + } + catch (Exception ex) + { + A2ADiagnostics.ErrorCount.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + RecordException(activity, ex); + throw; + } + finally + { + A2ADiagnostics.RequestDuration.Record(stopwatch.Elapsed.TotalMilliseconds); + } + } + + /// + public virtual async IAsyncEnumerable SendStreamingMessageAsync( + SendMessageRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var activity = A2ADiagnostics.Source.StartActivity("A2AServer.SendStreamingMessage", ActivityKind.Internal); + A2ADiagnostics.RequestCount.Add(1); + + RequestContext? context = null; + AgentEventQueue? eventQueue = null; + Task? agentTask = null; + int eventCount = 0; + + try + { + context = await ResolveContextAsync(request, streamingResponse: true, cancellationToken).ConfigureAwait(false); + TagActivity(activity, context); + GuardTerminalState(context); + + if (context.IsContinuation && _options.AutoAppendHistory) + { + await ApplyEventAsync( + new StreamResponse { Message = request.Message }, + context, cancellationToken).ConfigureAwait(false); + } + + eventQueue = new AgentEventQueue(); + agentTask = Task.Run(async () => + { + try + { + await _handler.ExecuteAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + finally + { + eventQueue.Complete(); + } + }, cancellationToken); + } + catch (Exception ex) + { + A2ADiagnostics.ErrorCount.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + RecordException(activity, ex); + throw; + } + + await foreach (var response in eventQueue.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + await ApplyEventAsync(response, context!, cancellationToken).ConfigureAwait(false); + + eventCount++; + yield return response; + } + + A2ADiagnostics.StreamEventCount.Record(eventCount); + + // Surface any agent exceptions + await agentTask!.ConfigureAwait(false); + } + + /// + public virtual async Task GetTaskAsync( + GetTaskRequest request, CancellationToken cancellationToken = default) + { + if (request.HistoryLength is { } hl && hl < 0) + { + throw new A2AException( + $"Invalid historyLength: {hl}. Must be non-negative.", + A2AErrorCode.InvalidParams); + } + + var task = await _taskStore.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound); + + return task.WithHistoryTrimmedTo(request.HistoryLength); + } + + /// + public virtual async Task ListTasksAsync( + ListTasksRequest request, CancellationToken cancellationToken = default) + { + return await _taskStore.ListTasksAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async Task CancelTaskAsync( + CancelTaskRequest request, CancellationToken cancellationToken = default) + { + using var activity = A2ADiagnostics.Source.StartActivity("A2AServer.CancelTask", ActivityKind.Internal); + activity?.SetTag("a2a.task.id", request.Id); + + var task = await _taskStore.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound); + + if (task.Status.State.IsTerminal()) + { + throw new A2AException("Task is already in a terminal state.", A2AErrorCode.TaskNotCancelable); + } + + var context = new RequestContext + { + Message = task.History?.LastOrDefault() ?? new Message { Role = Role.User, MessageId = string.Empty, Parts = [] }, + Task = task, + TaskId = task.Id, + ContextId = task.ContextId, + StreamingResponse = false, + Metadata = request.Metadata, + }; + + var eventQueue = new AgentEventQueue(); + try + { + await _handler.CancelAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + finally + { + eventQueue.Complete(); + } + + await foreach (var response in eventQueue.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + await ApplyEventAsync(response, context, cancellationToken).ConfigureAwait(false); + } + + return await _taskStore.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound); + } + + /// + public virtual async IAsyncEnumerable SubscribeToTaskAsync( + SubscribeToTaskRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var activity = A2ADiagnostics.Source.StartActivity("A2AServer.SubscribeToTask", ActivityKind.Internal); + activity?.SetTag("a2a.task.id", request.Id); + + AgentTask currentTask; + Channel channel; + + // Atomic: read task state + register subscriber channel under per-task lock. + // Concurrent ApplyEventAsync calls block until the channel is registered, + // guaranteeing no events are lost between snapshot and live stream. + using (await _notifier.AcquireTaskLockAsync(request.Id, cancellationToken).ConfigureAwait(false)) + { + currentTask = await _taskStore.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound); + + if (currentTask.Status.State.IsTerminal()) + { + throw new A2AException( + "Task is in a terminal state and cannot be subscribed to.", + A2AErrorCode.UnsupportedOperation); + } + + channel = _notifier.CreateChannel(request.Id); + } + + // First event MUST be current Task object (spec §3.1.6) + yield return new StreamResponse { Task = currentTask }; + + // Live events via channel (no catch-up needed — lock guarantees no gap) + try + { + await foreach (var streamEvent in channel.Reader.ReadAllAsync(cancellationToken) + .ConfigureAwait(false)) + { + yield return streamEvent; + } + } + finally + { + _notifier.RemoveChannel(request.Id, channel); + } + } + + /// + public virtual Task CreateTaskPushNotificationConfigAsync( + CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + throw new A2AException("Push notifications not supported.", A2AErrorCode.PushNotificationNotSupported); + } + + /// + public virtual Task GetTaskPushNotificationConfigAsync( + GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + throw new A2AException("Push notifications not supported.", A2AErrorCode.PushNotificationNotSupported); + } + + /// + public virtual Task ListTaskPushNotificationConfigAsync( + ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + throw new A2AException("Push notifications not supported.", A2AErrorCode.PushNotificationNotSupported); + } + + /// + public virtual Task DeleteTaskPushNotificationConfigAsync( + DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default) + { + throw new A2AException("Push notifications not supported.", A2AErrorCode.PushNotificationNotSupported); + } + + /// + public virtual Task GetExtendedAgentCardAsync( + GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default) + { + throw new A2AException("Extended agent card not configured.", A2AErrorCode.ExtendedAgentCardNotConfigured); + } + + // ─── Private Helpers ─── + + private async Task ResolveContextAsync( + SendMessageRequest request, bool streamingResponse, CancellationToken cancellationToken) + { + AgentTask? existingTask = null; + var taskId = request.Message.TaskId; + var contextId = request.Message.ContextId; + + if (!string.IsNullOrEmpty(taskId)) + { + existingTask = await _taskStore.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{taskId}' not found.", A2AErrorCode.TaskNotFound); + contextId ??= existingTask.ContextId; + } + + return new RequestContext + { + Message = request.Message, + Task = existingTask, + TaskId = taskId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId ?? Guid.NewGuid().ToString("N"), + ClientProvidedContextId = contextId is not null, + StreamingResponse = streamingResponse, + Metadata = request.Metadata, + }; + } + + private static void GuardTerminalState(RequestContext context) + { + if (context.Task is not null && context.Task.Status.State.IsTerminal()) + { + throw new A2AException( + "Task is in a terminal state and cannot accept messages.", + A2AErrorCode.UnsupportedOperation); + } + } + + private async Task ApplyEventAsync( + StreamResponse response, RequestContext context, CancellationToken cancellationToken) + { + using (await _notifier.AcquireTaskLockAsync(context.TaskId, cancellationToken).ConfigureAwait(false)) + { + var currentTask = await _taskStore.GetTaskAsync(context.TaskId, cancellationToken) + .ConfigureAwait(false); + + var updatedTask = TaskProjection.Apply(currentTask, response); + + // Message-only responses with no existing task have nothing to persist. + if (updatedTask is null) + { + _notifier.Notify(context.TaskId, response); + return; + } + + if (currentTask is null) + { + A2ADiagnostics.TaskCreatedCount.Add(1); + } + + await _taskStore.SaveTaskAsync(context.TaskId, updatedTask, cancellationToken) + .ConfigureAwait(false); + + _notifier.Notify(context.TaskId, response); + } + } + + private async Task MaterializeResponseAsync( + AgentEventQueue eventQueue, RequestContext context, CancellationToken cancellationToken) + { + SendMessageResponse? result = null; + + await foreach (var response in eventQueue.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + await ApplyEventAsync(response, context, cancellationToken).ConfigureAwait(false); + + // Capture the first Task or Message as the synchronous response + if (result is null) + { + if (response.Task is not null) + { + result = new SendMessageResponse { Task = response.Task }; + } + else if (response.Message is not null) + { + result = new SendMessageResponse { Message = response.Message }; + } + } + } + + // Re-fetch the projected task to ensure the response reflects + // all persisted events, not a stale snapshot. + if (result?.Task is not null) + { + result.Task = await _taskStore.GetTaskAsync(context.TaskId, cancellationToken).ConfigureAwait(false) + ?? throw new A2AException($"Task '{context.TaskId}' not found after processing.", A2AErrorCode.TaskNotFound); + } + + return result ?? throw new A2AException( + "Agent handler did not produce any response events.", + A2AErrorCode.InvalidAgentResponse); + } + + private static void TagActivity(Activity? activity, RequestContext context) + { + activity?.SetTag("a2a.task.id", context.TaskId); + activity?.SetTag("a2a.context.id", context.ContextId); + activity?.SetTag("a2a.is_continuation", context.IsContinuation); + activity?.SetTag("a2a.streaming_response", context.StreamingResponse); + } + + private static void RecordException(Activity? activity, Exception ex) + { + if (activity is null) + { + return; + } + + var tags = new ActivityTagsCollection + { + { "exception.type", ex.GetType().FullName }, + { "exception.message", ex.Message }, + }; + + activity.AddEvent(new ActivityEvent("exception", tags: tags)); + } +} diff --git a/src/A2A/Server/A2AServerOptions.cs b/src/A2A/Server/A2AServerOptions.cs new file mode 100644 index 00000000..691e8506 --- /dev/null +++ b/src/A2A/Server/A2AServerOptions.cs @@ -0,0 +1,13 @@ +namespace A2A; + +/// +/// Configuration options for . +/// +public sealed class A2AServerOptions +{ + /// + /// Whether to automatically append the incoming user message to task history + /// on continuation requests. Default: true. + /// + public bool AutoAppendHistory { get; set; } = true; +} diff --git a/src/A2A/Server/AgentEventQueue.cs b/src/A2A/Server/AgentEventQueue.cs new file mode 100644 index 00000000..c8543db5 --- /dev/null +++ b/src/A2A/Server/AgentEventQueue.cs @@ -0,0 +1,77 @@ +using System.Threading.Channels; + +namespace A2A; + +/// +/// A write-only event channel for agent code to produce A2A response events. +/// Backed by . Implements +/// for consumption by and protocol processors. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Queue accurately describes the channel-backed event queue semantics")] +public sealed class AgentEventQueue : IAsyncEnumerable +{ + private readonly Channel _channel; + + /// Creates a bounded event queue (default capacity 16). + /// Maximum number of buffered events. + public AgentEventQueue(int capacity = 16) + { + _channel = Channel.CreateBounded( + new BoundedChannelOptions(capacity) + { + SingleWriter = false, + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait, + }); + } + + /// Creates an event queue with custom channel options. + /// Channel configuration options. + public AgentEventQueue(BoundedChannelOptions options) + { + _channel = Channel.CreateBounded(options); + } + + /// Write a raw StreamResponse (low-level). + /// The response event to write. + /// Cancellation token. + public ValueTask WriteAsync(StreamResponse response, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(response, cancellationToken); + + /// Enqueue a task as the initial response event. + /// The task to enqueue. + /// Cancellation token. + public ValueTask EnqueueTaskAsync(AgentTask task, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(new StreamResponse { Task = task }, cancellationToken); + + /// Enqueue a message response (task-free mode). + /// The message to enqueue. + /// Cancellation token. + public ValueTask EnqueueMessageAsync(Message message, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(new StreamResponse { Message = message }, cancellationToken); + + /// Enqueue a task status update event. + /// The status update event to enqueue. + /// Cancellation token. + public ValueTask EnqueueStatusUpdateAsync(TaskStatusUpdateEvent update, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(new StreamResponse { StatusUpdate = update }, cancellationToken); + + /// Enqueue a task artifact update event. + /// The artifact update event to enqueue. + /// Cancellation token. + public ValueTask EnqueueArtifactUpdateAsync(TaskArtifactUpdateEvent update, CancellationToken cancellationToken = default) + => _channel.Writer.WriteAsync(new StreamResponse { ArtifactUpdate = update }, cancellationToken); + + /// Signal completion of the event stream. + /// Optional exception to signal failure. + public void Complete(Exception? exception = null) + => _channel.Writer.TryComplete(exception); + + /// + public async IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default) + { + await foreach (var item in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + yield return item; + } +} diff --git a/src/A2A/Server/ChannelEventNotifier.cs b/src/A2A/Server/ChannelEventNotifier.cs new file mode 100644 index 00000000..68acab8e --- /dev/null +++ b/src/A2A/Server/ChannelEventNotifier.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace A2A; + +/// +/// Per-task subscriber channel management for event notification fan-out, +/// and per-task locking for atomic subscribe and persist operations. +/// +public sealed class ChannelEventNotifier +{ + private readonly ConcurrentDictionary _subscribers = new(); + private readonly ConcurrentDictionary _taskLocks = new(); + + /// + /// Push an event to all registered subscriber channels for the given task. + /// On terminal events, completes all channels to end live tailing. + /// Callers must hold the per-task lock when calling this method. + /// + /// The task to notify subscribers for. + /// The stream response event. + public void Notify(string taskId, StreamResponse streamEvent) + { + if (!_subscribers.TryGetValue(taskId, out var set)) return; + + List> channels; + lock (set) { channels = [.. set.Channels]; } + + foreach (var ch in channels) + ch.Writer.TryWrite(streamEvent); + + if (IsTerminalEvent(streamEvent)) + { + lock (set) { channels = [.. set.Channels]; } + foreach (var ch in channels) + ch.Writer.TryComplete(); + } + } + + /// Creates and registers a subscriber channel for the given task. + /// The task to create a subscriber channel for. + internal Channel CreateChannel(string taskId) + { + var channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleWriter = false, SingleReader = true }); + + var set = _subscribers.GetOrAdd(taskId, _ => new SubscriberSet()); + lock (set) { set.Channels.Add(channel); } + return channel; + } + + /// Unregisters a channel when subscription ends. + /// The task to remove the channel from. + /// The channel to remove. + internal void RemoveChannel(string taskId, Channel channel) + { + if (!_subscribers.TryGetValue(taskId, out var set)) return; + lock (set) { set.Channels.Remove(channel); } + } + + /// + /// Acquire the per-task lock used to atomically read task state and register + /// a subscriber channel, preventing race conditions with concurrent mutations. + /// + /// The task to acquire the lock for. + /// Cancellation token. + public async Task AcquireTaskLockAsync( + string taskId, CancellationToken cancellationToken = default) + { + var sem = _taskLocks.GetOrAdd(taskId, _ => new SemaphoreSlim(1, 1)); + await sem.WaitAsync(cancellationToken).ConfigureAwait(false); + return new TaskLockRelease(sem); + } + + private static bool IsTerminalEvent(StreamResponse streamEvent) + { + var state = streamEvent.StatusUpdate?.Status.State ?? streamEvent.Task?.Status.State; + return state?.IsTerminal() == true; + } + + private sealed class TaskLockRelease(SemaphoreSlim semaphore) : IDisposable + { + private int _disposed; + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + semaphore.Release(); + } + } + + private sealed class SubscriberSet + { + public List> Channels { get; } = []; + } +} diff --git a/src/A2A/Server/IA2ARequestHandler.cs b/src/A2A/Server/IA2ARequestHandler.cs new file mode 100644 index 00000000..3dd760a8 --- /dev/null +++ b/src/A2A/Server/IA2ARequestHandler.cs @@ -0,0 +1,74 @@ +namespace A2A; + +/// +/// Contract between protocol processors and the task management layer. +/// Implemented by (easy path) and by custom implementations (difficult path). +/// +public interface IA2ARequestHandler +{ + /// Handles a send message request. + /// The send message request. + /// A cancellation token. + /// The send message response. + Task SendMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default); + + /// Handles a streaming send message request. + /// The send message request. + /// A cancellation token. + /// An asynchronous enumerable of streaming responses. + IAsyncEnumerable SendStreamingMessageAsync(SendMessageRequest request, CancellationToken cancellationToken = default); + + /// Gets a task by ID. + /// The get task request. + /// A cancellation token. + /// The agent task. + Task GetTaskAsync(GetTaskRequest request, CancellationToken cancellationToken = default); + + /// Lists tasks with pagination. + /// The list tasks request. + /// A cancellation token. + /// The list tasks response. + Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default); + + /// Cancels a task. + /// The cancel task request. + /// A cancellation token. + /// The canceled agent task. + Task CancelTaskAsync(CancelTaskRequest request, CancellationToken cancellationToken = default); + + /// Subscribes to task updates. + /// The subscribe to task request. + /// A cancellation token. + /// An asynchronous enumerable of streaming responses. + IAsyncEnumerable SubscribeToTaskAsync(SubscribeToTaskRequest request, CancellationToken cancellationToken = default); + + /// Creates a push notification configuration. + /// The create push notification config request. + /// A cancellation token. + /// The created push notification configuration. + Task CreateTaskPushNotificationConfigAsync(CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); + + /// Gets a push notification configuration. + /// The get push notification config request. + /// A cancellation token. + /// The push notification configuration. + Task GetTaskPushNotificationConfigAsync(GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); + + /// Lists push notification configurations. + /// The list push notification configs request. + /// A cancellation token. + /// The list push notification config response. + Task ListTaskPushNotificationConfigAsync(ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); + + /// Deletes a push notification configuration. + /// The delete push notification config request. + /// A cancellation token. + /// A task representing the asynchronous operation. + Task DeleteTaskPushNotificationConfigAsync(DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken = default); + + /// Gets the extended agent card. + /// The get extended agent card request. + /// A cancellation token. + /// The extended agent card. + Task GetExtendedAgentCardAsync(GetExtendedAgentCardRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/A2A/Server/IAgentHandler.cs b/src/A2A/Server/IAgentHandler.cs new file mode 100644 index 00000000..8e0dec34 --- /dev/null +++ b/src/A2A/Server/IAgentHandler.cs @@ -0,0 +1,33 @@ +namespace A2A; + +/// +/// Defines the agent's execution logic. Implement this for the easy path +/// where handles task lifecycle, persistence, +/// and observability. +/// +public interface IAgentHandler +{ + /// + /// Execute agent logic for an incoming message. + /// Use to emit task lifecycle events, or write + /// directly to for message-only responses. + /// + /// Pre-resolved context with IDs, existing task, and message. + /// Channel to write response events to. + /// Cancellation token. + Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken); + + /// + /// Handle task cancellation. Default implementation transitions the task + /// to state. Override for custom cleanup + /// logic (abort LLM calls, release resources). + /// + /// Pre-resolved context with IDs, existing task, and message. + /// Channel to write response events to. + /// Cancellation token. + async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(cancellationToken); + } +} diff --git a/src/A2A/Server/ITaskStore.cs b/src/A2A/Server/ITaskStore.cs index aafb489e..a72a2f9e 100644 --- a/src/A2A/Server/ITaskStore.cs +++ b/src/A2A/Server/ITaskStore.cs @@ -1,58 +1,42 @@ -namespace A2A; - -/// -/// Interface for storing and retrieving agent tasks. -/// -public interface ITaskStore -{ - /// - /// Retrieves a task by its ID. - /// - /// The ID of the task to retrieve. - /// A cancellation token that can be used to cancel the operation. - /// The task if found, null otherwise. - Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); - - /// - /// Retrieves push notification configuration for a task. - /// - /// The ID of the task. - /// The ID of the push notification configuration. - /// A cancellation token that can be used to cancel the operation. - /// The push notification configuration if found, null otherwise. - Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default); - - /// - /// Updates the status of a task. - /// - /// The ID of the task. - /// The new status. - /// Optional message associated with the status. - /// A cancellation token that can be used to cancel the operation. - /// The updated task status. - Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default); - - /// - /// Stores or updates a task. - /// - /// The task to store. - /// A cancellation token that can be used to cancel the operation. - /// A task representing the operation. - Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default); - - /// - /// Stores push notification configuration for a task. - /// - /// The push notification configuration. - /// A cancellation token that can be used to cancel the operation. - /// A task representing the operation. - Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default); - - /// - /// Retrieves push notifications for a task. - /// - /// The ID of the task. - /// A cancellation token that can be used to cancel the operation. - /// A list of push notification configurations for the task. - Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default); -} \ No newline at end of file +namespace A2A; + +/// +/// Persistence interface for A2A task state. +/// Implementations provide durable storage for objects. +/// +/// +/// The SDK manages task state mutations internally using +/// before calling . +/// Implementations only need to persist and retrieve the fully-formed task object. +/// For live event streaming (SubscribeToTask, SendStreamingMessage), +/// the SDK uses independently of this interface. +/// Implementations do not need to handle event notification or pub/sub. +/// +public interface ITaskStore +{ + /// Get the current state of a task. + /// The task identifier. + /// Cancellation token. + Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default); + + /// Save (upsert) the current state of a task. + /// The task identifier. + /// The task to persist. + /// Cancellation token. + Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default); + + /// Delete a task. + /// The task identifier. + /// Cancellation token. + Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default); + + /// + /// Query tasks with filtering and pagination. + /// Supports filtering by , + /// , , + /// and cursor-based pagination. + /// + /// The query request with filters and pagination. + /// Cancellation token. + Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/A2A/Server/InMemoryTaskStore.cs b/src/A2A/Server/InMemoryTaskStore.cs index a01944f2..2ec627f6 100644 --- a/src/A2A/Server/InMemoryTaskStore.cs +++ b/src/A2A/Server/InMemoryTaskStore.cs @@ -1,133 +1,127 @@ -using System.Collections.Concurrent; - -namespace A2A; - -/// -/// In-memory implementation of task store for development and testing. -/// -public sealed class InMemoryTaskStore : ITaskStore -{ - private readonly ConcurrentDictionary _taskCache = []; - // PushNotificationConfig.Id is optional, so there can be multiple configs with no Id. - // Since we want to maintain order of insertion and thread safety, we use a ConcurrentQueue. - private readonly ConcurrentDictionary> _pushNotificationCache = []; - - /// - public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - return string.IsNullOrEmpty(taskId) - ? Task.FromException(new ArgumentNullException(nameof(taskId))) - : Task.FromResult(_taskCache.TryGetValue(taskId, out var task) ? task : null); - } - - /// - public Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - if (string.IsNullOrEmpty(taskId)) - { - return Task.FromException(new ArgumentNullException(nameof(taskId))); - } - - if (!_pushNotificationCache.TryGetValue(taskId, out var pushNotificationConfigs)) - { - return Task.FromResult(null); - } - - var pushNotificationConfig = pushNotificationConfigs.FirstOrDefault(config => config.PushNotificationConfig.Id == notificationConfigId); - - return Task.FromResult(pushNotificationConfig); - } - - /// - public Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - if (string.IsNullOrEmpty(taskId)) - { - return Task.FromException(new A2AException("Invalid task ID", new ArgumentNullException(nameof(taskId)), A2AErrorCode.InvalidParams)); - } - - if (!_taskCache.TryGetValue(taskId, out var task)) - { - return Task.FromException(new A2AException("Task not found.", A2AErrorCode.TaskNotFound)); - } - - return Task.FromResult(task.Status = task.Status with - { - Message = message, - State = status, - Timestamp = DateTimeOffset.UtcNow - }); - } - - /// - public Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - if (task is null) - { - return Task.FromException(new ArgumentNullException(nameof(task))); - } - - if (string.IsNullOrEmpty(task.Id)) - { - return Task.FromException(new A2AException("Invalid task ID", A2AErrorCode.InvalidParams)); - } - - _taskCache[task.Id] = task; - return Task.CompletedTask; - } - - /// - public Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - - if (pushNotificationConfig is null) - { - return Task.FromException(new ArgumentNullException(nameof(pushNotificationConfig))); - } - - var pushNotificationConfigs = _pushNotificationCache.GetOrAdd(pushNotificationConfig.TaskId, _ => []); - pushNotificationConfigs.Enqueue(pushNotificationConfig); - - return Task.CompletedTask; - } - - /// - public Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default) - { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled>(cancellationToken); - } - - if (!_pushNotificationCache.TryGetValue(taskId, out var pushNotificationConfigs)) - { - return Task.FromResult>([]); - } - - return Task.FromResult>(pushNotificationConfigs); - } -} \ No newline at end of file +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace A2A; + +/// +/// In-memory task store using . +/// Suitable for development and testing. +/// +public sealed class InMemoryTaskStore : ITaskStore +{ + private readonly ConcurrentDictionary _tasks = new(); + + /// + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + if (!_tasks.TryGetValue(taskId, out var task)) + return Task.FromResult(null); + return Task.FromResult(CloneTask(task)); + } + + /// + public Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default) + { + _tasks[taskId] = CloneTask(task); + return Task.CompletedTask; + } + + /// + public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default) + { + _tasks.TryRemove(taskId, out _); + return Task.CompletedTask; + } + + /// + public Task ListTasksAsync(ListTasksRequest request, + CancellationToken cancellationToken = default) + { + IEnumerable allTasks = _tasks.Values + .Select(CloneTask); + + // Apply filters + if (!string.IsNullOrEmpty(request.ContextId)) + allTasks = allTasks.Where(t => t.ContextId == request.ContextId); + + if (request.Status is { } statusFilter) + allTasks = allTasks.Where(t => t.Status.State == statusFilter); + + if (request.StatusTimestampAfter is not null) + allTasks = allTasks.Where(t => + t.Status.Timestamp is not null && + t.Status.Timestamp > request.StatusTimestampAfter); + + // Sort descending by status timestamp (newest first) + var taskList = allTasks + .OrderByDescending(t => t.Status.Timestamp ?? DateTimeOffset.MinValue) + .ToList(); + + var totalSize = taskList.Count; + + // Pagination + int startIndex = 0; + if (!string.IsNullOrEmpty(request.PageToken)) + { + if (!int.TryParse(request.PageToken, out var offset) || offset < 0) + { + throw new A2AException( + $"Invalid pageToken: {request.PageToken}", + A2AErrorCode.InvalidParams); + } + + startIndex = offset; + } + + var pageSize = request.PageSize ?? 50; + var page = taskList.Skip(startIndex).Take(pageSize).ToList(); + + // Trim history + if (request.HistoryLength is { } historyLength) + { + foreach (var task in page) + { + if (historyLength == 0) + { + task.History = null; + } + else if (task.History is { Count: > 0 }) + { + task.History = task.History + .Skip(Math.Max(0, task.History.Count - historyLength)) + .ToList(); + } + } + } + + if (request.IncludeArtifacts is not true) + { + foreach (var task in page) + { + task.Artifacts = null; + } + } + + var nextIndex = startIndex + page.Count; + var nextPageToken = nextIndex < totalSize + ? nextIndex.ToString(System.Globalization.CultureInfo.InvariantCulture) + : string.Empty; + + return Task.FromResult(new ListTasksResponse + { + Tasks = page, + NextPageToken = nextPageToken, + PageSize = page.Count, + TotalSize = totalSize, + }); + } + + [UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "All types are registered in source-generated JsonContext.")] + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "All types are registered in source-generated JsonContext.")] + private static AgentTask CloneTask(AgentTask task) + { + var json = JsonSerializer.SerializeToUtf8Bytes(task, A2AJsonUtilities.DefaultOptions); + return JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)!; + } +} diff --git a/src/A2A/Server/MessageResponder.cs b/src/A2A/Server/MessageResponder.cs new file mode 100644 index 00000000..c6ade5f3 --- /dev/null +++ b/src/A2A/Server/MessageResponder.cs @@ -0,0 +1,31 @@ +namespace A2A; + +/// +/// Convenience wrapper around for sending +/// agent response messages. Handles Role, MessageId, and ContextId automatically. +/// +/// The event queue to write messages to. +/// The context ID to include on all messages. +public sealed class MessageResponder(AgentEventQueue eventQueue, string contextId) +{ + /// Gets the context ID this responder operates on. + public string ContextId => contextId; + + /// Send a text reply. + /// The text content of the reply. + /// Cancellation token. + public ValueTask ReplyAsync(string text, CancellationToken cancellationToken = default) + => ReplyAsync([Part.FromText(text)], cancellationToken); + + /// Send a reply with the specified parts. + /// The content parts of the reply. + /// Cancellation token. + public ValueTask ReplyAsync(List parts, CancellationToken cancellationToken = default) + => eventQueue.EnqueueMessageAsync(new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString("N"), + ContextId = contextId, + Parts = parts, + }, cancellationToken); +} diff --git a/src/A2A/Server/RequestContext.cs b/src/A2A/Server/RequestContext.cs new file mode 100644 index 00000000..6670d34a --- /dev/null +++ b/src/A2A/Server/RequestContext.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace A2A; + +/// +/// Provides the agent with pre-resolved context for the current request. +/// IDs are pre-generated, existing task is pre-fetched from store. +/// +public sealed class RequestContext +{ + /// The incoming client message. + public required Message Message { get; init; } + + /// Existing task if continuing, null for new conversations. + public AgentTask? Task { get; init; } + + /// The task ID — existing or newly generated. + public required string TaskId { get; init; } + + /// The context ID — client-provided, inherited from existing task, or SDK-generated. + public required string ContextId { get; init; } + + /// + /// Whether was provided by the client (or inherited from an existing task) + /// vs generated by the SDK. Agents backed by inference APIs can use this to decide whether + /// to reuse an existing conversation or start a new one. + /// + public bool ClientProvidedContextId { get; init; } + + /// Whether the response will be streamed (SSE) vs returned synchronously. + public required bool StreamingResponse { get; init; } + + /// Original request metadata. + public Dictionary? Metadata { get; init; } + + /// First text content from message parts, or null. + public string? UserText => Message.Parts.FirstOrDefault(p => p.Text is not null)?.Text; + + /// Whether this continues an existing task. + public bool IsContinuation => Task is not null; +} diff --git a/src/A2A/Server/TaskProjection.cs b/src/A2A/Server/TaskProjection.cs new file mode 100644 index 00000000..c8fce93d --- /dev/null +++ b/src/A2A/Server/TaskProjection.cs @@ -0,0 +1,85 @@ +namespace A2A; + +/// +/// Pure projection functions for materializing state +/// from a stream of events. +/// +/// +/// uses to mutate task state before +/// persisting via . Store implementors do +/// not need to call this directly. +/// +public static class TaskProjection +{ + /// + /// Apply a single event to an AgentTask state, returning the updated state. + /// + /// The current task state, or null if no events have been applied. + /// The event to apply. + public static AgentTask? Apply(AgentTask? current, StreamResponse streamEvent) + { + if (streamEvent.Task is { } task) + return task; + + if (current is null) + return current; + + if (streamEvent.StatusUpdate is { } su) + return ApplyStatus(current, su); + + if (streamEvent.ArtifactUpdate is { } au) + return ApplyArtifact(current, au); + + if (streamEvent.Message is { } msg) + { + if (current is not null) + { + (current.History ??= []).Add(msg); + } + return current; + } + + return current; + } + + private static AgentTask ApplyStatus(AgentTask current, TaskStatusUpdateEvent su) + { + // Move superseded status.message to history (aligned with Python SDK behavior). + if (current.Status.Message is not null) + { + (current.History ??= []).Add(current.Status.Message); + } + current.Status = su.Status; + return current; + } + + private static AgentTask ApplyArtifact(AgentTask current, TaskArtifactUpdateEvent au) + { + current.Artifacts ??= []; + var artifactId = au.Artifact.ArtifactId; + + if (!au.Append) + { + // append=false: add new or replace existing by artifactId + var idx = current.Artifacts.FindIndex(a => a.ArtifactId == artifactId); + if (idx >= 0) + current.Artifacts[idx] = au.Artifact; + else + current.Artifacts.Add(au.Artifact); + } + else + { + // append=true: extend existing artifact's parts list, or add if not found + var existing = current.Artifacts.FirstOrDefault(a => a.ArtifactId == artifactId); + if (existing is not null) + { + existing.Parts.AddRange(au.Artifact.Parts); + } + else + { + current.Artifacts.Add(au.Artifact); + } + } + return current; + } +} diff --git a/src/A2A/Server/TaskUpdateEventEnumerator.cs b/src/A2A/Server/TaskUpdateEventEnumerator.cs index 843e08d4..f215b974 100644 --- a/src/A2A/Server/TaskUpdateEventEnumerator.cs +++ b/src/A2A/Server/TaskUpdateEventEnumerator.cs @@ -1,68 +1,43 @@ +using System.Runtime.CompilerServices; using System.Threading.Channels; namespace A2A; -/// -/// Enumerator for streaming task update events to clients. -/// -public sealed class TaskUpdateEventEnumerator : IAsyncEnumerable, IDisposable, IAsyncDisposable +/// Provides an async enumerable of backed by a channel. +public sealed class TaskUpdateEventEnumerator : IAsyncEnumerable { - private readonly Channel _channel = Channel.CreateUnbounded(); + private readonly Channel _channel; - /// - /// Gets or sets the processing task to prevent garbage collection. - /// - public Task? ProcessingTask { get; set; } - - /// - /// Notifies of a new event in the task stream. - /// - /// The event to notify. - public void NotifyEvent(A2AEvent taskUpdateEvent) + /// Initializes a new instance of the class. + /// Optional bounded channel options. + public TaskUpdateEventEnumerator(BoundedChannelOptions? options = null) { - if (taskUpdateEvent is null) - { - throw new ArgumentNullException(nameof(taskUpdateEvent)); - } - - if (!_channel.Writer.TryWrite(taskUpdateEvent)) - { - throw new InvalidOperationException("Unable to write to the event channel."); - } + _channel = options is not null + ? Channel.CreateBounded(options) + : Channel.CreateUnbounded(); } - /// - /// Notifies of the final event in the task stream. - /// - /// The final event to notify. - public void NotifyFinalEvent(A2AEvent taskUpdateEvent) + /// Writes a stream response to the channel. + /// The stream response to write. + /// A cancellation token. + public async ValueTask WriteAsync(StreamResponse response, CancellationToken cancellationToken = default) { - if (taskUpdateEvent is null) - { - throw new ArgumentNullException(nameof(taskUpdateEvent)); - } - - if (!_channel.Writer.TryWrite(taskUpdateEvent)) - { - throw new InvalidOperationException("Unable to write to the event channel."); - } - - _channel.Writer.TryComplete(); + await _channel.Writer.WriteAsync(response, cancellationToken).ConfigureAwait(false); } - /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => _channel.Reader.ReadAllAsync(cancellationToken).GetAsyncEnumerator(cancellationToken); - - /// - public void Dispose() + /// Marks the channel as complete, indicating no more items will be written. + /// An optional exception to signal an error. + public void Complete(Exception? exception = null) { - _channel.Writer.TryComplete(); + _channel.Writer.TryComplete(exception); } /// - public ValueTask DisposeAsync() + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - this.Dispose(); - return default; + await foreach (var item in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return item; + } } } \ No newline at end of file diff --git a/src/A2A/Server/TaskUpdater.cs b/src/A2A/Server/TaskUpdater.cs new file mode 100644 index 00000000..3d8246e1 --- /dev/null +++ b/src/A2A/Server/TaskUpdater.cs @@ -0,0 +1,192 @@ +namespace A2A; + +/// +/// Convenience wrapper around for common +/// task lifecycle operations. Handles timestamps and event construction. +/// Usable by both implementations (easy path) +/// and direct consumers (difficult path). +/// +/// The event queue to write lifecycle events to. +/// The task ID to operate on. +/// The context ID to operate on. +public sealed class TaskUpdater(AgentEventQueue eventQueue, string taskId, string contextId) +{ + /// Gets the task ID this updater operates on. + public string TaskId => taskId; + + /// Gets the context ID this updater operates on. + public string ContextId => contextId; + + /// Emit the initial task with Submitted status. + /// Cancellation token. + public ValueTask SubmitAsync(CancellationToken cancellationToken = default) + => eventQueue.EnqueueTaskAsync(new AgentTask + { + Id = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Submitted, + Timestamp = DateTimeOffset.UtcNow, + }, + }, cancellationToken); + + /// Transition task to Working state with an optional status message. + /// Optional message describing the current work. + /// Cancellation token. + public ValueTask StartWorkAsync(Message? message = null, CancellationToken cancellationToken = default) + => eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Working, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + + /// Add an artifact to the task. + /// The content parts of the artifact. + /// Optional artifact ID; auto-generated if null. + /// Optional artifact name. + /// Optional artifact description. + /// Whether this is the final chunk for this artifact. + /// Whether to append to an existing artifact with the same ID. + /// Cancellation token. + public ValueTask AddArtifactAsync( + IReadOnlyList parts, + string? artifactId = null, + string? name = null, + string? description = null, + bool lastChunk = true, + bool append = false, + CancellationToken cancellationToken = default) + => eventQueue.EnqueueArtifactUpdateAsync(new TaskArtifactUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Artifact = new Artifact + { + ArtifactId = artifactId ?? Guid.NewGuid().ToString("N"), + Name = name, + Description = description, + Parts = [.. parts], + }, + Append = append, + LastChunk = lastChunk, + }, cancellationToken); + + /// Complete the task with an optional final message. + /// Optional completion message. + /// Cancellation token. + public async ValueTask CompleteAsync(Message? message = null, CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Completed, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + eventQueue.Complete(); + } + + /// Fail the task with an optional error message. + /// Optional error message. + /// Cancellation token. + public async ValueTask FailAsync(Message? message = null, CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Failed, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + eventQueue.Complete(); + } + + /// Cancel the task. + /// Cancellation token. + public async ValueTask CancelAsync(CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Canceled, + Timestamp = DateTimeOffset.UtcNow, + }, + }, cancellationToken); + eventQueue.Complete(); + } + + /// Reject the task with an optional reason message. + /// Optional rejection reason. + /// Cancellation token. + public async ValueTask RejectAsync(Message? message = null, CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.Rejected, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + eventQueue.Complete(); + } + + /// Request additional input from the client. + /// Message describing what input is needed. + /// Cancellation token. + public async ValueTask RequireInputAsync(Message message, CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.InputRequired, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + eventQueue.Complete(); + } + + /// Request authentication from the client. + /// Optional message describing the auth requirement. + /// Cancellation token. + public async ValueTask RequireAuthAsync(Message? message = null, CancellationToken cancellationToken = default) + { + await eventQueue.EnqueueStatusUpdateAsync(new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = contextId, + Status = new TaskStatus + { + State = TaskState.AuthRequired, + Timestamp = DateTimeOffset.UtcNow, + Message = message, + }, + }, cancellationToken); + eventQueue.Complete(); + } +} diff --git a/tests/A2A.AspNetCore.UnitTests/A2A.AspNetCore.UnitTests.csproj b/tests/A2A.AspNetCore.UnitTests/A2A.AspNetCore.UnitTests.csproj index d306bc4c..192df8a5 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2A.AspNetCore.UnitTests.csproj +++ b/tests/A2A.AspNetCore.UnitTests/A2A.AspNetCore.UnitTests.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0 + net10.0;net8.0 enable enable diff --git a/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index 82ebaf24..3060e9df 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -1,121 +1,87 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace A2A.AspNetCore.Tests; - -public class A2AEndpointRouteBuilderExtensionsTests -{ - [Fact] - public void MapA2A_RegistersEndpoint_WithCorrectPath() - { - // Arrange - var serviceCollection = new ServiceCollection(); - serviceCollection.AddLogging(); - serviceCollection.AddRouting(); - var services = serviceCollection.BuildServiceProvider(); - - var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); - - // Act & Assert - Should not throw - var result = app.MapA2A(taskManager, "/agent"); - Assert.NotNull(result); - } - - [Fact] - public void MapWellKnownAgentCard_RegistersEndpoint_WithCorrectPath() - { - // Arrange - var serviceCollection = new ServiceCollection(); - serviceCollection.AddLogging(); - serviceCollection.AddRouting(); - var services = serviceCollection.BuildServiceProvider(); - - var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); - - // Act & Assert - Should not throw - var result = app.MapWellKnownAgentCard(taskManager, "/agent"); - Assert.NotNull(result); - } - - [Fact] - public void MapA2A_And_MapWellKnownAgentCard_Together_RegistersBothEndpoints() - { - // Arrange - var serviceCollection = new ServiceCollection(); - serviceCollection.AddLogging(); - serviceCollection.AddRouting(); - var services = serviceCollection.BuildServiceProvider(); - - var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); - - // Act & Assert - Should not throw when calling both - var result1 = app.MapA2A(taskManager, "/agent"); - var result2 = app.MapWellKnownAgentCard(taskManager, "/agent"); - - Assert.NotNull(result1); - Assert.NotNull(result2); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void MapA2A_ThrowsArgumentException_WhenPathIsNullOrEmpty(string? path) - { - // Arrange - var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); - - // Act & Assert - if (path == null) - { - Assert.Throws(() => app.MapA2A(taskManager, path!)); - } - else - { - Assert.Throws(() => app.MapA2A(taskManager, path)); - } - } - - [Theory] - [InlineData(null)] - [InlineData("")] - public void MapWellKnownAgentCard_ThrowsArgumentException_WhenAgentPathIsNullOrEmpty(string? agentPath) - { - // Arrange - var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); - - // Act & Assert - if (agentPath == null) - { - Assert.Throws(() => app.MapWellKnownAgentCard(taskManager, agentPath!)); - } - else - { - Assert.Throws(() => app.MapWellKnownAgentCard(taskManager, agentPath)); - } - } - - [Fact] - public void MapA2A_RequiresNonNullTaskManager() - { - // Arrange - var app = WebApplication.CreateBuilder().Build(); - - // Act & Assert - Assert.Throws(() => app.MapA2A(null!, "/agent")); - } - - [Fact] - public void MapWellKnownAgentCard_RequiresNonNullTaskManager() - { - // Arrange - var app = WebApplication.CreateBuilder().Build(); - - // Act & Assert - Assert.Throws(() => app.MapWellKnownAgentCard(null!, "/agent")); - } -} \ No newline at end of file +using Microsoft.AspNetCore.Builder; +using Moq; + +namespace A2A.AspNetCore.Tests; + +public class A2AEndpointRouteBuilderExtensionsTests +{ + [Fact] + public void MapA2A_RegistersEndpoint_WithCorrectPath() + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + var requestHandler = new Mock().Object; + + // Act & Assert - Should not throw + var result = app.MapA2A(requestHandler, "/agent"); + Assert.NotNull(result); + } + + [Fact] + public void MapWellKnownAgentCard_RegistersEndpoint() + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + var agentCard = new AgentCard { Name = "Test", Description = "Test agent" }; + + // Act & Assert - Should not throw + var result = app.MapWellKnownAgentCard(agentCard); + Assert.NotNull(result); + } + + [Fact] + public void MapA2A_And_MapWellKnownAgentCard_Together_RegistersBothEndpoints() + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + var requestHandler = new Mock().Object; + var agentCard = new AgentCard { Name = "Test", Description = "Test agent" }; + + // Act & Assert - Should not throw when calling both + var result1 = app.MapA2A(requestHandler, "/agent"); + var result2 = app.MapWellKnownAgentCard(agentCard); + + Assert.NotNull(result1); + Assert.NotNull(result2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void MapA2A_ThrowsArgumentException_WhenPathIsNullOrEmpty(string? path) + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + var requestHandler = new Mock().Object; + + // Act & Assert + if (path == null) + { + Assert.Throws(() => app.MapA2A(requestHandler, path!)); + } + else + { + Assert.Throws(() => app.MapA2A(requestHandler, path)); + } + } + + [Fact] + public void MapA2A_RequiresNonNullRequestHandler() + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + + // Act & Assert + Assert.Throws(() => app.MapA2A(null!, "/agent")); + } + + [Fact] + public void MapWellKnownAgentCard_RequiresNonNullAgentCard() + { + // Arrange + var app = WebApplication.CreateBuilder().Build(); + + // Act & Assert + Assert.Throws(() => app.MapWellKnownAgentCard(null!)); + } +} diff --git a/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs b/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs index 440da983..b39a4f73 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs @@ -1,173 +1,182 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using System.Text.Json; - -namespace A2A.AspNetCore.Tests; - -public class A2AHttpProcessorTests -{ - [Fact] - public async Task GetAgentCard_ShouldReturnValidJsonResult() - { - // Arrange - var taskManager = new TaskManager(); - var logger = NullLogger.Instance; - - // Act - var result = await A2AHttpProcessor.GetAgentCardAsync(taskManager, logger, "http://example.com", CancellationToken.None); - (int statusCode, string? contentType, AgentCard agentCard) = await GetAgentCardResponse(result); - - // Assert - Assert.Equal(StatusCodes.Status200OK, statusCode); - Assert.Equal("application/json; charset=utf-8", contentType); - Assert.Equal("Unknown", agentCard.Name); - } - - [Fact] - public async Task GetTask_ShouldReturnNotNull() - { - // Arrange - var taskStore = new InMemoryTaskStore(); - await taskStore.SetTaskAsync(new AgentTask - { - Id = "testId", - }); - var taskManager = new TaskManager(taskStore: taskStore); - var logger = NullLogger.Instance; - var id = "testId"; - var historyLength = 10; - - // Act - var result = await A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, null, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Fact] - public async Task CancelTask_ShouldReturnNotNull() - { - // Arrange - var taskStore = new InMemoryTaskStore(); - await taskStore.SetTaskAsync(new AgentTask - { - Id = "testId", - }); - var taskManager = new TaskManager(taskStore: taskStore); - var logger = NullLogger.Instance; - var id = "testId"; - - // Act - var result = await A2AHttpProcessor.CancelTaskAsync(taskManager, logger, id, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Fact] - public async Task SendTaskMessage_ShouldReturnNotNull() - { - // Arrange - var taskStore = new InMemoryTaskStore(); - await taskStore.SetTaskAsync(new AgentTask - { - Id = "testId", - }); - var taskManager = new TaskManager(taskStore: taskStore); - var logger = NullLogger.Instance; - var sendParams = new MessageSendParams - { - Message = { TaskId = "testId" }, - Configuration = new() { HistoryLength = 10 } - }; - - // Act - var result = await A2AHttpProcessor.SendMessageAsync(taskManager, logger, sendParams, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.IsType(result); - } - - [Theory] - [InlineData(A2AErrorCode.TaskNotFound, StatusCodes.Status404NotFound)] - [InlineData(A2AErrorCode.MethodNotFound, StatusCodes.Status404NotFound)] - [InlineData(A2AErrorCode.InvalidRequest, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.InvalidParams, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.TaskNotCancelable, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.UnsupportedOperation, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.ParseError, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.PushNotificationNotSupported, StatusCodes.Status400BadRequest)] - [InlineData(A2AErrorCode.ContentTypeNotSupported, StatusCodes.Status422UnprocessableEntity)] - [InlineData(A2AErrorCode.InternalError, StatusCodes.Status500InternalServerError)] - public async Task GetTask_WithA2AException_ShouldMapToCorrectHttpStatusCode(A2AErrorCode errorCode, int expectedStatusCode) - { - // Arrange - var mockTaskStore = new Mock(); - mockTaskStore - .Setup(ts => ts.GetTaskAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new A2AException("Test exception", errorCode)); - - var taskManager = new TaskManager(taskStore: mockTaskStore.Object); - var logger = NullLogger.Instance; - var id = "testId"; - var historyLength = 10; - - // Act - var result = await A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, null, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedStatusCode, ((IStatusCodeHttpResult)result).StatusCode); - } - - [Fact] - public async Task GetTask_WithUnknownA2AErrorCode_ShouldReturn500InternalServerError() - { - // Arrange - var mockTaskStore = new Mock(); - // Create an A2AException with an unknown/invalid error code by casting an integer that doesn't correspond to any enum value - var unknownErrorCode = (A2AErrorCode)(-99999); - mockTaskStore - .Setup(ts => ts.GetTaskAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new A2AException("Test exception with unknown error code", unknownErrorCode)); - - var taskManager = new TaskManager(taskStore: mockTaskStore.Object); - var logger = NullLogger.Instance; - var id = "testId"; - var historyLength = 10; - - // Act - var result = await A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, null, CancellationToken.None); - - // Assert - Assert.NotNull(result); - Assert.Equal(StatusCodes.Status500InternalServerError, ((IStatusCodeHttpResult)result).StatusCode); - } - - private static async Task<(int statusCode, string? contentType, AgentCard agentCard)> GetAgentCardResponse(IResult responseResult) - { - ServiceCollection services = new(); - services.AddSingleton(new NullLoggerFactory()); - services.Configure(jsonOptions => jsonOptions.SerializerOptions.TypeInfoResolver = A2AJsonUtilities.DefaultOptions.TypeInfoResolver); - using ServiceProvider serviceProvider = services.BuildServiceProvider(); - HttpContext context = new DefaultHttpContext() - { - RequestServices = serviceProvider - }; - using MemoryStream memoryStream = new(); - context.Response.Body = memoryStream; - - await responseResult.ExecuteAsync(context); - - context.Response.Body.Position = 0; - var card = await JsonSerializer.DeserializeAsync(context.Response.Body, A2AJsonUtilities.DefaultOptions); - return (context.Response.StatusCode, context.Response.ContentType, card!); - } -} \ No newline at end of file +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using System.Text.Json; + +namespace A2A.AspNetCore.Tests; + +public class A2AHttpProcessorTests +{ + private sealed class TestAgentHandler : IAgentHandler + { + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + // For SendMessage: return the existing task if continuation, or a message + if (context.IsContinuation) + { + await eventQueue.EnqueueTaskAsync(context.Task!, cancellationToken); + } + else + { + await eventQueue.EnqueueMessageAsync(new Message + { + Role = Role.Agent, + MessageId = Guid.NewGuid().ToString(), + Parts = [Part.FromText("ok")], + }, cancellationToken); + } + + eventQueue.Complete(); + } + + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(cancellationToken); + } + } + + private static (A2AServer requestHandler, InMemoryTaskStore store) CreateServer() + { + var notifier = new ChannelEventNotifier(); + var store = new InMemoryTaskStore(); + var handler = new TestAgentHandler(); + return (new A2AServer(handler, store, notifier, NullLogger.Instance), store); + } + + [Fact] + public async Task GetTask_ShouldReturnNotNull() + { + // Arrange + var (requestHandler, store) = CreateServer(); + var agentTask = new AgentTask + { + Id = "testId", + ContextId = "ctx-1", + }; + await store.SaveTaskAsync(agentTask.Id, agentTask); + var logger = NullLogger.Instance; + + // Act + var result = await A2AHttpProcessor.GetTaskAsync(requestHandler, logger, "testId", 10, null, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public async Task CancelTask_ShouldReturnNotNull() + { + // Arrange + var (requestHandler, store) = CreateServer(); + var agentTask = new AgentTask + { + Id = "testId", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + await store.SaveTaskAsync(agentTask.Id, agentTask); + var logger = NullLogger.Instance; + + // Act + var result = await A2AHttpProcessor.CancelTaskAsync(requestHandler, logger, "testId", CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public async Task SendTaskMessage_ShouldReturnNotNull() + { + // Arrange + var (requestHandler, store) = CreateServer(); + var agentTask = new AgentTask + { + Id = "testId", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + await store.SaveTaskAsync(agentTask.Id, agentTask); + var logger = NullLogger.Instance; + var sendRequest = new SendMessageRequest + { + Message = new Message + { + TaskId = "testId", + Role = Role.User, + Parts = [Part.FromText("hi")], + }, + Configuration = new SendMessageConfiguration { HistoryLength = 10 } + }; + + // Act + var result = await A2AHttpProcessor.SendMessageAsync(requestHandler, logger, sendRequest, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [InlineData(A2AErrorCode.TaskNotFound, StatusCodes.Status404NotFound)] + [InlineData(A2AErrorCode.MethodNotFound, StatusCodes.Status404NotFound)] + [InlineData(A2AErrorCode.InvalidRequest, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.InvalidParams, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.TaskNotCancelable, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.UnsupportedOperation, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.ParseError, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.PushNotificationNotSupported, StatusCodes.Status400BadRequest)] + [InlineData(A2AErrorCode.ContentTypeNotSupported, StatusCodes.Status422UnprocessableEntity)] + [InlineData(A2AErrorCode.InternalError, StatusCodes.Status500InternalServerError)] + public async Task GetTask_WithA2AException_ShouldMapToCorrectHttpStatusCode(A2AErrorCode errorCode, int expectedStatusCode) + { + // Arrange + var mockTaskStore = new Mock(); + mockTaskStore + .Setup(ts => ts.GetTaskAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new A2AException("Test exception", errorCode)); + + var handler = new Mock().Object; + var notifier = new ChannelEventNotifier(); + var requestHandler = new A2AServer(handler, mockTaskStore.Object, notifier, NullLogger.Instance); + var logger = NullLogger.Instance; + var id = "testId"; + var historyLength = 10; + + // Act + var result = await A2AHttpProcessor.GetTaskAsync(requestHandler, logger, id, historyLength, null, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedStatusCode, ((IStatusCodeHttpResult)result).StatusCode); + } + + [Fact] + public async Task GetTask_WithUnknownA2AErrorCode_ShouldReturn500InternalServerError() + { + // Arrange + var mockTaskStore = new Mock(); + // Create an A2AException with an unknown/invalid error code by casting an integer that doesn't correspond to any enum value + var unknownErrorCode = (A2AErrorCode)(-99999); + mockTaskStore + .Setup(ts => ts.GetTaskAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new A2AException("Test exception with unknown error code", unknownErrorCode)); + + var handler = new Mock().Object; + var notifier = new ChannelEventNotifier(); + var requestHandler = new A2AServer(handler, mockTaskStore.Object, notifier, NullLogger.Instance); + var logger = NullLogger.Instance; + var id = "testId"; + var historyLength = 10; + + // Act + var result = await A2AHttpProcessor.GetTaskAsync(requestHandler, logger, id, historyLength, null, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(StatusCodes.Status500InternalServerError, ((IStatusCodeHttpResult)result).StatusCode); + } +} diff --git a/tests/A2A.AspNetCore.UnitTests/A2AJsonRpcProcessorTests.cs b/tests/A2A.AspNetCore.UnitTests/A2AJsonRpcProcessorTests.cs index 38ab6c9e..9cff9db6 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2AJsonRpcProcessorTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/A2AJsonRpcProcessorTests.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; using System.Text; using System.Text.Json; @@ -15,18 +18,17 @@ public class A2AJsonRpcProcessorTests public async Task ValidateIdField_HandlesVariousIdTypes(object? idValue, bool isValid) { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); var jsonRequest = $$""" { "jsonrpc": "2.0", - "method": "{{A2AMethods.MessageSend}}", + "method": "{{A2AMethods.SendMessage}}", "id": {{idValue}}, "params": { "message": { - "kind" : "message", "messageId": "test-message-id", - "role": "user", - "parts": [{ "kind":"text","text":"hi" }] + "role": "ROLE_USER", + "parts": [{"text":"hi"}] } } } @@ -35,7 +37,7 @@ public async Task ValidateIdField_HandlesVariousIdTypes(object? idValue, bool is var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -60,17 +62,16 @@ public async Task ValidateIdField_HandlesVariousIdTypes(object? idValue, bool is public async Task EmptyPartsArrayIsNotAllowed() { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); var jsonRequest = $$""" { "jsonrpc": "2.0", - "method": "{{A2AMethods.MessageSend}}", + "method": "{{A2AMethods.SendMessage}}", "id": "some", "params": { "message": { - "kind": "message", "messageId": "test-message-id", - "role": "user", + "role": "ROLE_USER", "parts": [] } } @@ -79,7 +80,7 @@ public async Task EmptyPartsArrayIsNotAllowed() var httpRequest = CreateHttpRequestFromJson(jsonRequest); - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); var responseResult = Assert.IsType(result); var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); @@ -92,14 +93,14 @@ public async Task EmptyPartsArrayIsNotAllowed() } [Theory] - [InlineData("\"method\": \"message/send\",", null)] // Valid method - should succeed + [InlineData("\"method\": \"SendMessage\",", null)] // Valid method - should succeed [InlineData("\"method\": \"invalid/method\",", -32601)] // Invalid method - should return method not found error [InlineData("\"method\": \"\",", -32600)] // Empty method - should return invalid request error [InlineData("", -32600)] // Missing method field - should return invalid request error public async Task ValidateMethodField_HandlesVariousMethodTypes(string methodPropertySnippet, int? expectedErrorCode) { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); // Build JSON with conditional method property inclusion var hasMethodProperty = !string.IsNullOrEmpty(methodPropertySnippet); @@ -110,10 +111,9 @@ public async Task ValidateMethodField_HandlesVariousMethodTypes(string methodPro "id": "test-id", "params": { "message": { - "kind" : "message", "messageId": "test-message-id", - "role": "user", - "parts": [{ "kind":"text","text":"hi" }] + "role": "ROLE_USER", + "parts": [{"text":"hi"}] } } } @@ -122,7 +122,7 @@ public async Task ValidateMethodField_HandlesVariousMethodTypes(string methodPro var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -145,7 +145,7 @@ public async Task ValidateMethodField_HandlesVariousMethodTypes(string methodPro } [Theory] - [InlineData("{\"message\":{\"kind\":\"message\", \"messageId\":\"test\", \"role\": \"user\", \"parts\": [{\"kind\":\"text\",\"text\":\"hi\"}]}}", null)] // Valid object params - should succeed + [InlineData("{\"message\":{\"messageId\":\"test\", \"role\": \"ROLE_USER\", \"parts\": [{\"text\":\"hi\"}]}}", null)] // Valid object params - should succeed [InlineData("[]", -32602)] // Array params - should return invalid params error [InlineData("\"string-params\"", -32602)] // String params - should return invalid params error [InlineData("42", -32602)] // Number params - should return invalid params error @@ -154,11 +154,11 @@ public async Task ValidateMethodField_HandlesVariousMethodTypes(string methodPro public async Task ValidateParamsField_HandlesVariousParamsTypes(string paramsValue, int? expectedErrorCode) { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); var jsonRequest = $$""" { "jsonrpc": "2.0", - "method": "{{A2AMethods.MessageSend}}", + "method": "{{A2AMethods.SendMessage}}", "id": "test-id", "params": {{paramsValue}} } @@ -167,7 +167,7 @@ public async Task ValidateParamsField_HandlesVariousParamsTypes(string paramsVal var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -192,18 +192,18 @@ public async Task ValidateParamsField_HandlesVariousParamsTypes(string paramsVal } [Theory] - [InlineData("{\"invalidField\": \"not_message\"}", "Invalid parameters for MessageSendParams")] // Wrong field structure - [InlineData("{\"message\": \"not_object\"}", "Invalid parameters for MessageSendParams")] // Wrong field type - [InlineData("{\"message\": {\"kind\": \"invalid\"}}", "Invalid parameters for MessageSendParams")] // Invalid discriminator - [InlineData("{\"\":\"not_a_dict\"}", "Invalid parameters for MessageSendParams")] // Invalid discriminator + [InlineData("{\"invalidField\": \"not_message\"}", "Invalid parameters: request body could not be deserialized as SendMessageRequest")] // Wrong field structure + [InlineData("{\"message\": \"not_object\"}", "Invalid parameters: request body could not be deserialized as SendMessageRequest")] // Wrong field type + [InlineData("{\"message\": {\"kind\": \"invalid\"}}", "Invalid parameters: request body could not be deserialized as SendMessageRequest")] // Missing required fields + [InlineData("{\"\":\"not_a_dict\"}", "Invalid parameters: request body could not be deserialized as SendMessageRequest")] // Missing message field public async Task ValidateParamsContent_HandlesInvalidParamsStructure(string paramsValue, string expectedErrorPrefix) { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); var jsonRequest = $$""" { "jsonrpc": "2.0", - "method": "{{A2AMethods.MessageSend}}", + "method": "{{A2AMethods.SendMessage}}", "id": "test-content-validation", "params": {{paramsValue}} } @@ -212,7 +212,7 @@ public async Task ValidateParamsContent_HandlesInvalidParamsStructure(string par var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -230,22 +230,22 @@ public async Task ValidateParamsContent_HandlesInvalidParamsStructure(string par [Fact] public async Task ProcessRequest_SingleResponse_MessageSend_Works() { - TaskManager taskManager = new(); - MessageSendParams sendParams = new() + var (requestHandler, _) = CreateTestServerWithStore(); + SendMessageRequest sendRequest = new() { - Message = new AgentMessage { MessageId = "test-message-id", Parts = [new TextPart { Text = "hi" }] } + Message = new Message { MessageId = "test-message-id", Role = Role.User, Parts = [Part.FromText("hi")] } }; JsonRpcRequest req = new() { Id = "1", - Method = A2AMethods.MessageSend, - Params = ToJsonElement(sendParams) + Method = A2AMethods.SendMessage, + Params = ToJsonElement(sendRequest) }; var httpRequest = CreateHttpRequest(req); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -256,13 +256,14 @@ public async Task ProcessRequest_SingleResponse_MessageSend_Works() Assert.Equal("application/json", ContentType); Assert.NotNull(BodyContent.Result); - var agentTask = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); + var sendMessageResponse = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); - Assert.NotNull(agentTask); + Assert.NotNull(sendMessageResponse?.Task); + var agentTask = sendMessageResponse.Task; Assert.Equal(TaskState.Submitted, agentTask.Status.State); - Assert.NotEmpty(agentTask.History); - Assert.Equal(MessageRole.User, agentTask.History[0].Role); - Assert.Equal("hi", ((TextPart)agentTask.History[0].Parts[0]).Text); + Assert.NotEmpty(agentTask.History!); + Assert.Equal(Role.User, agentTask.History[0].Role); + Assert.Equal("hi", agentTask.History[0].Parts[0].Text); Assert.Equal("test-message-id", agentTask.History[0].MessageId); } @@ -270,18 +271,18 @@ public async Task ProcessRequest_SingleResponse_MessageSend_Works() public async Task ProcessRequest_SingleResponse_InvalidParams_ReturnsError() { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); var req = new JsonRpcRequest { Id = "2", - Method = A2AMethods.MessageSend, + Method = A2AMethods.SendMessage, Params = null }; var httpRequest = CreateHttpRequest(req); // Act - var result = await A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, httpRequest, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -303,13 +304,19 @@ public async Task ProcessRequest_SingleResponse_InvalidParams_ReturnsError() public async Task SingleResponse_TaskGet_Works() { // Arrange - var taskManager = new TaskManager(); - var task = await taskManager.CreateTaskAsync(); + var (requestHandler, store) = CreateTestServerWithStore(); + var task = new AgentTask + { + Id = Guid.NewGuid().ToString(), + ContextId = Guid.NewGuid().ToString(), + Status = new TaskStatus { State = TaskState.Submitted } + }; + await store.SaveTaskAsync(task.Id, task); - var queryParams = new TaskQueryParams { Id = task.Id }; + var getTaskRequest = new GetTaskRequest { Id = task.Id }; // Act - var result = await A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "4", A2AMethods.TaskGet, ToJsonElement(queryParams), CancellationToken.None); + var result = await A2AJsonRpcProcessor.SingleResponseAsync(requestHandler, "4", A2AMethods.GetTask, ToJsonElement(getTaskRequest), CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -323,32 +330,45 @@ public async Task SingleResponse_TaskGet_Works() var agentTask = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); Assert.NotNull(agentTask); Assert.Equal(TaskState.Submitted, agentTask.Status.State); - Assert.Empty(agentTask.History); } [Fact] - public async Task NegativeHistoryLengthThrows() + public async Task SingleResponse_TaskGet_NegativeHistoryLength_ReturnsInvalidParams() { - TaskManager taskManager = new(); - TaskQueryParams queryParams = new() { Id = "doesNotMatter", HistoryLength = -1 }; - - A2AException result = await Assert.ThrowsAsync( - () => A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "4", A2AMethods.TaskGet, ToJsonElement(queryParams), CancellationToken.None)); + // Arrange - Negative historyLength is invalid per spec + var (requestHandler, store) = CreateTestServerWithStore(); + var task = new AgentTask + { + Id = Guid.NewGuid().ToString(), + ContextId = Guid.NewGuid().ToString(), + Status = new TaskStatus { State = TaskState.Submitted }, + History = [new Message { MessageId = "msg1", Role = Role.User, Parts = [Part.FromText("hello")] }] + }; + await store.SaveTaskAsync(task.Id, task); + GetTaskRequest getTaskRequest = new() { Id = task.Id, HistoryLength = -1 }; - Assert.Equal(A2AErrorCode.InvalidParams, result.ErrorCode); - Assert.Equal("History length cannot be negative", result.Message); + // Act & Assert — A2AServer.GetTaskAsync throws InvalidParams for negative historyLength + var ex = await Assert.ThrowsAsync(() => + A2AJsonRpcProcessor.SingleResponseAsync(requestHandler, "4", A2AMethods.GetTask, ToJsonElement(getTaskRequest), CancellationToken.None)); + Assert.Equal(A2AErrorCode.InvalidParams, ex.ErrorCode); } [Fact] public async Task SingleResponse_TaskCancel_Works() { // Arrange - var taskManager = new TaskManager(); - var newTask = await taskManager.CreateTaskAsync(); - var cancelParams = new TaskIdParams { Id = newTask.Id }; + var (requestHandler, store) = CreateTestServerWithStore(); + var newTask = new AgentTask + { + Id = Guid.NewGuid().ToString(), + ContextId = Guid.NewGuid().ToString(), + Status = new TaskStatus { State = TaskState.Submitted } + }; + await store.SaveTaskAsync(newTask.Id, newTask); + var cancelRequest = new CancelTaskRequest { Id = newTask.Id }; // Act - var result = await A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "5", A2AMethods.TaskCancel, ToJsonElement(cancelParams), CancellationToken.None); + var result = await A2AJsonRpcProcessor.SingleResponseAsync(requestHandler, "5", A2AMethods.CancelTask, ToJsonElement(cancelRequest), CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -362,25 +382,16 @@ public async Task SingleResponse_TaskCancel_Works() var agentTask = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); Assert.NotNull(agentTask); Assert.Equal(TaskState.Canceled, agentTask.Status.State); - Assert.Empty(agentTask.History); } [Fact] - public async Task SingleResponse_TaskPushNotificationConfigSet_Works() + public async Task StreamResponse_SendStreamingMessage_InvalidParams_ReturnsError() { // Arrange - var taskManager = new TaskManager(); - var config = new TaskPushNotificationConfig - { - TaskId = "test-task", - PushNotificationConfig = new PushNotificationConfig() - { - Url = "https://example.com/notify", - } - }; + var requestHandler = CreateTestServer(); // Act - var result = await A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "6", A2AMethods.TaskPushNotificationConfigSet, ToJsonElement(config), CancellationToken.None); + var result = A2AJsonRpcProcessor.StreamResponse(requestHandler, "10", A2AMethods.SendStreamingMessage, null, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); @@ -389,143 +400,384 @@ public async Task SingleResponse_TaskPushNotificationConfigSet_Works() Assert.Equal(StatusCodes.Status200OK, StatusCode); Assert.Equal("application/json", ContentType); - Assert.NotNull(BodyContent); - var notificationConfig = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); - Assert.NotNull(notificationConfig); + Assert.NotNull(BodyContent); + Assert.Null(BodyContent.Result); - Assert.Equal("test-task", notificationConfig.TaskId); - Assert.Equal("https://example.com/notify", notificationConfig.PushNotificationConfig.Url); + Assert.NotNull(BodyContent.Error); + Assert.Equal(-32602, BodyContent.Error!.Code); // Invalid params + Assert.Equal("Invalid parameters", BodyContent.Error.Message); } - [Fact] - public async Task SingleResponse_TaskPushNotificationConfigGet_Works() + /// Creates a test A2AServer with in-memory store and default callbacks. + private static IA2ARequestHandler CreateTestServer() { - // Arrange - var taskManager = new TaskManager(); + return CreateTestServerWithStore().requestHandler; + } - var task = await taskManager.CreateTaskAsync(); + /// Creates a test A2AServer with store exposed for pre-populating data. + private static (IA2ARequestHandler requestHandler, InMemoryTaskStore store) CreateTestServerWithStore() + { + var notifier = new ChannelEventNotifier(); + var store = new InMemoryTaskStore(); + var handler = new TestAgentHandler(); + var requestHandler = new A2AServer(handler, store, notifier, NullLogger.Instance); + return (requestHandler, store); + } - var config = new TaskPushNotificationConfig + private sealed class TestAgentHandler : IAgentHandler + { + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { - TaskId = task.Id, - PushNotificationConfig = new PushNotificationConfig() + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + var task = new AgentTask { - Url = "https://example.com/notify", - } - }; - await taskManager.SetPushNotificationAsync(config); - var getParams = new GetTaskPushNotificationConfigParams { Id = task.Id }; + Id = context.TaskId, + ContextId = updater.ContextId, + Status = new TaskStatus { State = TaskState.Submitted }, + History = [context.Message], + }; + await eventQueue.EnqueueTaskAsync(task, cancellationToken); + eventQueue.Complete(); + } + + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(cancellationToken); + } + } + + private static JsonElement ToJsonElement(T obj) + { + var json = JsonSerializer.Serialize(obj, A2AJsonUtilities.DefaultOptions); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + private static HttpRequest CreateHttpRequest(object request) + { + var context = new DefaultHttpContext(); + var json = JsonSerializer.Serialize(request, A2AJsonUtilities.DefaultOptions); + return CreateHttpRequestFromJson(json); + } + + private static HttpRequest CreateHttpRequestFromJson(string json) + { + var context = new DefaultHttpContext(); + var bytes = Encoding.UTF8.GetBytes(json); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentType = "application/json"; + return context.Request; + } + + [Fact] + public async Task ProcessRequestAsync_ListTasks_ReturnsEmptyResult() + { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.ListTasks}}", + "id": "list-1", + "params": {} + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "7", A2AMethods.TaskPushNotificationConfigGet, ToJsonElement(getParams), CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); - var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); Assert.Equal(StatusCodes.Status200OK, StatusCode); Assert.Equal("application/json", ContentType); - Assert.NotNull(BodyContent); - - var notificationConfig = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); - Assert.NotNull(notificationConfig); + Assert.NotNull(BodyContent.Result); + Assert.Null(BodyContent.Error); - Assert.Equal(task.Id, notificationConfig.TaskId); - Assert.Equal("https://example.com/notify", notificationConfig.PushNotificationConfig.Url); + var listResponse = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); + Assert.NotNull(listResponse); + Assert.Empty(listResponse.Tasks); } [Fact] - public async Task SingleResponse_TaskPushNotificationConfigGet_WithConfigId_Works() + public async Task ProcessRequestAsync_ListTasks_InvalidPageSize_ReturnsError() { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.ListTasks}}", + "id": "list-2", + "params": { "pageSize": 0 } + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); - var task = await taskManager.CreateTaskAsync(); + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); - var config = new TaskPushNotificationConfig - { - TaskId = task.Id, - PushNotificationConfig = new PushNotificationConfig() - { - Url = "https://example.com/notify2", - Id = "specific-config-id" - } - }; - await taskManager.SetPushNotificationAsync(config); - var getParams = new GetTaskPushNotificationConfigParams + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.Equal("application/json", ContentType); + Assert.Null(BodyContent.Result); + Assert.NotNull(BodyContent.Error); + Assert.Equal((int)A2AErrorCode.InvalidParams, BodyContent.Error.Code); + Assert.Contains("pageSize", BodyContent.Error.Message); + } + + [Fact] + public async Task ProcessRequestAsync_ListTasks_NegativeHistoryLength_ReturnsError() + { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" { - Id = task.Id, - PushNotificationConfigId = "specific-config-id" - }; + "jsonrpc": "2.0", + "method": "{{A2AMethods.ListTasks}}", + "id": "list-3", + "params": { "historyLength": -1 } + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = await A2AJsonRpcProcessor.SingleResponseAsync(taskManager, "8", A2AMethods.TaskPushNotificationConfigGet, ToJsonElement(getParams), CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); - var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); Assert.Equal(StatusCodes.Status200OK, StatusCode); Assert.Equal("application/json", ContentType); - Assert.NotNull(BodyContent); + Assert.Null(BodyContent.Result); + Assert.NotNull(BodyContent.Error); + Assert.Equal((int)A2AErrorCode.InvalidParams, BodyContent.Error.Code); + Assert.Contains("historyLength", BodyContent.Error.Message); + } - var notificationConfig = JsonSerializer.Deserialize(BodyContent.Result, A2AJsonUtilities.DefaultOptions); - Assert.NotNull(notificationConfig); + [Fact] + public async Task ProcessRequestAsync_PushNotificationMethod_ReturnsNotSupported() + { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.CreateTaskPushNotificationConfig}}", + "id": "pn-1", + "params": { "taskId": "some-task", "pushNotificationConfig": { "url": "https://example.com/callback" } } + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); - Assert.Equal(task.Id, notificationConfig.TaskId); - Assert.Equal("https://example.com/notify2", notificationConfig.PushNotificationConfig.Url); - Assert.Equal("specific-config-id", notificationConfig.PushNotificationConfig.Id); + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); + + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.Equal("application/json", ContentType); + Assert.Null(BodyContent.Result); + Assert.NotNull(BodyContent.Error); + Assert.Equal((int)A2AErrorCode.PushNotificationNotSupported, BodyContent.Error.Code); } [Fact] - public async Task StreamResponse_MessageStream_InvalidParams_ReturnsError() + public async Task ProcessRequestAsync_GetExtendedAgentCard_ReturnsNotConfigured() { // Arrange - var taskManager = new TaskManager(); + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.GetExtendedAgentCard}}", + "id": "card-1", + "params": {} + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); // Act - var result = A2AJsonRpcProcessor.StreamResponse(taskManager, "10", A2AMethods.MessageStream, null, CancellationToken.None); + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); // Assert var responseResult = Assert.IsType(result); - var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); Assert.Equal(StatusCodes.Status200OK, StatusCode); Assert.Equal("application/json", ContentType); - - Assert.NotNull(BodyContent); Assert.Null(BodyContent.Result); - Assert.NotNull(BodyContent.Error); - Assert.Equal(-32602, BodyContent.Error!.Code); // Invalid params - Assert.Equal("Invalid parameters", BodyContent.Error.Message); + Assert.Equal((int)A2AErrorCode.ExtendedAgentCardNotConfigured, BodyContent.Error.Code); } - private static JsonElement ToJsonElement(T obj) + [Fact] + public async Task ProcessRequestAsync_VersionNegotiation_EmptyHeader_Succeeds() { - var json = JsonSerializer.Serialize(obj, A2AJsonUtilities.DefaultOptions); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); + // Arrange - no A2A-Version header set + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.SendMessage}}", + "id": "ver-1", + "params": { + "message": { + "messageId": "test-msg", + "role": "ROLE_USER", + "parts": [{"text":"hello"}] + } + } + } + """; + + var httpRequest = CreateHttpRequestFromJson(jsonRequest); + + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); + + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, _, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.NotNull(BodyContent.Result); + Assert.Null(BodyContent.Error); } - private static HttpRequest CreateHttpRequest(object request) + [Fact] + public async Task ProcessRequestAsync_VersionNegotiation_V10_Succeeds() { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.SendMessage}}", + "id": "ver-2", + "params": { + "message": { + "messageId": "test-msg", + "role": "ROLE_USER", + "parts": [{"text":"hello"}] + } + } + } + """; + var context = new DefaultHttpContext(); - var json = JsonSerializer.Serialize(request, A2AJsonUtilities.DefaultOptions); - return CreateHttpRequestFromJson(json); + var bytes = Encoding.UTF8.GetBytes(jsonRequest); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentType = "application/json"; + context.Request.Headers["A2A-Version"] = "1.0"; + var httpRequest = context.Request; + + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); + + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, _, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.NotNull(BodyContent.Result); + Assert.Null(BodyContent.Error); } - private static HttpRequest CreateHttpRequestFromJson(string json) + [Fact] + public async Task ProcessRequestAsync_VersionNegotiation_V03_Succeeds() { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.SendMessage}}", + "id": "ver-3", + "params": { + "message": { + "messageId": "test-msg", + "role": "ROLE_USER", + "parts": [{"text":"hello"}] + } + } + } + """; + var context = new DefaultHttpContext(); - var bytes = Encoding.UTF8.GetBytes(json); + var bytes = Encoding.UTF8.GetBytes(jsonRequest); context.Request.Body = new MemoryStream(bytes); context.Request.ContentType = "application/json"; - return context.Request; + context.Request.Headers["A2A-Version"] = "0.3"; + var httpRequest = context.Request; + + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); + + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, _, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.NotNull(BodyContent.Result); + Assert.Null(BodyContent.Error); + } + + [Fact] + public async Task ProcessRequestAsync_VersionNegotiation_Unsupported_ReturnsError() + { + // Arrange + var requestHandler = CreateTestServer(); + var jsonRequest = $$""" + { + "jsonrpc": "2.0", + "method": "{{A2AMethods.SendMessage}}", + "id": "ver-4", + "params": { + "message": { + "messageId": "test-msg", + "role": "ROLE_USER", + "parts": [{"text":"hello"}] + } + } + } + """; + + var context = new DefaultHttpContext(); + var bytes = Encoding.UTF8.GetBytes(jsonRequest); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentType = "application/json"; + context.Request.Headers["A2A-Version"] = "2.0"; + var httpRequest = context.Request; + + // Act + var result = await A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, httpRequest, CancellationToken.None); + + // Assert + var responseResult = Assert.IsType(result); + var (StatusCode, ContentType, BodyContent) = await GetJsonRpcResponseHttpDetails(responseResult); + + Assert.Equal(StatusCodes.Status200OK, StatusCode); + Assert.Equal("application/json", ContentType); + Assert.Null(BodyContent.Result); + Assert.NotNull(BodyContent.Error); + Assert.Equal((int)A2AErrorCode.VersionNotSupported, BodyContent.Error.Code); + Assert.Contains("2.0", BodyContent.Error.Message); } private static async Task<(int StatusCode, string? ContentType, TBody BodyContent)> GetJsonRpcResponseHttpDetails(JsonRpcResponseResult responseResult) diff --git a/tests/A2A.AspNetCore.UnitTests/ClientTests.cs b/tests/A2A.AspNetCore.UnitTests/ClientTests.cs index a5f028f0..aedc70ba 100644 --- a/tests/A2A.AspNetCore.UnitTests/ClientTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/ClientTests.cs @@ -29,10 +29,8 @@ public async Task TestGetTask() var taskId = "test-task"; // Act - await client.GetTaskAsync(taskId); - var message = mockHandler.Request?.Content != null - ? await mockHandler.Request.Content.ReadAsStringAsync() - : string.Empty; + await client.GetTaskAsync(new GetTaskRequest { Id = taskId }); + var message = mockHandler.RequestBody ?? string.Empty; // Assert Assert.NotNull(message); @@ -46,23 +44,21 @@ public async Task TestGetTask() public async Task TestSendMessage() { // Arrange - var taskSendParams = new MessageSendParams + var sendRequest = new SendMessageRequest { - Message = new AgentMessage() + Message = new Message() { + Role = Role.User, Parts = [ - new TextPart() - { - Text = "Hello, World!", - } + Part.FromText("Hello, World!"), ], }, }; // Act - await client.SendMessageAsync(taskSendParams); - var message = await mockHandler!.Request!.Content!.ReadAsStringAsync(); + await client.SendMessageAsync(sendRequest); + var message = mockHandler.RequestBody ?? string.Empty; // Assert Assert.NotNull(message); @@ -80,8 +76,8 @@ public async Task TestCancelTask() var taskId = "test-task"; // Act - await client.CancelTaskAsync(new TaskIdParams { Id = taskId }); - var message = await mockHandler!.Request!.Content!.ReadAsStringAsync(); + await client.CancelTaskAsync(new CancelTaskRequest { Id = taskId }); + var message = mockHandler.RequestBody ?? string.Empty; // Assert Assert.NotNull(message); @@ -93,7 +89,7 @@ public async Task TestCancelTask() } [Fact] - public async Task TestSetPushNotification() + public async Task TestCreatePushNotificationConfig() { // Arrange @@ -102,15 +98,15 @@ public async Task TestSetPushNotification() { var pushNotificationResponse = new TaskPushNotificationConfig { + Id = "response-config-id", TaskId = "test-task", PushNotificationConfig = new PushNotificationConfig { Url = "http://example.org/notify", - Id = "response-config-id", Token = "test-token", - Authentication = new PushNotificationAuthenticationInfo + Authentication = new AuthenticationInfo { - Schemes = ["Bearer"] + Scheme = "Bearer" } } }; @@ -122,24 +118,23 @@ public async Task TestSetPushNotification() }; }; - var pushNotificationConfig = new TaskPushNotificationConfig + var createRequest = new CreateTaskPushNotificationConfigRequest { TaskId = "test-task", - PushNotificationConfig = new PushNotificationConfig() + Config = new PushNotificationConfig() { Url = "http://example.org/notify", - Id = "request-config-id", Token = "test-token", - Authentication = new PushNotificationAuthenticationInfo() + Authentication = new AuthenticationInfo() { - Schemes = ["Bearer"], + Scheme = "Bearer", } } }; // Act - await client.SetPushNotificationAsync(pushNotificationConfig); - var message = await mockHandler!.Request!.Content!.ReadAsStringAsync(); + await client.CreateTaskPushNotificationConfigAsync(createRequest); + var message = mockHandler.RequestBody ?? string.Empty; // Assert Assert.NotNull(message); @@ -169,16 +164,23 @@ public JsonSchemaFixture() public class MockMessageHandler : HttpMessageHandler { public HttpRequestMessage? Request { get; private set; } + public string? RequestBody { get; private set; } public Func? ResponseProvider { get; set; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Request = request; + // Capture the request body before the content may be disposed + if (request.Content is not null) + { + RequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + } + // Use custom response provider if available, otherwise create default var response = ResponseProvider?.Invoke(request) ?? CreateDefaultResponse(request); - return Task.FromResult(response); + return response; } private static HttpResponseMessage CreateDefaultResponse(HttpRequestMessage request) @@ -188,7 +190,7 @@ private static HttpResponseMessage CreateDefaultResponse(HttpRequestMessage requ { Id = "dummy-task-id", ContextId = "dummy-context-id", - Status = new AgentTaskStatus + Status = new TaskStatus { State = TaskState.Completed, } diff --git a/tests/A2A.AspNetCore.UnitTests/ProcessMessageTests.cs b/tests/A2A.AspNetCore.UnitTests/ProcessMessageTests.cs deleted file mode 100644 index 96501f1b..00000000 --- a/tests/A2A.AspNetCore.UnitTests/ProcessMessageTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -// using System; -// using System.Threading; -// using System.Threading.Tasks; -// using Xunit; -// using A2A; - -// namespace A2A.AspNetCore.Tests; - -// // These become A2AProcessor tests -// public class ProcessMessageTests -// { -// [Fact] -// public async Task ProcessMessage_SendAndGetTask_Works() -// { -// var taskManager = new TaskManager(); -// var taskId = Guid.NewGuid().ToString(); -// var sendParams = new TaskSendParams -// { -// Id = taskId, -// Message = new Message -// { -// Parts = [ new TextPart { Text = "Hello, World!" } ] -// } -// }; -// var sendRequest = new JsonRpcRequest -// { -// Id = Guid.NewGuid().ToString(), -// Method = A2AMethods.TaskSend, -// Params = sendParams -// }; -// var sendResponse = await taskManager.ProcessMessageAsync(sendRequest, CancellationToken.None); -// Assert.IsType(sendResponse); -// var sendResult = ((JsonRpcResponse)sendResponse).Result as AgentTask; -// Assert.NotNull(sendResult); -// Assert.Equal(taskId, sendResult.Id); -// Assert.Equal(TaskState.Submitted, sendResult.Status.State); - -// var getRequest = new JsonRpcRequest -// { -// Id = Guid.NewGuid().ToString(), -// Method = A2AMethods.TaskGet, -// Params = new TaskIdParams { Id = taskId } -// }; -// var getResponse = await taskManager.ProcessMessageAsync(getRequest, CancellationToken.None); -// Assert.IsType(getResponse); -// var getResult = ((JsonRpcResponse)getResponse).Result as AgentTask; -// Assert.NotNull(getResult); -// Assert.Equal(taskId, getResult.Id); -// Assert.Equal(TaskState.Submitted, getResult.Status.State); -// } - -// [Fact] -// public async Task ProcessMessage_CancelTask_Works() -// { -// var taskManager = new TaskManager(); -// var taskId = Guid.NewGuid().ToString(); -// var sendParams = new TaskSendParams -// { -// Id = taskId, -// Message = new Message -// { -// Parts = [ new TextPart { Text = "Hello, World!" } ] -// } -// }; -// var sendRequest = new JsonRpcRequest -// { -// Id = Guid.NewGuid().ToString(), -// Method = A2AMethods.TaskSend, -// Params = sendParams -// }; -// await taskManager.ProcessMessageAsync(sendRequest, CancellationToken.None); - -// var cancelRequest = new JsonRpcRequest -// { -// Id = Guid.NewGuid().ToString(), -// Method = A2AMethods.TaskCancel, -// Params = new TaskIdParams { Id = taskId } -// }; -// var cancelResponse = await taskManager.ProcessMessageAsync(cancelRequest, CancellationToken.None); -// Assert.IsType(cancelResponse); -// var cancelResult = ((JsonRpcResponse)cancelResponse).Result as AgentTask; -// Assert.NotNull(cancelResult); -// Assert.Equal(taskId, cancelResult.Id); -// Assert.Equal(TaskState.Canceled, cancelResult.Status.State); -// } -// [Fact] -// public async Task ProcessMessage_SetPushNotification_Works() -// { -// var taskManager = new TaskManager(); -// var taskId = Guid.NewGuid().ToString(); -// var pushNotificationConfig = new TaskPushNotificationConfig -// { -// Id = taskId, -// PushNotificationConfig = new PushNotificationConfig -// { -// Url = "http://example.com/notify", -// } -// }; -// var setRequest = new JsonRpcRequest -// { -// Id = Guid.NewGuid().ToString(), -// Method = A2AMethods.TaskPushNotificationConfigSet, -// Params = pushNotificationConfig -// }; -// var setResponse = await taskManager.ProcessMessageAsync(setRequest, CancellationToken.None); -// Assert.IsType(setResponse); -// var setResult = ((JsonRpcResponse)setResponse).Result as TaskPushNotificationConfig; -// Assert.NotNull(setResult); -// Assert.Equal(taskId, setResult.Id); -// Assert.Equal("http://example.com/notify", setResult.PushNotificationConfig.Url); -// } -// } diff --git a/tests/A2A.UnitTests/A2A.UnitTests.csproj b/tests/A2A.UnitTests/A2A.UnitTests.csproj index 652dd6fa..b66a3c11 100644 --- a/tests/A2A.UnitTests/A2A.UnitTests.csproj +++ b/tests/A2A.UnitTests/A2A.UnitTests.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net10.0;net8.0 enable enable diff --git a/tests/A2A.UnitTests/Client/A2ACardResolverTests.cs b/tests/A2A.UnitTests/Client/A2ACardResolverTests.cs index d7467c8d..9a71996e 100644 --- a/tests/A2A.UnitTests/Client/A2ACardResolverTests.cs +++ b/tests/A2A.UnitTests/Client/A2ACardResolverTests.cs @@ -14,12 +14,11 @@ public async Task GetAgentCardAsync_ReturnsAgentCard() { Name = "Test Agent", Description = "A test agent", - Url = "http://localhost", - Version = "1.0.0", - Capabilities = new AgentCapabilities { Streaming = true, PushNotifications = false, StateTransitionHistory = true }, + SupportedInterfaces = [new AgentInterface { Url = "http://localhost", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + Capabilities = new AgentCapabilities { Streaming = true, PushNotifications = false }, Skills = [new AgentSkill { Id = "test", Name = "Test Skill", Description = "desc", Tags = [] }] }; - var json = JsonSerializer.Serialize(agentCard); + var json = JsonSerializer.Serialize(agentCard, A2AJsonUtilities.DefaultOptions); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") @@ -35,10 +34,9 @@ public async Task GetAgentCardAsync_ReturnsAgentCard() Assert.NotNull(result); Assert.Equal(agentCard.Name, result.Name); Assert.Equal(agentCard.Description, result.Description); - Assert.Equal(agentCard.Url, result.Url); - Assert.Equal(agentCard.Version, result.Version); - Assert.Equal(agentCard.Capabilities.Streaming, result.Capabilities.Streaming); - Assert.Single(result.Skills); + Assert.Single(result.SupportedInterfaces); + Assert.Equal(agentCard.Capabilities.Streaming, result.Capabilities!.Streaming); + Assert.Single(result.Skills!); } [Fact] diff --git a/tests/A2A.UnitTests/Client/A2AClientTests.cs b/tests/A2A.UnitTests/Client/A2AClientTests.cs index 703cedfd..93387c63 100644 --- a/tests/A2A.UnitTests/Client/A2AClientTests.cs +++ b/tests/A2A.UnitTests/Client/A2AClientTests.cs @@ -1,649 +1,403 @@ -using System.Net; -using System.Net.ServerSentEvents; -using System.Text; -using System.Text.Json; - -namespace A2A.UnitTests.Client; - -public class A2AClientTests -{ - [Fact] - public async Task SendMessageAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req); - - var sendParams = new MessageSendParams - { - Message = new AgentMessage - { - Parts = [new TextPart { Text = "Hello" }], - Role = MessageRole.User, - MessageId = "msg-1", - TaskId = "task-1", - ContextId = "ctx-1", - Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } }, - ReferenceTaskIds = ["ref-1"] - }, - Configuration = new MessageSendConfiguration - { - AcceptedOutputModes = ["mode1"], - PushNotification = new PushNotificationConfig { Url = "http://push" }, - HistoryLength = 5, - Blocking = true - }, - Metadata = new Dictionary { { "baz", JsonDocument.Parse("\"qux\"").RootElement } } - }; - - // Act - await sut.SendMessageAsync(sendParams); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("message/send", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - - Assert.Equal(sendParams.Message.Parts.Count, parameters.Message.Parts.Count); - Assert.Equal(((TextPart)sendParams.Message.Parts[0]).Text, ((TextPart)parameters.Message.Parts[0]).Text); - Assert.Equal(sendParams.Message.Role, parameters.Message.Role); - Assert.Equal(sendParams.Message.MessageId, parameters.Message.MessageId); - Assert.Equal(sendParams.Message.TaskId, parameters.Message.TaskId); - Assert.Equal(sendParams.Message.ContextId, parameters.Message.ContextId); - Assert.Equal(sendParams.Message.Metadata["foo"].GetString(), parameters.Message.Metadata!["foo"].GetString()); - Assert.Equal(sendParams.Message.ReferenceTaskIds[0], parameters.Message.ReferenceTaskIds![0]); - - Assert.NotNull(parameters.Configuration); - Assert.Equal(sendParams.Configuration.AcceptedOutputModes[0], parameters.Configuration.AcceptedOutputModes![0]); - Assert.Equal(sendParams.Configuration.PushNotification.Url, parameters.Configuration.PushNotification!.Url); - Assert.Equal(sendParams.Configuration.HistoryLength, parameters.Configuration.HistoryLength); - Assert.Equal(sendParams.Configuration.Blocking, parameters.Configuration.Blocking); - - Assert.Equal(sendParams.Metadata["baz"].GetString(), parameters.Metadata!["baz"].GetString()); - } - - [Fact] - public async Task SendMessageAsync_MapsResponseCorrectly() - { - // Arrange - var expectedMessage = new AgentMessage - { - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Test text" }, - new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, - ], - Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, - ReferenceTaskIds = ["ref1", "ref2"], - MessageId = "msg-123", - TaskId = "task-456", - ContextId = "ctx-789" - }; - - var sut = CreateA2AClient(expectedMessage); - - var sendParams = new MessageSendParams(); - - // Act - var result = await sut.SendMessageAsync(sendParams) as AgentMessage; - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedMessage.Role, result.Role); - Assert.Equal(expectedMessage.Parts.Count, result.Parts.Count); - Assert.IsType(result.Parts[0]); - Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)result.Parts[0]).Text); - Assert.IsType(result.Parts[1]); - Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)result.Parts[1]).Data["key"].GetString()); - Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), result.Metadata!["metaKey"].GetString()); - Assert.Equal(expectedMessage.ReferenceTaskIds, result.ReferenceTaskIds); - Assert.Equal(expectedMessage.MessageId, result.MessageId); - Assert.Equal(expectedMessage.TaskId, result.TaskId); - Assert.Equal(expectedMessage.ContextId, result.ContextId); - } - - [Fact] - public async Task GetTaskAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new AgentTask { Id = "id-1", ContextId = "ctx-1" }, req => capturedRequest = req); - - var taskId = "task-1"; - - // Act - await sut.GetTaskAsync(taskId); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("tasks/get", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(taskId, parameters.Id); - } - - [Fact] - public async Task GetTaskAsync_MapsResponseCorrectly() - { - // Arrange - var expectedTask = new AgentTask - { - Id = "task-1", - ContextId = "ctx-ctx", - Status = new AgentTaskStatus { State = TaskState.Working }, - Artifacts = [new Artifact { ArtifactId = "a1", Parts = { new TextPart { Text = "part" } } }], - History = [new AgentMessage { MessageId = "m1" }], - Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } } - }; - - var sut = CreateA2AClient(expectedTask); - - // Act - var result = await sut.GetTaskAsync("task-1"); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedTask.Id, result.Id); - Assert.Equal(expectedTask.ContextId, result.ContextId); - Assert.Equal(expectedTask.Status.State, result.Status.State); - Assert.Equal(expectedTask.Artifacts![0].ArtifactId, result.Artifacts![0].ArtifactId); - Assert.Equal(((TextPart)expectedTask.Artifacts![0].Parts[0]).Text, ((TextPart)result.Artifacts![0].Parts[0]).Text); - Assert.Equal(expectedTask.History![0].MessageId, result.History![0].MessageId); - Assert.Equal(expectedTask.Metadata!["foo"].GetString(), result.Metadata!["foo"].GetString()); - } - - [Fact] - public async Task CancelTaskAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new AgentTask { Id = "task-2" }, req => capturedRequest = req); - - var taskIdParams = new TaskIdParams - { - Id = "task-2", - Metadata = new Dictionary { { "meta", JsonDocument.Parse("\"val\"").RootElement } } - }; - - // Act - await sut.CancelTaskAsync(taskIdParams); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("tasks/cancel", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(taskIdParams.Id, parameters.Id); - Assert.Equal(taskIdParams.Metadata!["meta"].GetString(), parameters.Metadata!["meta"].GetString()); - } - - [Fact] - public async Task CancelTaskAsync_MapsResponseCorrectly() - { - // Arrange - var expectedTask = new AgentTask - { - Id = "task-1", - ContextId = "ctx-ctx", - Status = new AgentTaskStatus { State = TaskState.Working }, - Artifacts = [new Artifact { ArtifactId = "a1", Parts = { new TextPart { Text = "part" } } }], - History = [new AgentMessage { MessageId = "m1" }], - Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } } - }; - - var sut = CreateA2AClient(expectedTask); - - var taskIdParams = new TaskIdParams { Id = "task-2" }; - - // Act - var result = await sut.CancelTaskAsync(taskIdParams); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedTask.Id, result.Id); - Assert.Equal(expectedTask.ContextId, result.ContextId); - Assert.Equal(expectedTask.Status.State, result.Status.State); - Assert.Equal(expectedTask.Artifacts![0].ArtifactId, result.Artifacts![0].ArtifactId); - Assert.Equal(((TextPart)expectedTask.Artifacts![0].Parts[0]).Text, ((TextPart)result.Artifacts![0].Parts[0]).Text); - Assert.Equal(expectedTask.History![0].MessageId, result.History![0].MessageId); - Assert.Equal(expectedTask.Metadata!["foo"].GetString(), result.Metadata!["foo"].GetString()); - } - - [Fact] - public async Task SetPushNotificationAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new TaskPushNotificationConfig() { TaskId = "id-1", PushNotificationConfig = new PushNotificationConfig() { Url = "url-1" } }, req => capturedRequest = req); - - var pushConfig = new TaskPushNotificationConfig - { - TaskId = "task-3", - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://push-url", - Id = "push-config-123", - Token = "tok", - Authentication = new PushNotificationAuthenticationInfo - { - Schemes = ["Bearer"], - } - } - }; - - // Act - await sut.SetPushNotificationAsync(pushConfig); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("tasks/pushNotificationConfig/set", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(pushConfig.TaskId, parameters.TaskId); - Assert.Equal(pushConfig.PushNotificationConfig.Url, parameters.PushNotificationConfig.Url); - Assert.Equal(pushConfig.PushNotificationConfig.Id, parameters.PushNotificationConfig.Id); - Assert.Equal(pushConfig.PushNotificationConfig.Token, parameters.PushNotificationConfig.Token); - Assert.Equal(pushConfig.PushNotificationConfig.Authentication!.Schemes, parameters.PushNotificationConfig.Authentication!.Schemes); - } - - [Fact] - public async Task SetPushNotificationAsync_MapsResponseCorrectly() - { - // Arrange - var expectedConfig = new TaskPushNotificationConfig - { - TaskId = "task-3", - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://push-url", - Id = "push-config-456", - Token = "tok", - Authentication = new PushNotificationAuthenticationInfo - { - Schemes = ["Bearer"], - } - } - }; - - var sut = CreateA2AClient(expectedConfig); - - // Act - var result = await sut.SetPushNotificationAsync(expectedConfig); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedConfig.TaskId, result.TaskId); - Assert.Equal(expectedConfig.PushNotificationConfig.Url, result.PushNotificationConfig.Url); - Assert.Equal(expectedConfig.PushNotificationConfig.Token, result.PushNotificationConfig.Token); - Assert.Equal(expectedConfig.PushNotificationConfig.Authentication!.Schemes, result.PushNotificationConfig.Authentication!.Schemes); - } - - [Fact] - public async Task GetPushNotificationAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var config = new TaskPushNotificationConfig { TaskId = "task-4", PushNotificationConfig = new PushNotificationConfig { Url = "url-1" } }; - - var sut = CreateA2AClient(config, req => capturedRequest = req); - - var notificationConfigParams = new GetTaskPushNotificationConfigParams - { - Id = "task-4", - Metadata = new Dictionary { { "meta", JsonDocument.Parse("\"val\"").RootElement } }, - PushNotificationConfigId = "config-123" - }; - - // Act - await sut.GetPushNotificationAsync(notificationConfigParams); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("tasks/pushNotificationConfig/get", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(notificationConfigParams.Id, parameters.Id); - Assert.Equal(notificationConfigParams.Metadata!["meta"].GetString(), parameters.Metadata!["meta"].GetString()); - Assert.Equal(notificationConfigParams.PushNotificationConfigId, parameters.PushNotificationConfigId); - } - - [Fact] - public async Task GetPushNotificationAsync_MapsResponseCorrectly() - { - // Arrange - var expectedConfig = new TaskPushNotificationConfig - { - TaskId = "task-4", - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://push-url2", - Token = "tok2", - Authentication = new PushNotificationAuthenticationInfo - { - Schemes = ["Bearer"] - } - } - }; - - var sut = CreateA2AClient(expectedConfig); - - var notificationConfigParams = new GetTaskPushNotificationConfigParams { Id = "task-4" }; - - // Act - var result = await sut.GetPushNotificationAsync(notificationConfigParams); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedConfig.TaskId, result.TaskId); - Assert.Equal(expectedConfig.PushNotificationConfig.Url, result.PushNotificationConfig.Url); - Assert.Equal(expectedConfig.PushNotificationConfig.Token, result.PushNotificationConfig.Token); - Assert.Equal(expectedConfig.PushNotificationConfig.Authentication!.Schemes, result.PushNotificationConfig.Authentication!.Schemes); - } - - [Fact] - public async Task GetPushNotificationAsync_WithPushNotificationConfigId_MapsRequestCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var config = new TaskPushNotificationConfig { TaskId = "task-5", PushNotificationConfig = new PushNotificationConfig { Url = "url-1" } }; - - var sut = CreateA2AClient(config, req => capturedRequest = req); - - var notificationConfigParams = new GetTaskPushNotificationConfigParams - { - Id = "task-5", - PushNotificationConfigId = "specific-config-id" - }; - - // Act - await sut.GetPushNotificationAsync(notificationConfigParams); - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(notificationConfigParams.Id, parameters.Id); - Assert.Equal(notificationConfigParams.PushNotificationConfigId, parameters.PushNotificationConfigId); - Assert.Null(parameters.Metadata); - } - - [Fact] - public async Task SendMessageStreamingAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req, isSse: true); - - var sendParams = new MessageSendParams - { - Message = new AgentMessage - { - Parts = [new TextPart { Text = "Hello" }], - Role = MessageRole.User, - MessageId = "msg-1", - TaskId = "task-1", - ContextId = "ctx-1", - Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } }, - ReferenceTaskIds = ["ref-1"] - }, - Configuration = new MessageSendConfiguration - { - AcceptedOutputModes = ["mode1"], - PushNotification = new PushNotificationConfig { Url = "http://push" }, - HistoryLength = 5, - Blocking = true - }, - Metadata = new Dictionary { { "baz", JsonDocument.Parse("\"qux\"").RootElement } } - }; - - // Act - await foreach (var _ in sut.SendMessageStreamingAsync(sendParams)) - { - break; // Only need to trigger the request - } - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("message/stream", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(sendParams.Message.Parts.Count, parameters.Message.Parts.Count); - Assert.Equal(((TextPart)sendParams.Message.Parts[0]).Text, ((TextPart)parameters.Message.Parts[0]).Text); - Assert.Equal(sendParams.Message.Role, parameters.Message.Role); - Assert.Equal(sendParams.Message.MessageId, parameters.Message.MessageId); - Assert.Equal(sendParams.Message.TaskId, parameters.Message.TaskId); - Assert.Equal(sendParams.Message.ContextId, parameters.Message.ContextId); - Assert.Equal(sendParams.Message.Metadata["foo"].GetString(), parameters.Message.Metadata!["foo"].GetString()); - Assert.Equal(sendParams.Message.ReferenceTaskIds[0], parameters.Message.ReferenceTaskIds![0]); - Assert.NotNull(parameters.Configuration); - Assert.Equal(sendParams.Configuration.AcceptedOutputModes[0], parameters.Configuration.AcceptedOutputModes![0]); - Assert.Equal(sendParams.Configuration.PushNotification.Url, parameters.Configuration.PushNotification!.Url); - Assert.Equal(sendParams.Configuration.HistoryLength, parameters.Configuration.HistoryLength); - Assert.Equal(sendParams.Configuration.Blocking, parameters.Configuration.Blocking); - Assert.Equal(sendParams.Metadata["baz"].GetString(), parameters.Metadata!["baz"].GetString()); - } - - [Fact] - public async Task SendMessageStreamingAsync_MapsResponseCorrectly() - { - // Arrange - var expectedMessage = new AgentMessage - { - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Test text" }, - new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, - ], - Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, - ReferenceTaskIds = ["ref1", "ref2"], - MessageId = "msg-123", - TaskId = "task-456", - ContextId = "ctx-789" - }; - - var sut = CreateA2AClient(expectedMessage, isSse: true); - - var sendParams = new MessageSendParams(); - - // Act - SseItem? result = null; - await foreach (var item in sut.SendMessageStreamingAsync(sendParams)) - { - result = item; - break; - } - - // Assert - Assert.NotNull(result); - var message = Assert.IsType(result.Value.Data); - Assert.Equal(expectedMessage.Role, message.Role); - Assert.Equal(expectedMessage.Parts.Count, message.Parts.Count); - Assert.IsType(message.Parts[0]); - Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)message.Parts[0]).Text); - Assert.IsType(message.Parts[1]); - Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)message.Parts[1]).Data["key"].GetString()); - Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), message.Metadata!["metaKey"].GetString()); - Assert.Equal(expectedMessage.ReferenceTaskIds, message.ReferenceTaskIds); - Assert.Equal(expectedMessage.MessageId, message.MessageId); - Assert.Equal(expectedMessage.TaskId, message.TaskId); - Assert.Equal(expectedMessage.ContextId, message.ContextId); - } - - [Fact] - public async Task SubscribeToTaskAsync_MapsRequestParamsCorrectly() - { - // Arrange - HttpRequestMessage? capturedRequest = null; - - var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req, isSse: true); - - var taskId = "task-123"; - - // Act - await foreach (var _ in sut.SubscribeToTaskAsync(taskId)) - { - break; // Only need to trigger the request - } - - // Assert - Assert.NotNull(capturedRequest); - - var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); - Assert.Equal("tasks/resubscribe", requestJson.RootElement.GetProperty("method").GetString()); - Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); - - var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); - Assert.NotNull(parameters); - Assert.Equal(taskId, parameters.Id); - } - - [Fact] - public async Task SubscribeToTaskAsync_MapsResponseCorrectly() - { - // Arrange - var expectedMessage = new AgentMessage - { - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Test text" }, - new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, - ], - Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, - ReferenceTaskIds = ["ref1", "ref2"], - MessageId = "msg-123", - TaskId = "task-456", - ContextId = "ctx-789" - }; - - var sut = CreateA2AClient(expectedMessage, isSse: true); - - // Act - SseItem? result = null; - await foreach (var item in sut.SubscribeToTaskAsync("task-123")) - { - result = item; - break; - } - - // Assert - Assert.NotNull(result); - var message = Assert.IsType(result.Value.Data); - Assert.Equal(expectedMessage.Role, message.Role); - Assert.Equal(expectedMessage.Parts.Count, message.Parts.Count); - Assert.IsType(message.Parts[0]); - Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)message.Parts[0]).Text); - Assert.IsType(message.Parts[1]); - Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)message.Parts[1]).Data["key"].GetString()); - Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), message.Metadata!["metaKey"].GetString()); - Assert.Equal(expectedMessage.ReferenceTaskIds, message.ReferenceTaskIds); - Assert.Equal(expectedMessage.MessageId, message.MessageId); - Assert.Equal(expectedMessage.TaskId, message.TaskId); - Assert.Equal(expectedMessage.ContextId, message.ContextId); - } - - [Fact] - public async Task SendMessageStreamingAsync_ThrowsOnJsonRpcError() - { - // Arrange - var sut = CreateA2AClient(JsonRpcResponse.InvalidParamsResponse("test-id"), isSse: true); - - var sendParams = new MessageSendParams(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - { - await foreach (var _ in sut.SendMessageStreamingAsync(sendParams)) - { - // Should throw before yielding any items - } - }); - - Assert.Equal(A2AErrorCode.InvalidParams, exception.ErrorCode); - Assert.Contains("Invalid parameters", exception.Message); - } - - [Fact] - public async Task SendMessageAsync_ThrowsOnJsonRpcError() - { - // Arrange - var sut = CreateA2AClient(JsonRpcResponse.MethodNotFoundResponse("test-id")); - - var sendParams = new MessageSendParams(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - { - await sut.SendMessageAsync(sendParams); - }); - - Assert.Equal(A2AErrorCode.MethodNotFound, exception.ErrorCode); - Assert.Contains("Method not found", exception.Message); - } - - private static A2AClient CreateA2AClient(object result, Action? onRequest = null, bool isSse = false) - { - var response = new JsonRpcResponse - { - Id = "test-id", - Result = JsonSerializer.SerializeToNode(result) - }; - - return CreateA2AClient(response, onRequest, isSse); - } - - private static A2AClient CreateA2AClient(JsonRpcResponse jsonResponse, Action? onRequest = null, bool isSse = false) - { - var responseContent = JsonSerializer.Serialize(jsonResponse); - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - isSse ? $"event: message\ndata: {responseContent}\n\n" : responseContent, - Encoding.UTF8, - isSse ? "text/event-stream" : "application/json") - }; - - var handler = new MockHttpMessageHandler(response, onRequest); - - var httpClient = new HttpClient(handler); - - return new A2AClient(new Uri("http://localhost"), httpClient); - } -} +using System.Net; +using System.Text; +using System.Text.Json; + +namespace A2A.UnitTests.Client; + +public class A2AClientTests +{ + [Fact] + public async Task SendMessageAsync_MapsRequestParamsCorrectly() + { + // Arrange + string? capturedBody = null; + + var responseResult = new SendMessageResponse + { + Message = new Message { MessageId = "id-1", Role = Role.User, Parts = [] } + }; + var sut = CreateA2AClient(responseResult, req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + var sendRequest = new SendMessageRequest + { + Message = new Message + { + Parts = [Part.FromText("Hello")], + Role = Role.User, + MessageId = "msg-1", + TaskId = "task-1", + ContextId = "ctx-1", + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } }, + ReferenceTaskIds = ["ref-1"] + }, + Configuration = new SendMessageConfiguration + { + AcceptedOutputModes = ["mode1"], + PushNotificationConfig = new PushNotificationConfig { Url = "http://push" }, + HistoryLength = 5, + Blocking = true + }, + Metadata = new Dictionary { { "baz", JsonDocument.Parse("\"qux\"").RootElement } } + }; + + // Act + await sut.SendMessageAsync(sendRequest); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.SendMessage, requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(parameters); + + Assert.Equal(sendRequest.Message.Parts.Count, parameters.Message.Parts.Count); + Assert.Equal(sendRequest.Message.Parts[0].Text, parameters.Message.Parts[0].Text); + Assert.Equal(sendRequest.Message.Role, parameters.Message.Role); + Assert.Equal(sendRequest.Message.MessageId, parameters.Message.MessageId); + } + + [Fact] + public async Task SendMessageAsync_MapsResponseCorrectly() + { + // Arrange + var expectedResponse = new SendMessageResponse + { + Message = new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Test text")], + MessageId = "msg-123", + TaskId = "task-456", + ContextId = "ctx-789" + } + }; + + var sut = CreateA2AClient(expectedResponse); + + var sendRequest = new SendMessageRequest { Message = new Message { Parts = [], Role = Role.User } }; + + // Act + var result = await sut.SendMessageAsync(sendRequest); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Message); + Assert.Equal(expectedResponse.Message.Role, result.Message!.Role); + Assert.Single(result.Message.Parts); + Assert.Equal("Test text", result.Message.Parts[0].Text); + Assert.Equal(expectedResponse.Message.MessageId, result.Message.MessageId); + } + + [Fact] + public async Task GetTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient(new AgentTask { Id = "id-1", ContextId = "ctx-1" }, req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + var request = new GetTaskRequest { Id = "task-1" }; + + // Act + await sut.GetTaskAsync(request); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.GetTask, requestJson.RootElement.GetProperty("method").GetString()); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(parameters); + Assert.Equal("task-1", parameters.Id); + } + + [Fact] + public async Task GetTaskAsync_MapsResponseCorrectly() + { + // Arrange + var expectedTask = new AgentTask + { + Id = "task-1", + ContextId = "ctx-ctx", + Status = new TaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = { Part.FromText("part") } }], + History = [new Message { MessageId = "m1" }], + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } } + }; + + var sut = CreateA2AClient(expectedTask); + + // Act + var result = await sut.GetTaskAsync(new GetTaskRequest { Id = "task-1" }); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedTask.Id, result.Id); + Assert.Equal(expectedTask.ContextId, result.ContextId); + Assert.Equal(expectedTask.Status.State, result.Status.State); + Assert.Equal(expectedTask.Artifacts![0].ArtifactId, result.Artifacts![0].ArtifactId); + } + + [Fact] + public async Task CancelTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient(new AgentTask { Id = "task-2" }, req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + var cancelRequest = new CancelTaskRequest + { + Id = "task-2", + }; + + // Act + await sut.CancelTaskAsync(cancelRequest); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.CancelTask, requestJson.RootElement.GetProperty("method").GetString()); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(parameters); + Assert.Equal(cancelRequest.Id, parameters.Id); + } + + [Fact] + public async Task SendStreamingMessageAsync_MapsResponseCorrectly() + { + // Arrange + var expectedResponse = new StreamResponse + { + Message = new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Test text")], + MessageId = "msg-123", + } + }; + + var sut = CreateA2AClient(expectedResponse, isSse: true); + + var sendRequest = new SendMessageRequest { Message = new Message { Parts = [], Role = Role.User } }; + + // Act + StreamResponse? result = null; + await foreach (var item in sut.SendStreamingMessageAsync(sendRequest)) + { + result = item; + break; + } + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Message); + Assert.Equal(expectedResponse.Message.Role, result.Message!.Role); + Assert.Single(result.Message.Parts); + Assert.Equal("Test text", result.Message.Parts[0].Text); + } + + [Fact] + public async Task SubscribeToTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + string? capturedBody = null; + + var responseResult = new StreamResponse + { + Message = new Message { MessageId = "id-1", Role = Role.User, Parts = [] } + }; + var sut = CreateA2AClient(responseResult, req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(), isSse: true); + + var request = new SubscribeToTaskRequest { Id = "task-123" }; + + // Act + await foreach (var _ in sut.SubscribeToTaskAsync(request)) + { + break; + } + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.SubscribeToTask, requestJson.RootElement.GetProperty("method").GetString()); + } + + [Fact] + public async Task SendStreamingMessageAsync_ThrowsOnJsonRpcError() + { + // Arrange + var sut = CreateA2AClient(JsonRpcResponse.InvalidParamsResponse("test-id"), isSse: true); + + var sendRequest = new SendMessageRequest { Message = new Message { Parts = [], Role = Role.User } }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in sut.SendStreamingMessageAsync(sendRequest)) + { + } + }); + + Assert.Equal(A2AErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SendMessageAsync_ThrowsOnJsonRpcError() + { + // Arrange + var sut = CreateA2AClient(JsonRpcResponse.MethodNotFoundResponse("test-id")); + + var sendRequest = new SendMessageRequest { Message = new Message { Parts = [], Role = Role.User } }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await sut.SendMessageAsync(sendRequest); + }); + + Assert.Equal(A2AErrorCode.MethodNotFound, exception.ErrorCode); + } + + [Fact] + public async Task ListTasksAsync_SendsCorrectMethodAndParams() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient( + new ListTasksResponse { Tasks = [] }, + req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + var request = new ListTasksRequest { ContextId = "ctx-1" }; + + // Act + await sut.ListTasksAsync(request); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.ListTasks, requestJson.RootElement.GetProperty("method").GetString()); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(parameters); + Assert.Equal("ctx-1", parameters.ContextId); + } + + [Fact] + public async Task CancelTaskAsync_SendsCorrectMethod() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient( + new AgentTask { Id = "task-1" }, + req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + // Act + await sut.CancelTaskAsync(new CancelTaskRequest { Id = "task-1" }); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.CancelTask, requestJson.RootElement.GetProperty("method").GetString()); + } + + [Fact] + public async Task GetExtendedAgentCardAsync_SendsCorrectMethod() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient( + new AgentCard { Name = "Agent", Description = "Desc", SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }] }, + req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + // Act + await sut.GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest()); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.GetExtendedAgentCard, requestJson.RootElement.GetProperty("method").GetString()); + } + + [Fact] + public async Task CreatePushNotificationConfigAsync_SendsCorrectMethod() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient( + new TaskPushNotificationConfig { Id = "cfg-1", TaskId = "t-1", PushNotificationConfig = new PushNotificationConfig { Url = "http://push" } }, + req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + // Act + await sut.CreateTaskPushNotificationConfigAsync(new CreateTaskPushNotificationConfigRequest { TaskId = "t-1", ConfigId = "cfg-1", Config = new PushNotificationConfig { Url = "http://push" } }); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.CreateTaskPushNotificationConfig, requestJson.RootElement.GetProperty("method").GetString()); + } + + [Fact] + public async Task DeletePushNotificationConfigAsync_SendsCorrectMethod() + { + // Arrange + string? capturedBody = null; + + var sut = CreateA2AClient( + new object(), + req => capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult()); + + // Act + await sut.DeleteTaskPushNotificationConfigAsync(new DeleteTaskPushNotificationConfigRequest { Id = "cfg-1", TaskId = "t-1" }); + + // Assert + Assert.NotNull(capturedBody); + + var requestJson = JsonDocument.Parse(capturedBody); + Assert.Equal(A2AMethods.DeleteTaskPushNotificationConfig, requestJson.RootElement.GetProperty("method").GetString()); + } + + private static A2AClient CreateA2AClient(object result, Action? onRequest = null, bool isSse = false) + { + var response = new JsonRpcResponse + { + Id = "test-id", + Result = JsonSerializer.SerializeToNode(result, A2AJsonUtilities.DefaultOptions) + }; + + return CreateA2AClient(response, onRequest, isSse); + } + + private static A2AClient CreateA2AClient(JsonRpcResponse jsonResponse, Action? onRequest = null, bool isSse = false) + { + var responseContent = JsonSerializer.Serialize(jsonResponse, A2AJsonUtilities.DefaultOptions); + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + isSse ? $"event: message\ndata: {responseContent}\n\n" : responseContent, + Encoding.UTF8, + isSse ? "text/event-stream" : "application/json") + }; + + var handler = new MockHttpMessageHandler(response, onRequest); + var httpClient = new HttpClient(handler); + + return new A2AClient(new Uri("http://localhost"), httpClient); + } +} diff --git a/tests/A2A.UnitTests/Client/JsonRpcContentTests.cs b/tests/A2A.UnitTests/Client/JsonRpcContentTests.cs index be87ef4d..638de8f8 100644 --- a/tests/A2A.UnitTests/Client/JsonRpcContentTests.cs +++ b/tests/A2A.UnitTests/Client/JsonRpcContentTests.cs @@ -43,7 +43,7 @@ public async Task Constructor_SetsContentType_AndSerializesResponse() { Id = "task-1", ContextId = "ctx-1", - Status = new AgentTaskStatus { State = TaskState.Completed } + Status = new TaskStatus { State = TaskState.Completed } }); var sut = new JsonRpcContent(response); @@ -60,7 +60,7 @@ public async Task Constructor_SetsContentType_AndSerializesResponse() var result = doc.RootElement.GetProperty("result"); Assert.Equal("task-1", result.GetProperty("id").GetString()); Assert.Equal("ctx-1", result.GetProperty("contextId").GetString()); - Assert.Equal("completed", result.GetProperty("status").GetProperty("state").GetString()); + Assert.Equal("TASK_STATE_COMPLETED", result.GetProperty("status").GetProperty("state").GetString()); } [Fact] diff --git a/tests/A2A.UnitTests/GitHubIssues/Issue160.cs b/tests/A2A.UnitTests/GitHubIssues/Issue160.cs index babbc50e..0d5613ed 100644 --- a/tests/A2A.UnitTests/GitHubIssues/Issue160.cs +++ b/tests/A2A.UnitTests/GitHubIssues/Issue160.cs @@ -7,6 +7,8 @@ public sealed class Issue160 [Fact] public void Issue_160_Passes() { + // v1 version: AgentTask no longer has "kind" discriminator + // This test verifies deserialization of a task from a JSON-RPC result var json = """ { "id": "aaa2d907-e493-483c-a569-6aa38c6951d4", @@ -16,13 +18,9 @@ public void Issue_160_Passes() { "artifactId": "artifact-1", "description": null, - "extensions": null, - "metadata": null, "name": "artifact-1", "parts": [ { - "kind": "text", - "metadata": null, "text": "Artifact update from the Movie Agent" } ] @@ -32,67 +30,44 @@ public void Issue_160_Passes() "history": [ { "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", - "extensions": null, - "kind": "message", "messageId": "From Dotnet", - "metadata": null, "parts": [ { - "kind": "text", - "metadata": null, "text": "jimmy" } ], - "referenceTaskIds": null, - "role": "user", - "taskId": null + "role": "ROLE_USER" }, { "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", - "extensions": null, - "kind": "message", "messageId": "488f7027-6805-4d1c-bafa-1bf55d438eb3", - "metadata": null, "parts": [ { - "kind": "text", - "metadata": null, "text": "Generating code..." } ], - "referenceTaskIds": null, - "role": "agent", + "role": "ROLE_AGENT", "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" }, { "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", - "extensions": null, - "kind": "message", "messageId": "31e24763-63ae-4509-9ff2-11d789640ae4", - "metadata": null, "parts": [], - "referenceTaskIds": null, - "role": "agent", + "role": "ROLE_AGENT", "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" } ], "id": "6b349583-196e-444c-a0bd-a4f22f0753f0", - "kind": "task", - "metadata": null, "status": { "message": { "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", - "extensions": null, - "kind": "message", "messageId": "31e24763-63ae-4509-9ff2-11d789640ae4", - "metadata": null, "parts": [], - "referenceTaskIds": null, - "role": "agent", + "role": "ROLE_AGENT", "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" }, - "state": "completed", - "timestamp": "2025-08-25T09:58:01.545" + "state": "TASK_STATE_COMPLETED", + "timestamp": "2025-08-25T09:58:01.545+00:00" } } } diff --git a/tests/A2A.UnitTests/JsonRpc/A2AMethodsTests.cs b/tests/A2A.UnitTests/JsonRpc/A2AMethodsTests.cs index 829ba98b..5e293920 100644 --- a/tests/A2A.UnitTests/JsonRpc/A2AMethodsTests.cs +++ b/tests/A2A.UnitTests/JsonRpc/A2AMethodsTests.cs @@ -5,10 +5,10 @@ namespace A2A.UnitTests.JsonRpc; public class A2AMethodsTests { [Fact] - public void IsStreamingMethod_ReturnsTrue_ForMessageStream() + public void IsStreamingMethod_ReturnsTrue_ForSendStreamingMessage() { // Arrange - var method = A2AMethods.MessageStream; + var method = A2AMethods.SendStreamingMessage; // Act var result = A2AMethods.IsStreamingMethod(method); @@ -18,10 +18,10 @@ public void IsStreamingMethod_ReturnsTrue_ForMessageStream() } [Fact] - public void IsStreamingMethod_ReturnsTrue_ForTaskSubscribe() + public void IsStreamingMethod_ReturnsTrue_ForSubscribeToTask() { // Arrange - var method = A2AMethods.TaskSubscribe; + var method = A2AMethods.SubscribeToTask; // Act var result = A2AMethods.IsStreamingMethod(method); @@ -31,11 +31,12 @@ public void IsStreamingMethod_ReturnsTrue_ForTaskSubscribe() } [Theory] - [InlineData(A2AMethods.MessageSend)] - [InlineData(A2AMethods.TaskGet)] - [InlineData(A2AMethods.TaskCancel)] - [InlineData(A2AMethods.TaskPushNotificationConfigSet)] - [InlineData(A2AMethods.TaskPushNotificationConfigGet)] + [InlineData(A2AMethods.SendMessage)] + [InlineData(A2AMethods.GetTask)] + [InlineData(A2AMethods.CancelTask)] + [InlineData(A2AMethods.ListTasks)] + [InlineData(A2AMethods.CreateTaskPushNotificationConfig)] + [InlineData(A2AMethods.GetTaskPushNotificationConfig)] [InlineData("unknown/method")] public void IsStreamingMethod_ReturnsFalse_ForNonStreamingMethods(string method) { @@ -48,7 +49,7 @@ public void IsStreamingMethod_ReturnsFalse_ForNonStreamingMethods(string method) [Theory] [InlineData("unknown/method")] - [InlineData("message/ssend")] + [InlineData("message/send")] [InlineData("invalid")] [InlineData("")] public void IsValidMethod_ReturnsFalse_ForInvalidMethods(string method) diff --git a/tests/A2A.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs b/tests/A2A.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs index 4e9b0644..88ed4e9b 100644 --- a/tests/A2A.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs +++ b/tests/A2A.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs @@ -23,11 +23,11 @@ public void Read_ValidJsonRpcRequest_WithAllFields_ReturnsRequest() { "jsonrpc": "2.0", "id": "test-id", - "method": "message/send", + "method": "SendMessage", "params": { "message": { "messageId": "msg-1", - "role": "user", + "role": "ROLE_USER", "parts": [] } } @@ -42,7 +42,7 @@ public void Read_ValidJsonRpcRequest_WithAllFields_ReturnsRequest() Assert.Equal("2.0", result.JsonRpc); Assert.True(result.Id.IsString); Assert.Equal("test-id", result.Id.AsString()); - Assert.Equal("message/send", result.Method); + Assert.Equal("SendMessage", result.Method); Assert.True(result.Params.HasValue); Assert.True(result.Params.Value.TryGetProperty("message", out _)); } @@ -55,7 +55,7 @@ public void Read_ValidJsonRpcRequest_WithoutParams_ReturnsRequest() { "jsonrpc": "2.0", "id": "test-id", - "method": "tasks/get" + "method": "GetTask" } """; @@ -67,7 +67,7 @@ public void Read_ValidJsonRpcRequest_WithoutParams_ReturnsRequest() Assert.Equal("2.0", result.JsonRpc); Assert.True(result.Id.IsString); Assert.Equal("test-id", result.Id.AsString()); - Assert.Equal("tasks/get", result.Method); + Assert.Equal("GetTask", result.Method); Assert.False(result.Params.HasValue); } @@ -78,7 +78,7 @@ public void Read_ValidJsonRpcRequest_WithoutId_ReturnsRequest() var json = """ { "jsonrpc": "2.0", - "method": "message/send", + "method": "SendMessage", "params": {} } """; @@ -90,7 +90,7 @@ public void Read_ValidJsonRpcRequest_WithoutId_ReturnsRequest() Assert.NotNull(result); Assert.Equal("2.0", result.JsonRpc); Assert.False(result.Id.HasValue); - Assert.Equal("message/send", result.Method); + Assert.Equal("SendMessage", result.Method); Assert.True(result.Params.HasValue); } @@ -105,7 +105,7 @@ public void Read_ValidIdTypes_ReturnsCorrectId(string idJson, string? expectedSt { "jsonrpc": "2.0", "id": {{idJson}}, - "method": "tasks/get" + "method": "GetTask" } """; @@ -132,13 +132,17 @@ public void Read_ValidIdTypes_ReturnsCorrectId(string idJson, string? expectedSt } [Theory] - [InlineData("message/send")] - [InlineData("message/stream")] - [InlineData("tasks/get")] - [InlineData("tasks/cancel")] - [InlineData("tasks/resubscribe")] - [InlineData("tasks/pushNotificationConfig/set")] - [InlineData("tasks/pushNotificationConfig/get")] + [InlineData(A2AMethods.SendMessage)] + [InlineData(A2AMethods.SendStreamingMessage)] + [InlineData(A2AMethods.GetTask)] + [InlineData(A2AMethods.ListTasks)] + [InlineData(A2AMethods.CancelTask)] + [InlineData(A2AMethods.SubscribeToTask)] + [InlineData(A2AMethods.CreateTaskPushNotificationConfig)] + [InlineData(A2AMethods.GetTaskPushNotificationConfig)] + [InlineData(A2AMethods.ListTaskPushNotificationConfig)] + [InlineData(A2AMethods.DeleteTaskPushNotificationConfig)] + [InlineData(A2AMethods.GetExtendedAgentCard)] public void Read_ValidMethods_ReturnsCorrectMethod(string method) { // Arrange @@ -169,7 +173,7 @@ public void Read_MissingJsonRpcField_ThrowsA2AException() var json = """ { "id": "test-id", - "method": "tasks/get" + "method": "GetTask" } """; @@ -193,7 +197,7 @@ public void Read_InvalidJsonRpcVersion_ThrowsA2AException(string versionJson) { "jsonrpc": {{versionJson}}, "id": "test-id", - "method": "tasks/get" + "method": "GetTask" } """; @@ -249,7 +253,7 @@ public void Read_EmptyOrNullMethod_ThrowsA2AException(string methodJson) [Theory] [InlineData("\"invalid/method\"")] [InlineData("\"unknown\"")] - [InlineData("\"message/invalid\"")] + [InlineData("\"message/send\"")] public void Read_InvalidMethod_ThrowsA2AException(string methodJson) { // Arrange @@ -280,7 +284,7 @@ public void Read_InvalidIdType_ThrowsA2AException(string idJson) { "jsonrpc": "2.0", "id": {{idJson}}, - "method": "tasks/get" + "method": "GetTask" } """; @@ -304,7 +308,7 @@ public void Read_InvalidParamsType_ThrowsA2AException(string paramsJson) { "jsonrpc": "2.0", "id": "test-id", - "method": "tasks/get", + "method": "GetTask", "params": {{paramsJson}} } """; @@ -329,7 +333,7 @@ public void Read_ErrorWithRequestId_IncludesRequestIdInException() { "jsonrpc": "1.0", "id": "error-test-id", - "method": "tasks/get" + "method": "GetTask" } """; @@ -347,7 +351,7 @@ public void Read_ErrorWithoutRequestId_HasNullRequestId() var json = """ { "jsonrpc": "1.0", - "method": "tasks/get" + "method": "GetTask" } """; @@ -371,7 +375,7 @@ public void Write_ValidJsonRpcRequest_WithAllFields_WritesCorrectJson() { JsonRpc = "2.0", Id = "test-id", - Method = "message/send", + Method = "SendMessage", Params = paramsDoc.RootElement }; @@ -384,7 +388,7 @@ public void Write_ValidJsonRpcRequest_WithAllFields_WritesCorrectJson() Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); Assert.Equal("test-id", root.GetProperty("id").GetString()); - Assert.Equal("message/send", root.GetProperty("method").GetString()); + Assert.Equal("SendMessage", root.GetProperty("method").GetString()); Assert.Equal("value", root.GetProperty("params").GetProperty("key").GetString()); } @@ -396,7 +400,7 @@ public void Write_ValidJsonRpcRequest_WithoutParams_WritesCorrectJson() { JsonRpc = "2.0", Id = "test-id", - Method = "tasks/get", + Method = "GetTask", Params = null }; @@ -409,7 +413,7 @@ public void Write_ValidJsonRpcRequest_WithoutParams_WritesCorrectJson() Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); Assert.Equal("test-id", root.GetProperty("id").GetString()); - Assert.Equal("tasks/get", root.GetProperty("method").GetString()); + Assert.Equal("GetTask", root.GetProperty("method").GetString()); Assert.False(root.TryGetProperty("params", out _)); } @@ -421,7 +425,7 @@ public void Write_ValidJsonRpcRequest_WithNullId_WritesCorrectJson() { JsonRpc = "2.0", Id = new JsonRpcId((string?)null), - Method = "tasks/get", + Method = "GetTask", Params = null }; @@ -434,7 +438,7 @@ public void Write_ValidJsonRpcRequest_WithNullId_WritesCorrectJson() Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); Assert.Equal(JsonValueKind.Null, root.GetProperty("id").ValueKind); - Assert.Equal("tasks/get", root.GetProperty("method").GetString()); + Assert.Equal("GetTask", root.GetProperty("method").GetString()); } #endregion @@ -449,7 +453,7 @@ public void RoundTrip_ValidJsonRpcRequest_PreservesAllData() { "message": { "messageId": "msg-1", - "role": "user", + "role": "ROLE_USER", "parts": [] } } @@ -459,7 +463,7 @@ public void RoundTrip_ValidJsonRpcRequest_PreservesAllData() { JsonRpc = "2.0", Id = "round-trip-test", - Method = "message/send", + Method = "SendMessage", Params = paramsDoc.RootElement }; @@ -477,13 +481,17 @@ public void RoundTrip_ValidJsonRpcRequest_PreservesAllData() } [Theory] - [InlineData("message/send")] - [InlineData("message/stream")] - [InlineData("tasks/get")] - [InlineData("tasks/cancel")] - [InlineData("tasks/resubscribe")] - [InlineData("tasks/pushNotificationConfig/set")] - [InlineData("tasks/pushNotificationConfig/get")] + [InlineData(A2AMethods.SendMessage)] + [InlineData(A2AMethods.SendStreamingMessage)] + [InlineData(A2AMethods.GetTask)] + [InlineData(A2AMethods.ListTasks)] + [InlineData(A2AMethods.CancelTask)] + [InlineData(A2AMethods.SubscribeToTask)] + [InlineData(A2AMethods.CreateTaskPushNotificationConfig)] + [InlineData(A2AMethods.GetTaskPushNotificationConfig)] + [InlineData(A2AMethods.ListTaskPushNotificationConfig)] + [InlineData(A2AMethods.DeleteTaskPushNotificationConfig)] + [InlineData(A2AMethods.GetExtendedAgentCard)] public void RoundTrip_AllValidMethods_PreservesMethod(string method) { // Arrange @@ -516,7 +524,7 @@ public void Read_ValidParamsNull_ReturnsRequestWithoutParams() { "jsonrpc": "2.0", "id": "test-id", - "method": "tasks/get", + "method": "GetTask", "params": null } """; @@ -537,7 +545,7 @@ public void Read_EmptyParamsObject_ReturnsRequestWithEmptyParams() { "jsonrpc": "2.0", "id": "test-id", - "method": "tasks/get", + "method": "GetTask", "params": {} } """; @@ -563,7 +571,7 @@ public void RoundTrip_NumericId_PreservesNumericType() { "jsonrpc": "2.0", "id": 123, - "method": "tasks/get" + "method": "GetTask" } """; @@ -602,7 +610,7 @@ public void RoundTrip_StringId_PreservesStringType() { "jsonrpc": "2.0", "id": "test-string-id", - "method": "tasks/get" + "method": "GetTask" } """; diff --git a/tests/A2A.UnitTests/Models/AIContentExtensionsTests.cs b/tests/A2A.UnitTests/Models/AIContentExtensionsTests.cs index beed2c67..5e740750 100644 --- a/tests/A2A.UnitTests/Models/AIContentExtensionsTests.cs +++ b/tests/A2A.UnitTests/Models/AIContentExtensionsTests.cs @@ -6,39 +6,38 @@ namespace A2A.UnitTests.Models public sealed class AIContentExtensionsTests { [Fact] - public void ToChatMessage_ThrowsOnNullAgentMessage() + public void ToChatMessage_ThrowsOnNullMessage() { - Assert.Throws("agentMessage", () => ((AgentMessage)null!).ToChatMessage()); + Assert.Throws("message", () => ((Message)null!).ToChatMessage()); } [Fact] public void ToChatMessage_ConvertsAgentRoleAndParts() { - var text = new TextPart { Text = "hello" }; - var file = new FilePart { File = new FileContent(new Uri("https://example.com")) { MimeType = "text/plain" } }; - var bytes = new byte[] { 1, 2, 3 }; - var fileBytes = new FilePart { File = new FileContent(Convert.ToBase64String(bytes)) { MimeType = "application/octet-stream", Name = "b.bin" } }; - var data = new DataPart { Data = new Dictionary { ["k"] = JsonSerializer.SerializeToElement("v") } }; - var agent = new AgentMessage + var textPart = Part.FromText("hello"); + var urlPart = Part.FromUrl("https://example.com", "text/plain"); + var rawBytes = new byte[] { 1, 2, 3 }; + var rawPart = Part.FromRaw(rawBytes, "application/octet-stream", "b.bin"); + var dataPart = Part.FromData(JsonSerializer.SerializeToElement(new Dictionary { ["k"] = "v" })); + var message = new Message { - Role = MessageRole.Agent, + Role = Role.Agent, MessageId = "mid-1", - Parts = new List { text, file, fileBytes, data } + Parts = new List { textPart, urlPart, rawPart, dataPart } }; - var chat = agent.ToChatMessage(); + var chat = message.ToChatMessage(); Assert.Equal(ChatRole.Assistant, chat.Role); - Assert.Same(agent, chat.RawRepresentation); - Assert.Equal(agent.MessageId, chat.MessageId); - Assert.Equal(agent.Parts.Count, chat.Contents.Count); + Assert.Same(message, chat.RawRepresentation); + Assert.Equal(message.MessageId, chat.MessageId); + Assert.Equal(message.Parts.Count, chat.Contents.Count); // Validate all content mappings var c0 = Assert.IsType(chat.Contents[0]); Assert.Equal("hello", c0.Text); var c1 = Assert.IsType(chat.Contents[1]); - Assert.Equal(new Uri("https://example.com"), c1.Uri); Assert.Equal("text/plain", c1.MediaType); var c2 = Assert.IsType(chat.Contents[2]); @@ -46,7 +45,7 @@ public void ToChatMessage_ConvertsAgentRoleAndParts() Assert.Equal("application/octet-stream", c2.MediaType); var c3 = Assert.IsType(chat.Contents[3]); - Assert.Same(data, c3.RawRepresentation); + Assert.Same(dataPart, c3.RawRepresentation); } [Fact] @@ -57,15 +56,15 @@ public void ToChatMessage_CopiesMetadataToAdditionalProperties() ["num"] = JsonSerializer.SerializeToElement(42), ["str"] = JsonSerializer.SerializeToElement("value") }; - var agent = new AgentMessage + var message = new Message { - Role = MessageRole.User, + Role = Role.User, MessageId = "m-meta", Parts = new List(), Metadata = metadata }; - var chat = agent.ToChatMessage(); + var chat = message.ToChatMessage(); Assert.NotNull(chat.AdditionalProperties); Assert.Equal(2, chat.AdditionalProperties!.Count); Assert.True(chat.AdditionalProperties.TryGetValue("num", out var numObj)); @@ -77,23 +76,23 @@ public void ToChatMessage_CopiesMetadataToAdditionalProperties() } [Fact] - public void ToAgentMessage_ThrowsOnNullChatMessage() + public void ToA2AMessage_ThrowsOnNullChatMessage() { - Assert.Throws("chatMessage", () => ((ChatMessage)null!).ToAgentMessage()); + Assert.Throws("chatMessage", () => ((ChatMessage)null!).ToA2AMessage()); } [Fact] - public void ToAgentMessage_ReturnsExistingAgentMessageWhenRawRepresentationMatches() + public void ToA2AMessage_ReturnsExistingMessageWhenRawRepresentationMatches() { - var original = new AgentMessage { MessageId = "m1", Parts = new List { new TextPart { Text = "hi" } } }; + var original = new Message { MessageId = "m1", Parts = new List { Part.FromText("hi") } }; var chat = original.ToChatMessage(); Assert.Same(original, chat.RawRepresentation); - Assert.Same(original, chat.ToAgentMessage()); + Assert.Same(original, chat.ToA2AMessage()); } [Fact] - public void ToAgentMessage_GeneratesMessageIdAndConvertsParts() + public void ToA2AMessage_GeneratesMessageIdAndConvertsParts() { var chat = new ChatMessage { @@ -106,13 +105,13 @@ public void ToAgentMessage_GeneratesMessageIdAndConvertsParts() } }; - var msg = chat.ToAgentMessage(); + var msg = chat.ToA2AMessage(); - Assert.Equal(MessageRole.Agent, msg.Role); + Assert.Equal(Role.Agent, msg.Role); Assert.False(string.IsNullOrWhiteSpace(msg.MessageId)); Assert.Equal(2, msg.Parts.Count); - Assert.IsType(msg.Parts[0]); - Assert.IsType(msg.Parts[1]); + Assert.NotNull(msg.Parts[0].Text); + Assert.NotNull(msg.Parts[1].Url); } [Fact] @@ -122,9 +121,9 @@ public void ToAIContent_ThrowsOnNullPart() } [Fact] - public void ToAIContent_ConvertsTextPart() + public void WhenTextPart_ToAIContent_ReturnsTextContent() { - var part = new TextPart { Text = "abc" }; + var part = Part.FromText("abc"); var content = part.ToAIContent(); var tc = Assert.IsType(content); Assert.Equal("abc", tc.Text); @@ -132,37 +131,30 @@ public void ToAIContent_ConvertsTextPart() } [Fact] - public void ToAIContent_ConvertsFilePartWithUri() + public void WhenUrlPart_ToAIContent_ReturnsUriContent() { - var uri = new Uri("https://example.com/data.json"); - var part = new FilePart { File = new FileContent(uri) { MimeType = "application/json" } }; + var part = Part.FromUrl("https://example.com/data.json", "application/json"); var content = part.ToAIContent(); var uc = Assert.IsType(content); - Assert.Equal(uri, uc.Uri); Assert.Equal("application/json", uc.MediaType); } [Fact] - public void ToAIContent_ConvertsFilePartWithBytes() + public void WhenRawPart_ToAIContent_ReturnsDataContent() { var raw = new byte[] { 10, 20, 30 }; - var b64 = Convert.ToBase64String(raw); - var part = new FilePart { File = new FileContent(b64) { MimeType = null, Name = "r.bin" } }; + var part = Part.FromRaw(raw, "image/png", "r.bin"); var content = part.ToAIContent(); var dc = Assert.IsType(content); Assert.Equal(raw, dc.Data); - Assert.Equal("application/octet-stream", dc.MediaType); // default applied + Assert.Equal("image/png", dc.MediaType); } [Fact] - public void ToAIContent_ConvertsDataPartWithMetadata() + public void WhenDataPart_ToAIContent_ReturnsDataContent() { - var metaValue = JsonSerializer.SerializeToElement(123); - var part = new DataPart - { - Data = new Dictionary { ["x"] = JsonSerializer.SerializeToElement("y") }, - Metadata = new Dictionary { ["m"] = metaValue } - }; + var part = Part.FromData(JsonSerializer.SerializeToElement(new { x = "y" })); + part.Metadata = new Dictionary { ["m"] = JsonSerializer.SerializeToElement(123) }; var content = part.ToAIContent(); Assert.IsType(content); Assert.NotNull(content.AdditionalProperties); @@ -170,15 +162,6 @@ public void ToAIContent_ConvertsDataPartWithMetadata() Assert.True(obj is JsonElement je && je.GetInt32() == 123); } - [Fact] - public void ToAIContent_FallsBackToBaseAIContentForUnknownPart() - { - var part = new CustomPart(); - var content = part.ToAIContent(); - Assert.Equal(typeof(AIContent), content.GetType()); - Assert.Same(part, content.RawRepresentation); - } - [Fact] public void ToPart_ThrowsOnNullContent() { @@ -188,7 +171,7 @@ public void ToPart_ThrowsOnNullContent() [Fact] public void ToPart_ReturnsExistingPartWhenRawRepresentationPresent() { - var part = new TextPart { Text = "hi" }; + var part = Part.FromText("hi"); Assert.Same(part, part.ToAIContent().ToPart()); } @@ -197,8 +180,8 @@ public void ToPart_ConvertsTextContent() { var content = new TextContent("hello"); var part = content.ToPart(); - var tp = Assert.IsType(part); - Assert.Equal("hello", tp.Text); + Assert.NotNull(part); + Assert.Equal("hello", part!.Text); } [Fact] @@ -207,10 +190,9 @@ public void ToPart_ConvertsUriContent() var uri = new Uri("https://example.com/a.txt"); var content = new UriContent(uri, "text/plain"); var part = content.ToPart(); - var fp = Assert.IsType(part); - Assert.NotNull(fp.File.Uri); - Assert.Equal(uri, fp.File.Uri); - Assert.Equal("text/plain", fp.File.MimeType); + Assert.NotNull(part); + Assert.NotNull(part!.Url); + Assert.Equal("text/plain", part.MediaType); } [Fact] @@ -219,10 +201,10 @@ public void ToPart_ConvertsDataContent() var payload = new byte[] { 1, 2, 3, 4 }; var content = new DataContent(payload, "application/custom"); var part = content.ToPart(); - var fp = Assert.IsType(part); - Assert.NotNull(fp.File.Bytes); - Assert.Equal(new byte[] { 1, 2, 3, 4 }, Convert.FromBase64String(fp.File.Bytes)); - Assert.Equal("application/custom", fp.File.MimeType); + Assert.NotNull(part); + Assert.NotNull(part!.Raw); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, part.Raw); + Assert.Equal("application/custom", part.MediaType); } [Fact] @@ -241,6 +223,33 @@ public void ToPart_TransfersAdditionalPropertiesToMetadata() Assert.Equal("str", part.Metadata["s"].GetString()); Assert.Equal(42, part.Metadata["i"].GetInt32()); } - private sealed class CustomPart() : Part("custom-kind") { } + + [Fact] + public void WhenMessage_ToChatMessage_ReturnsChatMessage() + { + var message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Hello")], + MessageId = "test-id", + }; + + var chatMessage = message.ToChatMessage(); + + Assert.Equal(ChatRole.User, chatMessage.Role); + Assert.Equal("test-id", chatMessage.MessageId); + Assert.Single(chatMessage.Contents); + } + + [Fact] + public void WhenChatMessage_ToA2AMessage_ReturnsMessage() + { + var chatMessage = new ChatMessage(ChatRole.Assistant, "Hello"); + + var message = chatMessage.ToA2AMessage(); + + Assert.Equal(Role.Agent, message.Role); + Assert.NotEmpty(message.Parts); + } } } diff --git a/tests/A2A.UnitTests/Models/AgentCardSignatureTests.cs b/tests/A2A.UnitTests/Models/AgentCardSignatureTests.cs index 3dd1e4d8..7eac05d1 100644 --- a/tests/A2A.UnitTests/Models/AgentCardSignatureTests.cs +++ b/tests/A2A.UnitTests/Models/AgentCardSignatureTests.cs @@ -12,10 +12,10 @@ public void AgentCardSignature_SerializesAndDeserializesCorrectly() { Protected = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", Signature = "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ", - Header = new Dictionary + Header = new Dictionary { - { "kid", "key-1" }, - { "alg", "ES256" } + { "kid", JsonDocument.Parse("\"key-1\"").RootElement }, + { "alg", JsonDocument.Parse("\"ES256\"").RootElement } } }; diff --git a/tests/A2A.UnitTests/Models/AgentCardTests.cs b/tests/A2A.UnitTests/Models/AgentCardTests.cs index cb6c04c5..0905f46b 100644 --- a/tests/A2A.UnitTests/Models/AgentCardTests.cs +++ b/tests/A2A.UnitTests/Models/AgentCardTests.cs @@ -1,301 +1,281 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models; - -public class AgentCardTests -{ - private const string ExpectedJson = """ - { - "name": "Test Agent", - "description": "A test agent for MVP serialization", - "url": "https://example.com/agent", - "provider": { - "organization": "Test Org", - "url": "https://testorg.com" - }, - "version": "1.0.0", - "protocolVersion": "0.3.0", - "documentationUrl": "https://docs.example.com", - "capabilities": { - "streaming": true, - "pushNotifications": false, - "stateTransitionHistory": true - }, - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "name": "X-API-Key", - "in": "header" - } - }, - "security": [ - { - "apiKey": [] - } - ], - "defaultInputModes": ["text", "image"], - "defaultOutputModes": ["text", "json"], - "skills": [ - { - "id": "test-skill", - "name": "Test Skill", - "description": "A test skill", - "tags": ["test", "skill"], - "examples": ["Example usage"], - "inputModes": ["text"], - "outputModes": ["text"], - "security": [ - { - "oauth": ["read"] - } - ] - } - ], - "supportsAuthenticatedExtendedCard": true, - "additionalInterfaces": [ - { - "transport": "JSONRPC", - "url": "https://jsonrpc.example.com/agent" - } - ], - "preferredTransport": "GRPC", - "signatures": [ - { - "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", - "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" - } - ] - } - """; - - [Fact] - public void AgentCard_Deserialize_AllPropertiesCorrect() - { - // Act - var deserializedCard = JsonSerializer.Deserialize(ExpectedJson, A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserializedCard); - Assert.Equal("Test Agent", deserializedCard.Name); - Assert.Equal("A test agent for MVP serialization", deserializedCard.Description); - Assert.Equal("https://example.com/agent", deserializedCard.Url); - Assert.Equal("1.0.0", deserializedCard.Version); - Assert.Equal("0.3.0", deserializedCard.ProtocolVersion); - Assert.Equal("https://docs.example.com", deserializedCard.DocumentationUrl); - Assert.True(deserializedCard.SupportsAuthenticatedExtendedCard); - - // Provider - Assert.NotNull(deserializedCard.Provider); - Assert.Equal("Test Org", deserializedCard.Provider.Organization); - Assert.Equal("https://testorg.com", deserializedCard.Provider.Url); - - // Capabilities - Assert.NotNull(deserializedCard.Capabilities); - Assert.True(deserializedCard.Capabilities.Streaming); - Assert.False(deserializedCard.Capabilities.PushNotifications); - Assert.True(deserializedCard.Capabilities.StateTransitionHistory); - - // Security - Assert.NotNull(deserializedCard.SecuritySchemes); - Assert.Single(deserializedCard.SecuritySchemes); - Assert.Contains("apiKey", deserializedCard.SecuritySchemes.Keys); - var apisec = Assert.IsType(deserializedCard.SecuritySchemes["apiKey"]); - Assert.False(string.IsNullOrWhiteSpace(apisec.Name)); - Assert.False(string.IsNullOrWhiteSpace(apisec.KeyLocation)); - - Assert.NotNull(deserializedCard.Security); - Assert.Single(deserializedCard.Security); - - var securityRequirement = deserializedCard.Security[0]; - Assert.NotNull(securityRequirement); - Assert.Single(securityRequirement); - Assert.True(securityRequirement.ContainsKey("apiKey")); - Assert.Empty(securityRequirement["apiKey"]); - - // Input/Output modes - Assert.Equal(new List { "text", "image" }, deserializedCard.DefaultInputModes); - Assert.Equal(new List { "text", "json" }, deserializedCard.DefaultOutputModes); - - // Skills - Assert.NotNull(deserializedCard.Skills); - Assert.Single(deserializedCard.Skills); - var skill = deserializedCard.Skills[0]; - Assert.Equal("test-skill", skill.Id); - Assert.Equal("Test Skill", skill.Name); - Assert.Equal("A test skill", skill.Description); - Assert.NotNull(skill.Tags); - Assert.Equal(2, skill.Tags.Count); - Assert.Contains("test", skill.Tags); - Assert.Contains("skill", skill.Tags); - - // Skill Security - Assert.NotNull(skill.Security); - Assert.Single(skill.Security); - var skillSecurityRequirement = skill.Security[0]; - Assert.NotNull(skillSecurityRequirement); - Assert.Single(skillSecurityRequirement); - Assert.True(skillSecurityRequirement.ContainsKey("oauth")); - Assert.Equal(["read"], skillSecurityRequirement["oauth"]); - - // Transport properties - Assert.Equal("GRPC", deserializedCard.PreferredTransport.Label); - Assert.NotNull(deserializedCard.AdditionalInterfaces); - Assert.Single(deserializedCard.AdditionalInterfaces); - Assert.Equal("JSONRPC", deserializedCard.AdditionalInterfaces[0].Transport.Label); - Assert.Equal("https://jsonrpc.example.com/agent", deserializedCard.AdditionalInterfaces[0].Url); - - // Signatures - Assert.NotNull(deserializedCard.Signatures); - Assert.Single(deserializedCard.Signatures); - var signature = deserializedCard.Signatures[0]; - Assert.Equal("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", signature.Protected); - Assert.Equal("QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ", signature.Signature); - Assert.Null(signature.Header); - } - - [Fact] - public void AgentCard_Serialize_ProducesExpectedJson() - { - // Arrange - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for MVP serialization", - Url = "https://example.com/agent", - Provider = new AgentProvider - { - Organization = "Test Org", - Url = "https://testorg.com" - }, - Version = "1.0.0", - ProtocolVersion = "0.3.0", - DocumentationUrl = "https://docs.example.com", - Capabilities = new AgentCapabilities - { - Streaming = true, - PushNotifications = false, - StateTransitionHistory = true - }, - SecuritySchemes = new Dictionary - { - ["apiKey"] = new ApiKeySecurityScheme("X-API-Key", "header") - }, - Security = new List> - { - new() - { - ["apiKey"] = [] - } - }, - DefaultInputModes = ["text", "image"], - DefaultOutputModes = ["text", "json"], - Skills = [ - new AgentSkill - { - Id = "test-skill", - Name = "Test Skill", - Description = "A test skill", - Tags = ["test", "skill"], - Examples = ["Example usage"], - InputModes = ["text"], - OutputModes = ["text"], - Security = [ - new Dictionary - { - ["oauth"] = ["read"] - } - ] - } - ], - SupportsAuthenticatedExtendedCard = true, - AdditionalInterfaces = [ - new AgentInterface - { - Transport = AgentTransport.JsonRpc, - Url = "https://jsonrpc.example.com/agent" - } - ], - PreferredTransport = new AgentTransport("GRPC"), - Signatures = [ - new AgentCardSignature - { - Protected = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", - Signature = "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" - } - ] - }; - - // Act - var serializedJson = JsonSerializer.Serialize(agentCard, A2AJsonUtilities.DefaultOptions); - - // Assert - Compare objects instead of raw JSON strings to avoid formatting/ordering issues - // and provide more meaningful error messages when properties don't match - var expectedCard = JsonSerializer.Deserialize(ExpectedJson, A2AJsonUtilities.DefaultOptions); - var actualCard = JsonSerializer.Deserialize(serializedJson, A2AJsonUtilities.DefaultOptions); - - Assert.NotNull(actualCard); - Assert.NotNull(expectedCard); - - // Compare key properties - Assert.Equal(expectedCard.Name, actualCard.Name); - Assert.Equal(expectedCard.Description, actualCard.Description); - Assert.Equal(expectedCard.Url, actualCard.Url); - Assert.Equal(expectedCard.Version, actualCard.Version); - Assert.Equal(expectedCard.ProtocolVersion, actualCard.ProtocolVersion); - Assert.Equal(expectedCard.DocumentationUrl, actualCard.DocumentationUrl); - Assert.Equal(expectedCard.SupportsAuthenticatedExtendedCard, actualCard.SupportsAuthenticatedExtendedCard); - - // Provider - Assert.Equal(expectedCard.Provider?.Organization, actualCard.Provider?.Organization); - Assert.Equal(expectedCard.Provider?.Url, actualCard.Provider?.Url); - - // Capabilities - Assert.Equal(expectedCard.Capabilities?.Streaming, actualCard.Capabilities?.Streaming); - Assert.Equal(expectedCard.Capabilities?.PushNotifications, actualCard.Capabilities?.PushNotifications); - Assert.Equal(expectedCard.Capabilities?.StateTransitionHistory, actualCard.Capabilities?.StateTransitionHistory); - - // Input/Output modes - Assert.Equal(expectedCard.DefaultInputModes, actualCard.DefaultInputModes); - Assert.Equal(expectedCard.DefaultOutputModes, actualCard.DefaultOutputModes); - - // Skills - Assert.Equal(expectedCard.Skills?.Count, actualCard.Skills?.Count); - if (expectedCard.Skills?.Count > 0 && actualCard.Skills?.Count > 0) - { - var expectedSkill = expectedCard.Skills[0]; - var actualSkill = actualCard.Skills[0]; - Assert.Equal(expectedSkill.Id, actualSkill.Id); - Assert.Equal(expectedSkill.Name, actualSkill.Name); - Assert.Equal(expectedSkill.Description, actualSkill.Description); - Assert.Equal(expectedSkill.Tags, actualSkill.Tags); - Assert.Equal(expectedSkill.Examples, actualSkill.Examples); - Assert.Equal(expectedSkill.InputModes, actualSkill.InputModes); - Assert.Equal(expectedSkill.OutputModes, actualSkill.OutputModes); - - // Skill Security - Assert.Equal(expectedSkill.Security?.Count, actualSkill.Security?.Count); - if (expectedSkill.Security?.Count > 0 && actualSkill.Security?.Count > 0) - { - Assert.Equal(expectedSkill.Security[0].Count, actualSkill.Security[0].Count); - foreach (var kvp in expectedSkill.Security[0]) - { - Assert.True(actualSkill.Security[0].ContainsKey(kvp.Key)); - Assert.Equal(kvp.Value, actualSkill.Security[0][kvp.Key]); - } - } - } - - // Transport properties - Assert.Equal(expectedCard.PreferredTransport.Label, actualCard.PreferredTransport.Label); - Assert.Equal(expectedCard.AdditionalInterfaces?.Count, actualCard.AdditionalInterfaces?.Count); - if (expectedCard.AdditionalInterfaces?.Count > 0 && actualCard.AdditionalInterfaces?.Count > 0) - { - Assert.Equal(expectedCard.AdditionalInterfaces[0].Transport.Label, actualCard.AdditionalInterfaces[0].Transport.Label); - Assert.Equal(expectedCard.AdditionalInterfaces[0].Url, actualCard.AdditionalInterfaces[0].Url); - } - - // Security schemes - Assert.Equal(expectedCard.SecuritySchemes?.Count, actualCard.SecuritySchemes?.Count); - Assert.Equal(expectedCard.Security?.Count, actualCard.Security?.Count); - } -} +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public class AgentCardTests +{ + [Fact] + public void WhenValidAgentCard_Serialized_RoundTripsCorrectly() + { + var card = new AgentCard + { + Name = "Test Agent", + Description = "A test agent", + SupportedInterfaces = + [ + new AgentInterface + { + Url = "http://localhost:5000/a2a", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + }; + + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("Test Agent", deserialized.Name); + Assert.Equal("A test agent", deserialized.Description); + Assert.Single(deserialized.SupportedInterfaces); + Assert.Equal("http://localhost:5000/a2a", deserialized.SupportedInterfaces[0].Url); + } + + [Fact] + public void WhenNullOptionalFields_Serialized_OmitsNullFields() + { + var card = new AgentCard + { + Name = "Agent", + Description = "Desc", + SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + }; + + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var doc = JsonDocument.Parse(json); + + Assert.True(doc.RootElement.TryGetProperty("capabilities", out _)); + Assert.True(doc.RootElement.TryGetProperty("skills", out _)); + Assert.False(doc.RootElement.TryGetProperty("securitySchemes", out _)); + } + + [Fact] + public void AgentCard_Serialize_WithAllProperties_RoundTripsCorrectly() + { + // Arrange + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for v1 serialization", + Provider = new AgentProvider + { + Organization = "Test Org", + Url = "https://testorg.com" + }, + SupportedInterfaces = + [ + new AgentInterface + { + Url = "https://example.com/agent", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + }, + SecuritySchemes = new Dictionary + { + ["apiKey"] = new SecurityScheme + { + ApiKeySecurityScheme = new ApiKeySecurityScheme { Name = "X-API-Key", Location = "header" } + } + }, + DefaultInputModes = ["text", "image"], + DefaultOutputModes = ["text", "json"], + Skills = + [ + new AgentSkill + { + Id = "test-skill", + Name = "Test Skill", + Description = "A test skill", + Tags = ["test", "skill"], + Examples = ["Example usage"], + InputModes = ["text"], + OutputModes = ["text"], + } + ], + Signatures = + [ + new AgentCardSignature + { + Protected = "eyJhbGciOiJFUzI1NiJ9", + Signature = "dGVzdC1zaWduYXR1cmU" + } + ] + }; + + // Act + var json = JsonSerializer.Serialize(agentCard, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(agentCard.Name, deserialized.Name); + Assert.Equal(agentCard.Description, deserialized.Description); + Assert.NotNull(deserialized.Provider); + Assert.Equal("Test Org", deserialized.Provider.Organization); + Assert.Single(deserialized.SupportedInterfaces); + Assert.Equal("https://example.com/agent", deserialized.SupportedInterfaces[0].Url); + Assert.NotNull(deserialized.Capabilities); + Assert.True(deserialized.Capabilities.Streaming); + Assert.NotNull(deserialized.SecuritySchemes); + Assert.Single(deserialized.SecuritySchemes); + Assert.NotNull(deserialized.SecuritySchemes["apiKey"].ApiKeySecurityScheme); + Assert.Equal("X-API-Key", deserialized.SecuritySchemes["apiKey"].ApiKeySecurityScheme!.Name); + Assert.Equal(new List { "text", "image" }, deserialized.DefaultInputModes); + Assert.Equal(new List { "text", "json" }, deserialized.DefaultOutputModes); + Assert.NotNull(deserialized.Skills); + Assert.Single(deserialized.Skills); + Assert.Equal("test-skill", deserialized.Skills[0].Id); + Assert.NotNull(deserialized.Signatures); + Assert.Single(deserialized.Signatures); + Assert.Equal("eyJhbGciOiJFUzI1NiJ9", deserialized.Signatures[0].Protected); + } + + [Fact] + public void Serialize_WithSupportedInterfaces_RoundTrips() + { + // Arrange + var card = new AgentCard + { + Name = "Interface Agent", + Description = "Tests interfaces", + SupportedInterfaces = + [ + new AgentInterface + { + Url = "http://localhost:8080/a2a", + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + } + ], + }; + + // Act + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Single(deserialized.SupportedInterfaces); + var iface = deserialized.SupportedInterfaces[0]; + Assert.Equal("http://localhost:8080/a2a", iface.Url); + Assert.Equal("JSONRPC", iface.ProtocolBinding); + Assert.Equal("1.0", iface.ProtocolVersion); + } + + [Fact] + public void Serialize_WithSecuritySchemes_RoundTrips() + { + // Arrange + var card = new AgentCard + { + Name = "Secure Agent", + Description = "Tests security schemes", + SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + SecuritySchemes = new Dictionary + { + ["bearer"] = new SecurityScheme + { + HttpAuthSecurityScheme = new HttpAuthSecurityScheme { Scheme = "bearer", BearerFormat = "JWT" } + } + } + }; + + // Act + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.SecuritySchemes); + Assert.Single(deserialized.SecuritySchemes); + Assert.NotNull(deserialized.SecuritySchemes["bearer"].HttpAuthSecurityScheme); + Assert.Equal("bearer", deserialized.SecuritySchemes["bearer"].HttpAuthSecurityScheme!.Scheme); + } + + [Fact] + public void Serialize_WithSecurityRequirements_RoundTrips() + { + // Arrange + var card = new AgentCard + { + Name = "Requirements Agent", + Description = "Tests security requirements", + SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + SecurityRequirements = + [ + new SecurityRequirement + { + Schemes = new Dictionary + { + ["oauth2"] = new StringList { List = ["read", "write"] } + } + } + ] + }; + + // Act + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.SecurityRequirements); + Assert.Single(deserialized.SecurityRequirements); + Assert.NotNull(deserialized.SecurityRequirements[0].Schemes); + Assert.Equal(new List { "read", "write" }, deserialized.SecurityRequirements[0].Schemes!["oauth2"].List); + } + + [Fact] + public void RequiredFields_AllPresent_Serializes() + { + // Arrange + var card = new AgentCard + { + Name = "Complete Agent", + Description = "Full description", + Version = "2.0.0", + SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + }; + + // Act + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var doc = JsonDocument.Parse(json); + + // Assert + Assert.True(doc.RootElement.TryGetProperty("name", out _)); + Assert.True(doc.RootElement.TryGetProperty("description", out _)); + Assert.True(doc.RootElement.TryGetProperty("version", out _)); + Assert.True(doc.RootElement.TryGetProperty("supportedInterfaces", out _)); + Assert.True(doc.RootElement.TryGetProperty("capabilities", out _)); + Assert.True(doc.RootElement.TryGetProperty("skills", out _)); + Assert.True(doc.RootElement.TryGetProperty("defaultInputModes", out _)); + Assert.True(doc.RootElement.TryGetProperty("defaultOutputModes", out _)); + } + + [Fact] + public void Version_SerializesCorrectly() + { + // Arrange + var card = new AgentCard + { + Name = "Versioned Agent", + Description = "Tests version", + Version = "1.2.3", + SupportedInterfaces = [new AgentInterface { Url = "http://test", ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }], + }; + + // Act + var json = JsonSerializer.Serialize(card, A2AJsonUtilities.DefaultOptions); + var doc = JsonDocument.Parse(json); + + // Assert + Assert.Equal("1.2.3", doc.RootElement.GetProperty("version").GetString()); + } +} diff --git a/tests/A2A.UnitTests/Models/AgentSkillTests.cs b/tests/A2A.UnitTests/Models/AgentSkillTests.cs index 8bf6ffb5..8b57ef9d 100644 --- a/tests/A2A.UnitTests/Models/AgentSkillTests.cs +++ b/tests/A2A.UnitTests/Models/AgentSkillTests.cs @@ -5,101 +5,44 @@ namespace A2A.UnitTests.Models; public class AgentSkillTests { [Fact] - public void AgentSkill_SecurityProperty_SerializesCorrectly() + public void AgentSkill_WithAllProperties_SerializesAndDeserializesCorrectly() { // Arrange - var skill = new AgentSkill + var originalSkill = new AgentSkill { - Id = "test-skill", - Name = "Test Skill", - Description = "A test skill with security", - Tags = ["test", "security"], - Security = - [ - new Dictionary - { - { "oauth", ["read", "write"] } - }, - new Dictionary - { - { "api-key", [] }, - { "mtls", [] } - } - ] + Id = "full-skill", + Name = "Full Skill", + Description = "A skill with all properties", + Tags = ["complete", "test"], + Examples = ["Example usage 1", "Example usage 2"], + InputModes = ["text", "image"], + OutputModes = ["text", "json"], }; // Act - var json = JsonSerializer.Serialize(skill, A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.Contains("\"security\"", json); - Assert.Contains("\"oauth\"", json); - Assert.Contains("\"api-key\"", json); - Assert.Contains("\"mtls\"", json); - Assert.Contains("\"read\"", json); - Assert.Contains("\"write\"", json); - } - - [Fact] - public void AgentSkill_SecurityProperty_DeserializesCorrectly() - { - // Arrange - var json = """ - { - "id": "secure-skill", - "name": "Secure Skill", - "description": "A skill with security requirements", - "tags": ["secure"], - "security": [ - { - "google": ["oidc"] - }, - { - "api-key": [], - "mtls": [] - } - ] - } - """; - - // Act - var skill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var json = JsonSerializer.Serialize(originalSkill, A2AJsonUtilities.DefaultOptions); + var deserializedSkill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); // Assert - Assert.NotNull(skill); - Assert.Equal("secure-skill", skill.Id); - Assert.Equal("Secure Skill", skill.Name); - Assert.Equal("A skill with security requirements", skill.Description); - - Assert.NotNull(skill.Security); - Assert.Equal(2, skill.Security.Count); - - // First security requirement (google oidc) - var firstRequirement = skill.Security[0]; - Assert.Single(firstRequirement); - Assert.Contains("google", firstRequirement.Keys); - Assert.Equal(["oidc"], firstRequirement["google"]); - - // Second security requirement (api-key AND mtls) - var secondRequirement = skill.Security[1]; - Assert.Equal(2, secondRequirement.Count); - Assert.Contains("api-key", secondRequirement.Keys); - Assert.Contains("mtls", secondRequirement.Keys); - Assert.Empty(secondRequirement["api-key"]); - Assert.Empty(secondRequirement["mtls"]); + Assert.NotNull(deserializedSkill); + Assert.Equal(originalSkill.Id, deserializedSkill.Id); + Assert.Equal(originalSkill.Name, deserializedSkill.Name); + Assert.Equal(originalSkill.Description, deserializedSkill.Description); + Assert.Equal(originalSkill.Tags, deserializedSkill.Tags); + Assert.Equal(originalSkill.Examples, deserializedSkill.Examples); + Assert.Equal(originalSkill.InputModes, deserializedSkill.InputModes); + Assert.Equal(originalSkill.OutputModes, deserializedSkill.OutputModes); } [Fact] - public void AgentSkill_SecurityProperty_CanBeNull() + public void AgentSkill_NullOptionalFields_OmittedInJson() { // Arrange var skill = new AgentSkill { Id = "simple-skill", Name = "Simple Skill", - Description = "A skill without security requirements", - Tags = ["simple"], - Security = null + Description = "A skill without optional properties", }; // Act @@ -107,50 +50,42 @@ public void AgentSkill_SecurityProperty_CanBeNull() var deserializedSkill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); // Assert - Assert.DoesNotContain("\"security\"", json); // Should be omitted when null + Assert.Contains("\"tags\"", json); + Assert.DoesNotContain("\"examples\"", json); + Assert.DoesNotContain("\"securityRequirements\"", json); Assert.NotNull(deserializedSkill); - Assert.Null(deserializedSkill.Security); + Assert.NotNull(deserializedSkill.Tags); + Assert.Empty(deserializedSkill.Tags); + Assert.Null(deserializedSkill.SecurityRequirements); } [Fact] - public void AgentSkill_WithAllProperties_SerializesAndDeserializesCorrectly() + public void AgentSkill_DeserializesFromJson() { // Arrange - var originalSkill = new AgentSkill + var json = """ { - Id = "full-skill", - Name = "Full Skill", - Description = "A skill with all properties", - Tags = ["complete", "test"], - Examples = ["Example usage 1", "Example usage 2"], - InputModes = ["text", "image"], - OutputModes = ["text", "json"], - Security = - [ - new Dictionary - { - { "oauth", ["read"] } - } - ] - }; + "id": "test-skill", + "name": "Test Skill", + "description": "A test skill", + "tags": ["test", "skill"], + "examples": ["Example usage"], + "inputModes": ["text"], + "outputModes": ["text"] + } + """; // Act - var json = JsonSerializer.Serialize(originalSkill, A2AJsonUtilities.DefaultOptions); - var deserializedSkill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var skill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); // Assert - Assert.NotNull(deserializedSkill); - Assert.Equal(originalSkill.Id, deserializedSkill.Id); - Assert.Equal(originalSkill.Name, deserializedSkill.Name); - Assert.Equal(originalSkill.Description, deserializedSkill.Description); - Assert.Equal(originalSkill.Tags, deserializedSkill.Tags); - Assert.Equal(originalSkill.Examples, deserializedSkill.Examples); - Assert.Equal(originalSkill.InputModes, deserializedSkill.InputModes); - Assert.Equal(originalSkill.OutputModes, deserializedSkill.OutputModes); - - Assert.NotNull(deserializedSkill.Security); - Assert.Single(deserializedSkill.Security); - Assert.Contains("oauth", deserializedSkill.Security[0].Keys); - Assert.Equal(["read"], deserializedSkill.Security[0]["oauth"]); + Assert.NotNull(skill); + Assert.Equal("test-skill", skill.Id); + Assert.Equal("Test Skill", skill.Name); + Assert.Equal("A test skill", skill.Description); + Assert.NotNull(skill.Tags); + Assert.Equal(2, skill.Tags.Count); + Assert.Contains("test", skill.Tags); + Assert.Contains("skill", skill.Tags); } } \ No newline at end of file diff --git a/tests/A2A.UnitTests/Models/MessageTests.cs b/tests/A2A.UnitTests/Models/MessageTests.cs new file mode 100644 index 00000000..a0d9d191 --- /dev/null +++ b/tests/A2A.UnitTests/Models/MessageTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public sealed class MessageTests +{ + [Fact] + public void Serialize_WithRequiredFields_RoundTrips() + { + var message = new Message + { + MessageId = "msg-1", + Role = Role.User, + Parts = [Part.FromText("Hello")] + }; + + var json = JsonSerializer.Serialize(message, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("msg-1", deserialized.MessageId); + Assert.Equal(Role.User, deserialized.Role); + Assert.Single(deserialized.Parts); + Assert.Equal("Hello", deserialized.Parts[0].Text); + } + + [Fact] + public void Serialize_WithAllOptionalFields_RoundTrips() + { + var metadata = new Dictionary + { + ["key"] = JsonSerializer.SerializeToElement("value") + }; + + var message = new Message + { + MessageId = "msg-2", + Role = Role.Agent, + Parts = [Part.FromText("reply")], + ContextId = "ctx-1", + TaskId = "task-1", + ReferenceTaskIds = ["ref-1", "ref-2"], + Extensions = ["ext-1"], + Metadata = metadata + }; + + var json = JsonSerializer.Serialize(message, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("msg-2", deserialized.MessageId); + Assert.Equal(Role.Agent, deserialized.Role); + Assert.Equal("ctx-1", deserialized.ContextId); + Assert.Equal("task-1", deserialized.TaskId); + Assert.NotNull(deserialized.ReferenceTaskIds); + Assert.Equal(["ref-1", "ref-2"], deserialized.ReferenceTaskIds); + Assert.NotNull(deserialized.Extensions); + Assert.Equal(["ext-1"], deserialized.Extensions); + Assert.NotNull(deserialized.Metadata); + Assert.Equal("value", deserialized.Metadata["key"].GetString()); + } + + [Fact] + public void Deserialize_FromJson_ParsesCorrectly() + { + const string json = """ + { + "messageId": "m-42", + "role": "ROLE_USER", + "parts": [{ "text": "test message" }], + "contextId": "c-1", + "taskId": "t-1" + } + """; + + var message = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(message); + Assert.Equal("m-42", message.MessageId); + Assert.Equal(Role.User, message.Role); + Assert.Single(message.Parts); + Assert.Equal("test message", message.Parts[0].Text); + Assert.Equal("c-1", message.ContextId); + Assert.Equal("t-1", message.TaskId); + Assert.Null(message.ReferenceTaskIds); + Assert.Null(message.Extensions); + Assert.Null(message.Metadata); + } +} diff --git a/tests/A2A.UnitTests/Models/PartTests.cs b/tests/A2A.UnitTests/Models/PartTests.cs new file mode 100644 index 00000000..d5cad16d --- /dev/null +++ b/tests/A2A.UnitTests/Models/PartTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public sealed class PartTests +{ + [Fact] + public void FromText_SetsContentCaseToText() + { + var part = Part.FromText("hello"); + + Assert.Equal(PartContentCase.Text, part.ContentCase); + Assert.Equal("hello", part.Text); + } + + [Fact] + public void FromRaw_SetsContentCaseToRaw() + { + var data = new byte[] { 0x01, 0x02, 0x03 }; + var part = Part.FromRaw(data, "application/octet-stream", "file.bin"); + + Assert.Equal(PartContentCase.Raw, part.ContentCase); + Assert.Equal(data, part.Raw); + Assert.Equal("application/octet-stream", part.MediaType); + Assert.Equal("file.bin", part.Filename); + } + + [Fact] + public void FromUrl_SetsContentCaseToUrl() + { + var part = Part.FromUrl("https://example.com/file.pdf", "application/pdf", "file.pdf"); + + Assert.Equal(PartContentCase.Url, part.ContentCase); + Assert.Equal("https://example.com/file.pdf", part.Url); + Assert.Equal("application/pdf", part.MediaType); + Assert.Equal("file.pdf", part.Filename); + } + + [Fact] + public void FromData_SetsContentCaseToData() + { + var element = JsonSerializer.SerializeToElement(new { key = "value" }); + var part = Part.FromData(element); + + Assert.Equal(PartContentCase.Data, part.ContentCase); + Assert.NotNull(part.Data); + Assert.Equal("value", part.Data.Value.GetProperty("key").GetString()); + } + + [Fact] + public void ContentCase_WhenEmpty_ReturnsNone() + { + var part = new Part(); + + Assert.Equal(PartContentCase.None, part.ContentCase); + } + + [Fact] + public void RoundTrip_TextPart_PreservesContent() + { + var part = Part.FromText("round trip text"); + + var json = JsonSerializer.Serialize(part, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(PartContentCase.Text, deserialized.ContentCase); + Assert.Equal("round trip text", deserialized.Text); + } + + [Fact] + public void RoundTrip_UrlPart_PreservesMediaTypeAndFilename() + { + var part = Part.FromUrl("https://example.com/doc.pdf", "application/pdf", "doc.pdf"); + + var json = JsonSerializer.Serialize(part, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(PartContentCase.Url, deserialized.ContentCase); + Assert.Equal("https://example.com/doc.pdf", deserialized.Url); + Assert.Equal("application/pdf", deserialized.MediaType); + Assert.Equal("doc.pdf", deserialized.Filename); + } + + [Fact] + public void RoundTrip_RawPart_PreservesBinaryData() + { + var rawData = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + var part = Part.FromRaw(rawData, "application/octet-stream", "data.bin"); + + var json = JsonSerializer.Serialize(part, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(PartContentCase.Raw, deserialized.ContentCase); + Assert.Equal(rawData, deserialized.Raw); + Assert.Equal("application/octet-stream", deserialized.MediaType); + Assert.Equal("data.bin", deserialized.Filename); + } +} diff --git a/tests/A2A.UnitTests/Models/RoleTests.cs b/tests/A2A.UnitTests/Models/RoleTests.cs new file mode 100644 index 00000000..f62a2141 --- /dev/null +++ b/tests/A2A.UnitTests/Models/RoleTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public sealed class RoleTests +{ + [Fact] + public void User_SerializesToRoleUser() + { + var json = JsonSerializer.Serialize(Role.User, A2AJsonUtilities.DefaultOptions); + + Assert.Equal("\"ROLE_USER\"", json); + } + + [Fact] + public void Agent_SerializesToRoleAgent() + { + var json = JsonSerializer.Serialize(Role.Agent, A2AJsonUtilities.DefaultOptions); + + Assert.Equal("\"ROLE_AGENT\"", json); + } + + [Fact] + public void Unspecified_SerializesToRoleUnspecified() + { + var json = JsonSerializer.Serialize(Role.Unspecified, A2AJsonUtilities.DefaultOptions); + + Assert.Equal("\"ROLE_UNSPECIFIED\"", json); + } + + [Fact] + public void Deserialize_FromString_ParsesCorrectly() + { + var user = JsonSerializer.Deserialize("\"ROLE_USER\"", A2AJsonUtilities.DefaultOptions); + var agent = JsonSerializer.Deserialize("\"ROLE_AGENT\"", A2AJsonUtilities.DefaultOptions); + var unspecified = JsonSerializer.Deserialize("\"ROLE_UNSPECIFIED\"", A2AJsonUtilities.DefaultOptions); + + Assert.Equal(Role.User, user); + Assert.Equal(Role.Agent, agent); + Assert.Equal(Role.Unspecified, unspecified); + } +} diff --git a/tests/A2A.UnitTests/Models/SecuritySchemeTests.cs b/tests/A2A.UnitTests/Models/SecuritySchemeTests.cs index 17f9090b..30508b2c 100644 --- a/tests/A2A.UnitTests/Models/SecuritySchemeTests.cs +++ b/tests/A2A.UnitTests/Models/SecuritySchemeTests.cs @@ -4,217 +4,76 @@ namespace A2A.UnitTests.Models; public class SecuritySchemeTests { - private static readonly JsonSerializerOptions s_jsonOptions = new() - { - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - [Fact] - public void SecurityScheme_DescriptionProperty_SerializesCorrectly() - { - // Arrange - SecurityScheme scheme = new ApiKeySecurityScheme("X-API-Key", "header"); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions) as ApiKeySecurityScheme; - - // Assert - Assert.Contains("\"type\": \"apiKey\"", json); - Assert.Contains("\"description\": \"API key for authentication\"", json); - Assert.NotNull(deserialized); - Assert.Equal("API key for authentication", deserialized.Description); - } - - [Fact] - public void SecurityScheme_DescriptionProperty_CanBeNull() - { - // Arrange - SecurityScheme scheme = new HttpAuthSecurityScheme("bearer", null); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions) as HttpAuthSecurityScheme; - - // Assert - Assert.DoesNotContain("\"description\"", json); - Assert.Contains("\"type\": \"http\"", json); - Assert.NotNull(deserialized); - Assert.Null(deserialized.Description); - } - - [Fact] - public void ApiKeySecurityScheme_SerializesAndDeserializesCorrectly() - { - // Arrange - SecurityScheme scheme = new ApiKeySecurityScheme("X-API-Key", "header"); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var d = JsonSerializer.Deserialize(json, s_jsonOptions); - - // Assert - Assert.Contains("\"type\": \"apiKey\"", json); - Assert.Contains("\"description\":", json); - - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("API key for authentication", deserialized.Description); - Assert.Equal("X-API-Key", deserialized.Name); - Assert.Equal("header", deserialized.KeyLocation); - } - [Fact] - public void HttpAuthSecurityScheme_SerializesAndDeserializesCorrectly() + public void WhenApiKeyScheme_Serialized_OnlyApiKeyFieldPresent() { - // Arrange - SecurityScheme scheme = new HttpAuthSecurityScheme("bearer"); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var d = JsonSerializer.Deserialize(json, s_jsonOptions); + var scheme = new SecurityScheme + { + ApiKeySecurityScheme = new ApiKeySecurityScheme { Name = "api_key", Location = "header" } + }; - // Assert - Assert.Contains("\"type\": \"http\"", json); - Assert.DoesNotContain("\"description\"", json); + var json = JsonSerializer.Serialize(scheme, A2AJsonUtilities.DefaultOptions); + var doc = JsonDocument.Parse(json); - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("bearer", deserialized.Scheme); - Assert.Null(deserialized.Description); + Assert.True(doc.RootElement.TryGetProperty("apiKeySecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("httpAuthSecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("oauth2SecurityScheme", out _)); } [Fact] - public void OAuth2SecurityScheme_SerializesAndDeserializesCorrectly() + public void WhenSecurityScheme_RoundTrips_Correctly() { - // Arrange - var flows = new OAuthFlows + var scheme = new SecurityScheme { - Password = new(new("https://example.com/token"), scopes: new Dictionary() { ["read"] = "Read access", ["write"] = "Write access" }), + HttpAuthSecurityScheme = new HttpAuthSecurityScheme { Scheme = "bearer", BearerFormat = "JWT" } }; - SecurityScheme scheme = new OAuth2SecurityScheme(flows, "OAuth2 authentication"); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var d = JsonSerializer.Deserialize(json, s_jsonOptions); - // Assert - Assert.Contains("\"description\": \"OAuth2 authentication\"", json); + var json = JsonSerializer.Serialize(scheme, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var deserialized = Assert.IsType(d); Assert.Contains("\"type\": \"oauth2\"", json); - Assert.NotNull(deserialized); - Assert.Equal("OAuth2 authentication", deserialized.Description); - Assert.NotNull(deserialized.Flows); - Assert.Null(deserialized.Flows.ClientCredentials); - Assert.Null(deserialized.Flows.Implicit); - Assert.Null(deserialized.Flows.AuthorizationCode); - Assert.NotNull(deserialized.Flows.Password); - Assert.Equal("https://example.com/token", deserialized.Flows.Password.TokenUrl.ToString()); - Assert.NotNull(deserialized.Flows.Password.Scopes); - Assert.Equal(2, deserialized.Flows.Password.Scopes.Count); - Assert.Contains("read", deserialized.Flows.Password.Scopes.Keys); - Assert.Contains("write", deserialized.Flows.Password.Scopes.Keys); - Assert.Equal("Read access", deserialized.Flows.Password.Scopes["read"]); - Assert.Equal("Write access", deserialized.Flows.Password.Scopes["write"]); + Assert.NotNull(deserialized?.HttpAuthSecurityScheme); + Assert.Equal("bearer", deserialized.HttpAuthSecurityScheme.Scheme); + Assert.Equal("JWT", deserialized.HttpAuthSecurityScheme.BearerFormat); } [Fact] - public void OAuth2SecurityScheme_DeserializesFromRawJsonCorrectly() + public void WhenOAuth2Scheme_RoundTrips_Correctly() { - // Arrange - var rawJson = """ + var scheme = new SecurityScheme { - "type": "oauth2", - "description": "OAuth2 authentication", - "flows": { - "password": { - "tokenUrl": "https://example.com/token", - "scopes": { - "read": "Read access", - "write": "Write access" - } + OAuth2SecurityScheme = new OAuth2SecurityScheme + { + Flows = new OAuthFlows + { + ClientCredentials = new ClientCredentialsOAuthFlow + { + TokenUrl = "https://example.com/token", + Scopes = new Dictionary { ["read"] = "Read access" }, + }, } } - } - """; - - // Act - var d = JsonSerializer.Deserialize(rawJson, s_jsonOptions); - - // Assert - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("OAuth2 authentication", deserialized.Description); - Assert.NotNull(deserialized.Flows); - Assert.Null(deserialized.Flows.ClientCredentials); - Assert.Null(deserialized.Flows.Implicit); - Assert.Null(deserialized.Flows.AuthorizationCode); - Assert.NotNull(deserialized.Flows.Password); - Assert.Equal("https://example.com/token", deserialized.Flows.Password.TokenUrl.ToString()); - Assert.NotNull(deserialized.Flows.Password.Scopes); - Assert.Equal(2, deserialized.Flows.Password.Scopes.Count); - Assert.Contains("read", deserialized.Flows.Password.Scopes.Keys); - Assert.Contains("write", deserialized.Flows.Password.Scopes.Keys); - Assert.Equal("Read access", deserialized.Flows.Password.Scopes["read"]); - Assert.Equal("Write access", deserialized.Flows.Password.Scopes["write"]); - } - - [Fact] - public void OpenIdConnectSecurityScheme_SerializesAndDeserializesCorrectly() - { - // Arrange - SecurityScheme scheme = new OpenIdConnectSecurityScheme(new("https://example.com/.well-known/openid_configuration"), "OpenID Connect authentication"); - - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var d = JsonSerializer.Deserialize(json, s_jsonOptions); - - // Assert - Assert.Contains("\"type\": \"openIdConnect\"", json); - Assert.Contains("\"description\": \"OpenID Connect authentication\"", json); - - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("OpenID Connect authentication", deserialized.Description); - Assert.Equal("https://example.com/.well-known/openid_configuration", deserialized.OpenIdConnectUrl.ToString()); - } - - [Fact] - public void MutualTlsSecurityScheme_DeserializesFromBaseSecurityScheme() - { - // Arrange - SecurityScheme scheme = new MutualTlsSecurityScheme(); + }; - // Act - var json = JsonSerializer.Serialize(scheme, s_jsonOptions); - var d = JsonSerializer.Deserialize(json, s_jsonOptions); + var json = JsonSerializer.Serialize(scheme, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - // Assert - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("Mutual TLS authentication", deserialized.Description); + Assert.NotNull(deserialized?.OAuth2SecurityScheme); + Assert.NotNull(deserialized.OAuth2SecurityScheme.Flows?.ClientCredentials); + Assert.Equal("https://example.com/token", deserialized.OAuth2SecurityScheme.Flows.ClientCredentials.TokenUrl); } [Fact] - public void OpenIdConnectSecurityScheme_DeserializesFromRawJsonCorrectly() + public void WhenEmptySecurityScheme_Serialized_OmitsAllNullFields() { - // Arrange - var rawJson = """ - { - "type": "openIdConnect", - "description": "OpenID Connect authentication", - "openIdConnectUrl": "https://example.com/.well-known/openid_configuration" - } - """; + var scheme = new SecurityScheme(); - // Act - var d = JsonSerializer.Deserialize(rawJson, s_jsonOptions); + var json = JsonSerializer.Serialize(scheme, A2AJsonUtilities.DefaultOptions); + var doc = JsonDocument.Parse(json); - // Assert - var deserialized = Assert.IsType(d); - Assert.NotNull(deserialized); - Assert.Equal("OpenID Connect authentication", deserialized.Description); - Assert.Equal("https://example.com/.well-known/openid_configuration", deserialized.OpenIdConnectUrl.ToString()); + Assert.False(doc.RootElement.TryGetProperty("apiKeySecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("httpAuthSecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("oauth2SecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("openIdConnectSecurityScheme", out _)); + Assert.False(doc.RootElement.TryGetProperty("mtlsSecurityScheme", out _)); } } \ No newline at end of file diff --git a/tests/A2A.UnitTests/Models/SendMessageResponseTests.cs b/tests/A2A.UnitTests/Models/SendMessageResponseTests.cs new file mode 100644 index 00000000..1339eefc --- /dev/null +++ b/tests/A2A.UnitTests/Models/SendMessageResponseTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public sealed class SendMessageResponseTests +{ + [Fact] + public void PayloadCase_WhenTaskSet_ReturnsTask() + { + var response = new SendMessageResponse + { + Task = new AgentTask { Id = "t1", ContextId = "ctx1", Status = new TaskStatus { State = TaskState.Working } } + }; + + Assert.Equal(SendMessageResponseCase.Task, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenMessageSet_ReturnsMessage() + { + var response = new SendMessageResponse + { + Message = new Message { MessageId = "m1", Role = Role.Agent, Parts = [Part.FromText("hello")] } + }; + + Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenEmpty_ReturnsNone() + { + var response = new SendMessageResponse(); + + Assert.Equal(SendMessageResponseCase.None, response.PayloadCase); + } + + [Fact] + public void RoundTrip_WithTask_PreservesFields() + { + var response = new SendMessageResponse + { + Task = new AgentTask + { + Id = "task-42", + ContextId = "ctx-7", + Status = new TaskStatus { State = TaskState.Completed } + } + }; + + var json = JsonSerializer.Serialize(response, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Task); + Assert.Equal("task-42", deserialized.Task.Id); + Assert.Equal("ctx-7", deserialized.Task.ContextId); + Assert.Equal(TaskState.Completed, deserialized.Task.Status.State); + Assert.Null(deserialized.Message); + } + + [Fact] + public void RoundTrip_WithMessage_PreservesFields() + { + var response = new SendMessageResponse + { + Message = new Message + { + MessageId = "msg-1", + Role = Role.Agent, + Parts = [Part.FromText("response text")], + ContextId = "ctx-1" + } + }; + + var json = JsonSerializer.Serialize(response, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Message); + Assert.Equal("msg-1", deserialized.Message.MessageId); + Assert.Equal(Role.Agent, deserialized.Message.Role); + Assert.Single(deserialized.Message.Parts); + Assert.Equal("response text", deserialized.Message.Parts[0].Text); + Assert.Equal("ctx-1", deserialized.Message.ContextId); + Assert.Null(deserialized.Task); + } +} diff --git a/tests/A2A.UnitTests/Models/StreamResponseTests.cs b/tests/A2A.UnitTests/Models/StreamResponseTests.cs new file mode 100644 index 00000000..d4b3d842 --- /dev/null +++ b/tests/A2A.UnitTests/Models/StreamResponseTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace A2A.UnitTests.Models; + +public sealed class StreamResponseTests +{ + [Fact] + public void PayloadCase_WhenTaskSet_ReturnsTask() + { + var response = new StreamResponse + { + Task = new AgentTask { Id = "t1", ContextId = "ctx1", Status = new TaskStatus { State = TaskState.Working } } + }; + + Assert.Equal(StreamResponseCase.Task, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenMessageSet_ReturnsMessage() + { + var response = new StreamResponse + { + Message = new Message { MessageId = "m1", Role = Role.Agent, Parts = [Part.FromText("hi")] } + }; + + Assert.Equal(StreamResponseCase.Message, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenStatusUpdateSet_ReturnsStatusUpdate() + { + var response = new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = "t1", + ContextId = "ctx1", + Status = new TaskStatus { State = TaskState.Working } + } + }; + + Assert.Equal(StreamResponseCase.StatusUpdate, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenArtifactUpdateSet_ReturnsArtifactUpdate() + { + var response = new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "t1", + ContextId = "ctx1", + Artifact = new Artifact { ArtifactId = "a1", Parts = [Part.FromText("data")] } + } + }; + + Assert.Equal(StreamResponseCase.ArtifactUpdate, response.PayloadCase); + } + + [Fact] + public void PayloadCase_WhenEmpty_ReturnsNone() + { + var response = new StreamResponse(); + + Assert.Equal(StreamResponseCase.None, response.PayloadCase); + } + + [Fact] + public void RoundTrip_WithStatusUpdate_PreservesFields() + { + var response = new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = "task-99", + ContextId = "ctx-5", + Status = new TaskStatus { State = TaskState.InputRequired } + } + }; + + var json = JsonSerializer.Serialize(response, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.StatusUpdate); + Assert.Equal("task-99", deserialized.StatusUpdate.TaskId); + Assert.Equal("ctx-5", deserialized.StatusUpdate.ContextId); + Assert.Equal(TaskState.InputRequired, deserialized.StatusUpdate.Status.State); + Assert.Null(deserialized.Task); + Assert.Null(deserialized.Message); + Assert.Null(deserialized.ArtifactUpdate); + } +} diff --git a/tests/A2A.UnitTests/ParsingTests.cs b/tests/A2A.UnitTests/ParsingTests.cs index b9e0ca68..7b4cf4e8 100644 --- a/tests/A2A.UnitTests/ParsingTests.cs +++ b/tests/A2A.UnitTests/ParsingTests.cs @@ -6,66 +6,62 @@ namespace A2A.UnitTests; public class ParsingTests { [Fact] - public void RoundTripTaskSendParams() + public void RoundTripSendMessageRequest() { // Arrange - var taskSendParams = new MessageSendParams + var sendRequest = new SendMessageRequest { - Message = new AgentMessage() + Message = new Message { Parts = [ - new TextPart() - { - Text = "Hello, World!", - } + Part.FromText("Hello, World!"), ], + Role = Role.User, }, }; - var json = JsonSerializer.Serialize(taskSendParams); + var json = JsonSerializer.Serialize(sendRequest, A2AJsonUtilities.DefaultOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var deserializedParams = JsonSerializer.Deserialize(stream); + var deserializedParams = JsonSerializer.Deserialize(stream, A2AJsonUtilities.DefaultOptions); // Act var result = deserializedParams; // Assert Assert.NotNull(result); - Assert.Equal(((TextPart)taskSendParams.Message.Parts[0]).Text, ((TextPart)result.Message.Parts[0]).Text); + Assert.Equal(sendRequest.Message.Parts[0].Text, result.Message.Parts[0].Text); } [Fact] - public void JsonRpcTaskSend() + public void JsonRpcSendMessage() { // Arrange - var taskSendParams = new MessageSendParams + var sendRequest = new SendMessageRequest { - Message = new AgentMessage() + Message = new Message { Parts = [ - new TextPart() - { - Text = "Hello, World!", - } + Part.FromText("Hello, World!"), ], + Role = Role.User, }, }; var jsonRpcRequest = new JsonRpcRequest { - Method = A2AMethods.MessageSend, - Params = JsonSerializer.SerializeToElement(taskSendParams), + Method = A2AMethods.SendMessage, + Params = JsonSerializer.SerializeToElement(sendRequest, A2AJsonUtilities.DefaultOptions), }; - var json = JsonSerializer.Serialize(jsonRpcRequest); + var json = JsonSerializer.Serialize(jsonRpcRequest, A2AJsonUtilities.DefaultOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var deserializedRequest = JsonSerializer.Deserialize(stream); + var deserializedRequest = JsonSerializer.Deserialize(stream, A2AJsonUtilities.DefaultOptions); // Act - var result = deserializedRequest?.Params?.Deserialize(); + var result = deserializedRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); // Assert Assert.NotNull(result); - Assert.Equal(((TextPart)taskSendParams.Message.Parts[0]).Text, ((TextPart)result.Message.Parts[0]).Text); + Assert.Equal(sendRequest.Message.Parts[0].Text, result.Message.Parts[0].Text); } [Fact] @@ -76,14 +72,14 @@ public void RoundTripTaskStatusUpdateEvent() { TaskId = "test-task", ContextId = "test-session", - Status = new AgentTaskStatus + Status = new TaskStatus { State = TaskState.Working, } }; - var json = JsonSerializer.Serialize(taskStatusUpdateEvent); + var json = JsonSerializer.Serialize(taskStatusUpdateEvent, A2AJsonUtilities.DefaultOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var deserializedEvent = JsonSerializer.Deserialize(stream); + var deserializedEvent = JsonSerializer.Deserialize(stream, A2AJsonUtilities.DefaultOptions); // Act var result = deserializedEvent; @@ -107,18 +103,13 @@ public void RoundTripArtifactUpdateEvent() { Parts = [ - new TextPart - { - Text = "Hello, World!", - } + Part.FromText("Hello, World!"), ], } }; - var json = JsonSerializer.Serialize(taskArtifactUpdateEvent); + var json = JsonSerializer.Serialize(taskArtifactUpdateEvent, A2AJsonUtilities.DefaultOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - // Deserialize using the base class - // This is important to ensure polymorphic deserialization works correctly - var deserializedEvent = JsonSerializer.Deserialize(stream); + var deserializedEvent = JsonSerializer.Deserialize(stream, A2AJsonUtilities.DefaultOptions); // Act var result = deserializedEvent; @@ -127,43 +118,84 @@ public void RoundTripArtifactUpdateEvent() Assert.NotNull(result); Assert.Equal(taskArtifactUpdateEvent.TaskId, result.TaskId); Assert.Equal(taskArtifactUpdateEvent.ContextId, result.ContextId); - Assert.Equal(taskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text, result.Artifact.Parts[0].AsTextPart().Text); + Assert.Equal(taskArtifactUpdateEvent.Artifact.Parts[0].Text, result.Artifact.Parts[0].Text); } [Fact] - public void RoundTripJsonRpcResponseWithArtifactUpdateStatus() + public void RoundTripStreamResponse() { // Arrange - var taskArtifactUpdateEvent = new TaskArtifactUpdateEvent + var streamResponse = new StreamResponse { - TaskId = "test-task", - ContextId = "test-session", - Artifact = new Artifact + ArtifactUpdate = new TaskArtifactUpdateEvent { - Parts = - [ - new TextPart - { - Text = "Hello, World!", - } - ], + TaskId = "test-task", + ContextId = "test-session", + Artifact = new Artifact + { + Parts = [Part.FromText("Hello, World!")], + } } }; - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("test-id", taskArtifactUpdateEvent); - var json = JsonSerializer.Serialize(jsonRpcResponse); + + var json = JsonSerializer.Serialize(streamResponse, A2AJsonUtilities.DefaultOptions); using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - var deserializedResponse = JsonSerializer.Deserialize(stream); - // Deserialize using the base class - // This is important to ensure polymorphic deserialization works correctly - var resultObject = (deserializedResponse?.Result).Deserialize(); + var deserialized = JsonSerializer.Deserialize(stream, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.ArtifactUpdate); + Assert.Equal("test-task", deserialized.ArtifactUpdate!.TaskId); + Assert.Equal("Hello, World!", deserialized.ArtifactUpdate.Artifact.Parts[0].Text); + } + + [Fact] + public void RoundTripSendMessageResponse() + { + // Arrange + var sendMsgResponse = new SendMessageResponse + { + Task = new AgentTask + { + Id = "test-task", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + } + }; + + var json = JsonSerializer.Serialize(sendMsgResponse, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Task); + Assert.Equal("test-task", deserialized.Task!.Id); + Assert.Equal(TaskState.Completed, deserialized.Task.Status.State); + } + + [Fact] + public void TaskState_SerializesWithV1Format() + { + // Arrange + var status = new TaskStatus { State = TaskState.Completed }; + + // Act + var json = JsonSerializer.Serialize(status, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.Contains("TASK_STATE_COMPLETED", json); + } + + [Fact] + public void Role_SerializesWithV1Format() + { + // Arrange + var message = new Message { Role = Role.User, Parts = [] }; + // Act + var json = JsonSerializer.Serialize(message, A2AJsonUtilities.DefaultOptions); // Assert - Assert.NotNull(resultObject); - var resultTaskArtifactUpdateEvent = resultObject as TaskArtifactUpdateEvent; - Assert.NotNull(resultTaskArtifactUpdateEvent); - Assert.Equal(taskArtifactUpdateEvent.TaskId, resultTaskArtifactUpdateEvent.TaskId); - Assert.Equal(taskArtifactUpdateEvent.ContextId, resultTaskArtifactUpdateEvent.ContextId); - Assert.Equal(taskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text, resultTaskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text); + Assert.Contains("ROLE_USER", json); } } \ No newline at end of file diff --git a/tests/A2A.UnitTests/Server/A2AServerTests.cs b/tests/A2A.UnitTests/Server/A2AServerTests.cs new file mode 100644 index 00000000..41d91694 --- /dev/null +++ b/tests/A2A.UnitTests/Server/A2AServerTests.cs @@ -0,0 +1,605 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Threading.Channels; + +namespace A2A.UnitTests.Server; + +public class A2AServerTests +{ + private sealed class TestAgentHandler : IAgentHandler + { + public Func? OnExecute { get; set; } + public Func? OnCancel { get; set; } + + public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + => OnExecute?.Invoke(context, eventQueue, cancellationToken) ?? Task.CompletedTask; + + public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + => OnCancel?.Invoke(context, eventQueue, cancellationToken) + ?? new TaskUpdater(eventQueue, context.TaskId, context.ContextId).CancelAsync(cancellationToken).AsTask(); + } + + private static (A2AServer server, InMemoryTaskStore store, TestAgentHandler handler) + CreateServer() + { + var notifier = new ChannelEventNotifier(); + var store = new InMemoryTaskStore(); + var handler = new TestAgentHandler(); + var server = new A2AServer(handler, store, notifier, NullLogger.Instance); + return (server, store, handler); + } + + [Fact] + public async Task GivenNewMessage_WhenHandlerReturnsMessage_ThenSendMessageReturnsMessageResponse() + { + // Arrange + var (server, _, handler) = CreateServer(); + handler.OnExecute = async (ctx, eq, ct) => + { + await eq.EnqueueMessageAsync(new Message + { + Role = Role.Agent, + MessageId = "m1", + ContextId = ctx.ContextId, + Parts = [Part.FromText("Goodbye!")], + }, ct); + eq.Complete(); + }; + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "u1", Parts = [Part.FromText("Hello!")], Role = Role.User } + }; + + // Act + var result = await server.SendMessageAsync(request); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Message); + Assert.Equal("Goodbye!", result.Message!.Parts[0].Text); + } + + [Fact] + public async Task GivenNewMessage_WhenHandlerReturnsTask_ThenSendMessageReturnsTaskResponse() + { + // Arrange + var (server, _, handler) = CreateServer(); + handler.OnExecute = async (ctx, eq, ct) => + { + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.SubmitAsync(ct); + await updater.CompleteAsync(cancellationToken: ct); + }; + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "u1", Parts = [Part.FromText("Hello!")], Role = Role.User } + }; + + // Act + var result = await server.SendMessageAsync(request); + + // Assert — task status reflects final state after all events consumed + Assert.NotNull(result); + Assert.NotNull(result.Task); + Assert.Equal(TaskState.Completed, result.Task!.Status.State); + } + + [Fact] + public async Task GivenNewMessage_WhenHandlerReturnsTask_ThenTaskPersistedInStore() + { + // Arrange + var (server, store, handler) = CreateServer(); + string? capturedTaskId = null; + handler.OnExecute = async (ctx, eq, ct) => + { + capturedTaskId = ctx.TaskId; + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.SubmitAsync(ct); + await updater.CompleteAsync(cancellationToken: ct); + }; + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "u1", Parts = [Part.FromText("Hello!")], Role = Role.User } + }; + + // Act + await server.SendMessageAsync(request); + + // Assert + Assert.NotNull(capturedTaskId); + var persisted = await store.GetTaskAsync(capturedTaskId!); + Assert.NotNull(persisted); + } + + [Fact] + public async Task GivenExistingTask_WhenSendMessage_ThenHistoryAppended() + { + // Arrange + var (server, store, handler) = CreateServer(); + var existingTask = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + History = [new Message { MessageId = "m0", Parts = [Part.FromText("initial")] }], + }; + await store.SaveTaskAsync(existingTask.Id, existingTask); + + handler.OnExecute = async (ctx, eq, ct) => + { + await eq.EnqueueMessageAsync(new Message + { + Role = Role.Agent, + MessageId = "m2", + ContextId = ctx.ContextId, + Parts = [Part.FromText("reply")], + }, ct); + eq.Complete(); + }; + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "m1", TaskId = "t1", Parts = [Part.FromText("follow-up")], Role = Role.User } + }; + + // Act + await server.SendMessageAsync(request); + + // Assert — history should now have 3 messages: initial (m0), user follow-up (m1), agent reply (m2) + var task = await store.GetTaskAsync("t1"); + Assert.NotNull(task); + Assert.NotNull(task!.History); + Assert.Equal(3, task.History.Count); + } + + [Fact] + public async Task GivenTerminalTask_WhenSendMessage_ThenThrowsUnsupportedOperation() + { + // Arrange + var (server, store, handler) = CreateServer(); + handler.OnExecute = (_, eq, _) => { eq.Complete(); return Task.CompletedTask; }; + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + }); + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "u1", TaskId = "t1", Parts = [Part.FromText("hi")], Role = Role.User } + }; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => server.SendMessageAsync(request)); + Assert.Equal(A2AErrorCode.UnsupportedOperation, ex.ErrorCode); + } + + [Fact] + public async Task GivenMissingTaskId_WhenSendMessage_ThenContextIdsGenerated() + { + // Arrange + var (server, _, handler) = CreateServer(); + RequestContext? capturedContext = null; + handler.OnExecute = async (ctx, eq, ct) => + { + capturedContext = ctx; + await eq.EnqueueMessageAsync(new Message + { + Role = Role.Agent, + MessageId = "m1", + Parts = [Part.FromText("ok")], + }, ct); + eq.Complete(); + }; + + var request = new SendMessageRequest + { + Message = new Message { MessageId = "u1", Parts = [Part.FromText("hi")], Role = Role.User } + }; + + // Act + await server.SendMessageAsync(request); + + // Assert + Assert.NotNull(capturedContext); + Assert.False(string.IsNullOrEmpty(capturedContext!.TaskId)); + Assert.False(string.IsNullOrEmpty(capturedContext.ContextId)); + Assert.False(capturedContext.ClientProvidedContextId); + Assert.False(capturedContext.IsContinuation); + } + + [Fact] + public async Task GivenExistingTask_WhenCancelTask_ThenHandlerCancelCalled() + { + // Arrange + var (server, store, handler) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + }); + + bool cancelCalled = false; + handler.OnCancel = async (ctx, eq, ct) => + { + cancelCalled = true; + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.CancelAsync(ct); + }; + + // Act + var result = await server.CancelTaskAsync(new CancelTaskRequest { Id = "t1" }); + + // Assert + Assert.True(cancelCalled); + Assert.Equal(TaskState.Canceled, result.Status.State); + } + + [Fact] + public async Task GivenExistingTask_WhenCancelTaskWithMetadata_ThenMetadataPassedToHandler() + { + // Arrange + var (server, store, handler) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + }); + + Dictionary? capturedMetadata = null; + handler.OnCancel = async (ctx, eq, ct) => + { + capturedMetadata = ctx.Metadata; + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.CancelAsync(ct); + }; + + var metadata = new Dictionary + { + ["reason"] = System.Text.Json.JsonDocument.Parse("\"user-requested\"").RootElement, + }; + + // Act + await server.CancelTaskAsync(new CancelTaskRequest { Id = "t1", Metadata = metadata }); + + // Assert + Assert.NotNull(capturedMetadata); + Assert.True(capturedMetadata!.ContainsKey("reason")); + Assert.Equal("user-requested", capturedMetadata["reason"].GetString()); + } + + [Fact] + public async Task GivenTerminalTask_WhenCancelTask_ThenThrowsTaskNotCancelable() + { + // Arrange + var (server, store, _) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + server.CancelTaskAsync(new CancelTaskRequest { Id = "t1" })); + Assert.Equal(A2AErrorCode.TaskNotCancelable, ex.ErrorCode); + } + + [Fact] + public async Task GetTaskAsync_ReturnsTask_WhenExists() + { + // Arrange + var (server, store, _) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted } + }); + + // Act + var result = await server.GetTaskAsync(new GetTaskRequest { Id = "t1" }); + + // Assert + Assert.NotNull(result); + Assert.Equal("t1", result.Id); + Assert.Equal(TaskState.Submitted, result.Status.State); + } + + [Fact] + public async Task GetTaskAsync_ThrowsTaskNotFound_WhenNotExists() + { + // Arrange + var (server, _, _) = CreateServer(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + server.GetTaskAsync(new GetTaskRequest { Id = "nonexistent" })); + Assert.Equal(A2AErrorCode.TaskNotFound, ex.ErrorCode); + } + + [Fact] + public async Task GetTaskAsync_RespectsHistoryLength() + { + // Arrange + var (server, store, _) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + History = [ + new Message { MessageId = "m1", Parts = [Part.FromText("First")] }, + new Message { MessageId = "m2", Parts = [Part.FromText("Second")] }, + new Message { MessageId = "m3", Parts = [Part.FromText("Third")] }, + ] + }); + + // Act + var result = await server.GetTaskAsync(new GetTaskRequest { Id = "t1", HistoryLength = 2 }); + + // Assert + Assert.NotNull(result.History); + Assert.Equal(2, result.History.Count); + Assert.Equal("m2", result.History[0].MessageId); + Assert.Equal("m3", result.History[1].MessageId); + } + + [Fact] + public async Task SubscribeToTaskAsync_ThrowsTaskNotFound_WhenNotExists() + { + // Arrange + var (server, _, _) = CreateServer(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in server.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "notfound" })) + { + } + }); + Assert.Equal(A2AErrorCode.TaskNotFound, ex.ErrorCode); + } + + [Fact] + public async Task SubscribeToTaskAsync_ReturnsTaskAsFirstEvent() + { + // Arrange + var (server, store, handler) = CreateServer(); + handler.OnExecute = (_, eq, _) => { eq.Complete(); return Task.CompletedTask; }; + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Act — first event from SubscribeToTaskAsync MUST be the Task object (spec §3.1.6) + StreamResponse? firstEvent = null; + await foreach (var e in server.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "t1" }, cts.Token)) + { + firstEvent = e; + break; // Only need the first event + } + + // Assert + Assert.NotNull(firstEvent); + Assert.NotNull(firstEvent!.Task); + Assert.Equal("t1", firstEvent.Task!.Id); + } + + [Fact] + public async Task SubscribeToTaskAsync_ThrowsUnsupportedOperation_WhenTerminalState() + { + // Arrange + var (server, store, _) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in server.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "t1" })) + { + } + }); + Assert.Equal(A2AErrorCode.UnsupportedOperation, ex.ErrorCode); + } + + [Fact] + public async Task ListTasksAsync_DelegatesToStore() + { + // Arrange + var (server, store, _) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Submitted } }); + await store.SaveTaskAsync("t2", new AgentTask { Id = "t2", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Completed } }); + + // Act + var result = await server.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" }); + + // Assert + Assert.NotNull(result.Tasks); + Assert.Equal(2, result.Tasks!.Count); + } + + [Fact] + public async Task PushNotificationConfig_ThrowsNotSupported() + { + // Arrange + var (server, _, _) = CreateServer(); + + // Act & Assert + await Assert.ThrowsAsync(() => + server.CreateTaskPushNotificationConfigAsync(new CreateTaskPushNotificationConfigRequest())); + await Assert.ThrowsAsync(() => + server.GetTaskPushNotificationConfigAsync(new GetTaskPushNotificationConfigRequest())); + } + + [Fact] + public async Task GetExtendedAgentCard_ThrowsNotConfigured() + { + // Arrange + var (server, _, _) = CreateServer(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + server.GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest())); + Assert.Equal(A2AErrorCode.ExtendedAgentCardNotConfigured, ex.ErrorCode); + } + + [Fact] + public async Task SubscribeToTaskAsync_DeliversLiveEvents() + { + var (server, store, handler) = CreateServer(); + // Seed a working task + await store.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Working } }); + + handler.OnExecute = async (ctx, eq, ct) => + { + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.SubmitAsync(ct); + await updater.CompleteAsync(cancellationToken: ct); + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var events = new List(); + var snapshotReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Start subscribing (first event = task snapshot, signals channel is registered) + var subscribeTask = Task.Run(async () => + { + await foreach (var e in server.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "t1" }, cts.Token)) + { + events.Add(e); + if (events.Count == 1) snapshotReceived.TrySetResult(); + if (e.StatusUpdate?.Status.State.IsTerminal() == true) break; + } + }, cts.Token); + + // Wait for snapshot (proves channel is registered — no race) + await snapshotReceived.Task.WaitAsync(cts.Token); + + // Send a message that triggers the handler (which completes the task) + await server.SendMessageAsync(new SendMessageRequest + { + Message = new Message { Role = Role.User, MessageId = "m1", TaskId = "t1", Parts = [Part.FromText("go")] } + }, cts.Token); + + await subscribeTask; + + // First event = Task snapshot, subsequent = live events + Assert.True(events.Count >= 2); + Assert.NotNull(events[0].Task); // snapshot + } + + [Fact] + public async Task SubscribeToTaskAsync_AtomicRace_NoMissedEvents() + { + // Directly test via notifier + store to control timing precisely + var notifier = new ChannelEventNotifier(); + var store = new InMemoryTaskStore(); + await store.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Working } }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Simulate subscribe: lock → get + createChannel → unlock + Channel channel; + using (await notifier.AcquireTaskLockAsync("t1", cts.Token)) + { + var task = await store.GetTaskAsync("t1", cts.Token); + Assert.NotNull(task); + channel = notifier.CreateChannel("t1"); + } + + // Simulate persist: lock → save + notify → unlock + using (await notifier.AcquireTaskLockAsync("t1", cts.Token)) + { + var completed = new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Completed } }; + await store.SaveTaskAsync("t1", completed, cts.Token); + notifier.Notify("t1", new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Completed } } + }); + } + + // Verify: event was delivered to the channel (not missed) + Assert.True(channel.Reader.TryRead(out var evt)); + Assert.NotNull(evt.StatusUpdate); + Assert.Equal(TaskState.Completed, evt.StatusUpdate!.Status.State); + } + + [Fact] + public async Task SubscribeToTaskAsync_MultipleSubscribers_AllReceive() + { + var notifier = new ChannelEventNotifier(); + + // Register two subscriber channels + var ch1 = notifier.CreateChannel("t1"); + var ch2 = notifier.CreateChannel("t1"); + + // Persist and notify + notifier.Notify("t1", new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Completed } } + }); + + // Both channels should receive the event + Assert.True(ch1.Reader.TryRead(out var e1)); + Assert.True(ch2.Reader.TryRead(out var e2)); + Assert.Equal(TaskState.Completed, e1.StatusUpdate!.Status.State); + Assert.Equal(TaskState.Completed, e2.StatusUpdate!.Status.State); + } + + [Fact] + public async Task SubscribeToTaskAsync_TerminalEvent_EndsStream() + { + var (server, store, handler) = CreateServer(); + await store.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Working } }); + + handler.OnExecute = async (ctx, eq, ct) => + { + var updater = new TaskUpdater(eq, ctx.TaskId, ctx.ContextId); + await updater.SubmitAsync(ct); + await updater.FailAsync(cancellationToken: ct); + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var events = new List(); + var snapshotReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var subscribeTask = Task.Run(async () => + { + await foreach (var e in server.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = "t1" }, cts.Token)) + { + events.Add(e); + if (events.Count == 1) snapshotReceived.TrySetResult(); + } + }, cts.Token); + + // Wait for snapshot (proves channel is registered — no race) + await snapshotReceived.Task.WaitAsync(cts.Token); + + await server.SendMessageAsync(new SendMessageRequest + { + Message = new Message { Role = Role.User, MessageId = "m1", TaskId = "t1", Parts = [Part.FromText("trigger")] } + }, cts.Token); + + await subscribeTask; // Should complete without timeout + + Assert.True(events.Count >= 2); // snapshot + at least terminal + Assert.Contains(events, e => e.StatusUpdate?.Status.State == TaskState.Failed); + } +} diff --git a/tests/A2A.UnitTests/Server/AgentContextTests.cs b/tests/A2A.UnitTests/Server/AgentContextTests.cs new file mode 100644 index 00000000..ba9a1184 --- /dev/null +++ b/tests/A2A.UnitTests/Server/AgentContextTests.cs @@ -0,0 +1,62 @@ +namespace A2A.UnitTests.Server; + +public class AgentContextTests +{ + [Fact] + public void GivenMessageWithTextPart_WhenUserText_ThenReturnsText() + { + var context = new RequestContext + { + Message = new Message { MessageId = "m1", Role = Role.User, Parts = [Part.FromText("hello")] }, + TaskId = "t1", + ContextId = "ctx-1", + StreamingResponse = false, + }; + + Assert.Equal("hello", context.UserText); + } + + [Fact] + public void GivenMessageWithoutTextPart_WhenUserText_ThenReturnsNull() + { + var context = new RequestContext + { + Message = new Message { MessageId = "m1", Role = Role.User, Parts = [new Part { Data = System.Text.Json.JsonDocument.Parse("{}").RootElement }] }, + TaskId = "t1", + ContextId = "ctx-1", + StreamingResponse = false, + }; + + Assert.Null(context.UserText); + } + + [Fact] + public void GivenTaskSet_WhenIsContinuation_ThenReturnsTrue() + { + var context = new RequestContext + { + Message = new Message { MessageId = "m1", Role = Role.User, Parts = [Part.FromText("hi")] }, + Task = new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Working } }, + TaskId = "t1", + ContextId = "ctx-1", + StreamingResponse = false, + }; + + Assert.True(context.IsContinuation); + } + + [Fact] + public void GivenNoTask_WhenIsContinuation_ThenReturnsFalse() + { + var context = new RequestContext + { + Message = new Message { MessageId = "m1", Role = Role.User, Parts = [Part.FromText("hi")] }, + Task = null, + TaskId = "t1", + ContextId = "ctx-1", + StreamingResponse = false, + }; + + Assert.False(context.IsContinuation); + } +} diff --git a/tests/A2A.UnitTests/Server/AgentEventQueueTests.cs b/tests/A2A.UnitTests/Server/AgentEventQueueTests.cs new file mode 100644 index 00000000..9a674ece --- /dev/null +++ b/tests/A2A.UnitTests/Server/AgentEventQueueTests.cs @@ -0,0 +1,142 @@ +namespace A2A.UnitTests.Server; + +public class AgentEventQueueTests +{ + [Fact] + public async Task WriteAsync_ShouldYieldResponse() + { + // Arrange + var queue = new AgentEventQueue(); + var response = new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", Status = new TaskStatus { State = TaskState.Submitted } } + }; + await queue.WriteAsync(response); + queue.Complete(); + + // Act + List yielded = []; + await foreach (var e in queue) + { + yielded.Add(e); + } + + // Assert + Assert.Single(yielded); + Assert.NotNull(yielded[0].StatusUpdate); + Assert.Equal("t1", yielded[0].StatusUpdate!.TaskId); + } + + [Fact] + public async Task Complete_ShouldEndEnumeration() + { + // Arrange + var queue = new AgentEventQueue(); + var r1 = new StreamResponse { StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", Status = new TaskStatus { State = TaskState.Submitted } } }; + var r2 = new StreamResponse { StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", Status = new TaskStatus { State = TaskState.Working } } }; + var r3 = new StreamResponse { StatusUpdate = new TaskStatusUpdateEvent { TaskId = "t1", Status = new TaskStatus { State = TaskState.Completed } } }; + await queue.WriteAsync(r1); + await queue.WriteAsync(r2); + await queue.WriteAsync(r3); + queue.Complete(); + + // Act + List yielded = []; + await foreach (var e in queue) + { + yielded.Add(e); + } + + // Assert + Assert.Equal(3, yielded.Count); + Assert.Equal(TaskState.Submitted, yielded[0].StatusUpdate!.Status.State); + Assert.Equal(TaskState.Working, yielded[1].StatusUpdate!.Status.State); + Assert.Equal(TaskState.Completed, yielded[2].StatusUpdate!.Status.State); + } + + [Fact] + public async Task Enumerator_ShouldSupportCancellation() + { + // Arrange + var queue = new AgentEventQueue(); + var cts = new CancellationTokenSource(50); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in queue.WithCancellation(cts.Token)) + { + // Should not yield - will time out and cancel + } + }); + } + + [Fact] + public async Task WriteAsync_MessageResponse_ShouldYield() + { + // Arrange + var queue = new AgentEventQueue(); + var response = new StreamResponse + { + Message = new Message { Role = Role.Agent, Parts = [Part.FromText("hello")] } + }; + await queue.WriteAsync(response); + queue.Complete(); + + // Act + List yielded = []; + await foreach (var e in queue) + { + yielded.Add(e); + } + + // Assert + Assert.Single(yielded); + Assert.NotNull(yielded[0].Message); + Assert.Equal("hello", yielded[0].Message!.Parts[0].Text); + } + + [Fact] + public async Task EnqueueTaskAsync_ShouldYieldTaskResponse() + { + // Arrange + var queue = new AgentEventQueue(); + var task = new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Submitted } }; + await queue.EnqueueTaskAsync(task); + queue.Complete(); + + // Act + List yielded = []; + await foreach (var e in queue) + { + yielded.Add(e); + } + + // Assert + Assert.Single(yielded); + Assert.NotNull(yielded[0].Task); + Assert.Equal("t1", yielded[0].Task!.Id); + } + + [Fact] + public async Task EnqueueMessageAsync_ShouldYieldMessageResponse() + { + // Arrange + var queue = new AgentEventQueue(); + var message = new Message { Role = Role.Agent, MessageId = "m1", Parts = [Part.FromText("hi")] }; + await queue.EnqueueMessageAsync(message); + queue.Complete(); + + // Act + List yielded = []; + await foreach (var e in queue) + { + yielded.Add(e); + } + + // Assert + Assert.Single(yielded); + Assert.NotNull(yielded[0].Message); + Assert.Equal("m1", yielded[0].Message!.MessageId); + } +} diff --git a/tests/A2A.UnitTests/Server/ChannelEventNotifierTests.cs b/tests/A2A.UnitTests/Server/ChannelEventNotifierTests.cs new file mode 100644 index 00000000..18f69d8d --- /dev/null +++ b/tests/A2A.UnitTests/Server/ChannelEventNotifierTests.cs @@ -0,0 +1,221 @@ +using System.Threading.Channels; + +namespace A2A.UnitTests.Server; + +public class ChannelEventNotifierTests +{ + private static StreamResponse WorkingStatus(string taskId = "t1") + => new() + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + } + }; + + private static StreamResponse TerminalStatus(string taskId = "t1") + => new() + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = taskId, + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + } + }; + + [Fact] + public void Notify_WithNoSubscribers_DoesNotThrow() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var evt = WorkingStatus(); + + // Act & Assert — should not throw + notifier.Notify("t1", evt); + } + + [Fact] + public async Task Notify_PushesToAllRegisteredChannels() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var ch1 = notifier.CreateChannel("t1"); + var ch2 = notifier.CreateChannel("t1"); + + var evt = WorkingStatus(); + + // Act + notifier.Notify("t1", evt); + + // Assert — both channels should receive the event + Assert.True(ch1.Reader.TryRead(out var r1)); + Assert.NotNull(r1.StatusUpdate); + + Assert.True(ch2.Reader.TryRead(out var r2)); + Assert.NotNull(r2.StatusUpdate); + } + + [Fact] + public async Task Notify_TerminalEvent_CompletesChannels() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var ch = notifier.CreateChannel("t1"); + + var evt = TerminalStatus(); + + // Act + notifier.Notify("t1", evt); + + // Assert — channel should receive the event and then complete + Assert.True(ch.Reader.TryRead(out var received)); + Assert.NotNull(received.StatusUpdate); + Assert.Equal(TaskState.Completed, received.StatusUpdate!.Status.State); + + // Reader should complete since the event is terminal + await ch.Reader.Completion; + } + + [Fact] + public void CreateChannel_RegistersForTask() + { + // Arrange + var notifier = new ChannelEventNotifier(); + + // Act + var ch = notifier.CreateChannel("t1"); + var evt = WorkingStatus(); + notifier.Notify("t1", evt); + + // Assert — channel should receive the event + Assert.True(ch.Reader.TryRead(out var received)); + Assert.NotNull(received.StatusUpdate); + } + + [Fact] + public void RemoveChannel_UnregistersChannel() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var ch = notifier.CreateChannel("t1"); + + // Act + notifier.RemoveChannel("t1", ch); + + var evt = WorkingStatus(); + notifier.Notify("t1", evt); + + // Assert — removed channel should NOT receive the event + Assert.False(ch.Reader.TryRead(out _)); + } + + [Fact] + public void Notify_AfterRemoveChannel_DoesNotPushToRemoved() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var ch1 = notifier.CreateChannel("t1"); + var ch2 = notifier.CreateChannel("t1"); + + // Remove ch1 but keep ch2 + notifier.RemoveChannel("t1", ch1); + + var evt = WorkingStatus(); + + // Act + notifier.Notify("t1", evt); + + // Assert — ch1 should NOT receive, ch2 should receive + Assert.False(ch1.Reader.TryRead(out _)); + Assert.True(ch2.Reader.TryRead(out var received)); + Assert.NotNull(received.StatusUpdate); + } + + [Fact] + public async Task AcquireTaskLockAsync_BlocksConcurrentAccess() + { + // Arrange + var notifier = new ChannelEventNotifier(); + var lockAcquired = false; + var lockReleased = false; + + // Act — acquire lock, verify second acquire blocks until first released + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var firstLock = await notifier.AcquireTaskLockAsync("t1", cts.Token); + + var secondLockTask = Task.Run(async () => + { + lockAcquired = true; + using var secondLock = await notifier.AcquireTaskLockAsync("t1", cts.Token); + lockReleased = true; + }, cts.Token); + + // Allow time for the second lock attempt to start waiting + await Task.Delay(200, cts.Token); + + // Assert — second lock should be waiting (lockReleased still false) + Assert.True(lockAcquired); + Assert.False(lockReleased); + + // Release first lock + firstLock.Dispose(); + await secondLockTask; + + // Assert — second lock was acquired and released + Assert.True(lockReleased); + } + + [Fact] + public async Task AcquireTaskLockAsync_IsPerTask_NotGlobal() + { + // Arrange + var notifier = new ChannelEventNotifier(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Act — acquire lock for t1, then immediately acquire for t2 (should not block) + using var lock1 = await notifier.AcquireTaskLockAsync("t1", cts.Token); + using var lock2 = await notifier.AcquireTaskLockAsync("t2", cts.Token); + + // Assert — if we get here, locks for different tasks don't block each other + Assert.NotNull(lock1); + Assert.NotNull(lock2); + } + + [Fact] + public void Notify_DoesNotCrossTaskBoundary() + { + var notifier = new ChannelEventNotifier(); + var chA = notifier.CreateChannel("task-a"); + var chB = notifier.CreateChannel("task-b"); + + notifier.Notify("task-a", WorkingStatus("task-a")); + + Assert.True(chA.Reader.TryRead(out _)); // task-a gets it + Assert.False(chB.Reader.TryRead(out _)); // task-b does not + } + + [Fact] + public async Task ConcurrentNotify_DoesNotCorruptChannelList() + { + var notifier = new ChannelEventNotifier(); + + // Run 20 concurrent operations: create channels, notify, remove channels + var tasks = Enumerable.Range(0, 20).Select(i => Task.Run(() => + { + if (i % 3 == 0) + { + var ch = notifier.CreateChannel("t1"); + notifier.RemoveChannel("t1", ch); + } + else + { + notifier.Notify("t1", WorkingStatus()); + } + })); + + await Task.WhenAll(tasks); // Should not throw or deadlock + } +} diff --git a/tests/A2A.UnitTests/Server/InMemoryTaskStoreTests.cs b/tests/A2A.UnitTests/Server/InMemoryTaskStoreTests.cs index 09846931..0d9e3605 100644 --- a/tests/A2A.UnitTests/Server/InMemoryTaskStoreTests.cs +++ b/tests/A2A.UnitTests/Server/InMemoryTaskStoreTests.cs @@ -1,283 +1,300 @@ -namespace A2A.UnitTests.Server; - -public class InMemoryTaskStoreTests -{ - [Fact] - public async Task SetTaskAsync_And_GetTaskAsync_ShouldStoreAndRetrieveTask() - { - // Arrange - var sut = new InMemoryTaskStore(); - var task = new AgentTask { Id = "task1", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - - // Act - await sut.SetTaskAsync(task); - var result = await sut.GetTaskAsync("task1"); - - // Assert - Assert.NotNull(result); - Assert.Equal("task1", result!.Id); - Assert.Equal(TaskState.Submitted, result.Status.State); - } - - [Fact] - public async Task GetTaskAsync_ShouldReturnNull_WhenTaskDoesNotExist() - { - // Arrange - var sut = new InMemoryTaskStore(); - - // Act - var result = await sut.GetTaskAsync("nonexistent"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldUpdateTaskStatus() - { - // Arrange - var sut = new InMemoryTaskStore(); - var task = new AgentTask { Id = "task2", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - await sut.SetTaskAsync(task); - var message = new AgentMessage { MessageId = "msg1" }; - - // Act - var status = await sut.UpdateStatusAsync("task2", TaskState.Working, message); - var updatedTask = await sut.GetTaskAsync("task2"); - - // Assert - Assert.Equal(TaskState.Working, status.State); - Assert.Equal(TaskState.Working, updatedTask!.Status.State); - Assert.Equal("msg1", status.Message!.MessageId); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldThrow_WhenTaskDoesNotExist() - { - // Arrange - var sut = new InMemoryTaskStore(); - - // Act & Assert - var ex = await Assert.ThrowsAsync(() => sut.UpdateStatusAsync("notfound", TaskState.Completed)); - Assert.Equal(A2AErrorCode.TaskNotFound, ex.ErrorCode); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnNull_WhenTaskDoesNotExist() - { - // Arrange - var sut = new InMemoryTaskStore(); - - // Act - var result = await sut.GetPushNotificationAsync("missing", "config-missing"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnNull_WhenConfigDoesNotExist() - { - // Arrange - var sut = new InMemoryTaskStore(); - - await sut.SetPushNotificationConfigAsync(new TaskPushNotificationConfig { TaskId = "task-id", PushNotificationConfig = new() { Id = "config-id" } }); - - // Act - var result = await sut.GetPushNotificationAsync("task-id", "config-missing"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnCorrectConfig_WhenMultipleConfigsExistForSameTask() - { - // Arrange - var sut = new InMemoryTaskStore(); - var taskId = "task-with-multiple-configs"; - - var config1 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config1", - Id = "config-id-1", - Token = "token1" - } - }; - - var config2 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config2", - Id = "config-id-2", - Token = "token2" - } - }; - - var config3 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config3", - Id = "config-id-3", - Token = "token3" - } - }; - - // Act - Store multiple configs for the same task - await sut.SetPushNotificationConfigAsync(config1); - await sut.SetPushNotificationConfigAsync(config2); - await sut.SetPushNotificationConfigAsync(config3); - - // Get specific configs by both taskId and notificationConfigId - var result1 = await sut.GetPushNotificationAsync(taskId, "config-id-1"); - var result2 = await sut.GetPushNotificationAsync(taskId, "config-id-2"); - var result3 = await sut.GetPushNotificationAsync(taskId, "config-id-3"); - var resultNotFound = await sut.GetPushNotificationAsync(taskId, "non-existent-config"); - - // Assert - Verify each call returns the correct specific config - Assert.NotNull(result1); - Assert.Equal(taskId, result1!.TaskId); - Assert.Equal("config-id-1", result1.PushNotificationConfig.Id); - Assert.Equal("http://config1", result1.PushNotificationConfig.Url); - Assert.Equal("token1", result1.PushNotificationConfig.Token); - - Assert.NotNull(result2); - Assert.Equal(taskId, result2!.TaskId); - Assert.Equal("config-id-2", result2.PushNotificationConfig.Id); - Assert.Equal("http://config2", result2.PushNotificationConfig.Url); - Assert.Equal("token2", result2.PushNotificationConfig.Token); - - Assert.NotNull(result3); - Assert.Equal(taskId, result3!.TaskId); - Assert.Equal("config-id-3", result3.PushNotificationConfig.Id); - Assert.Equal("http://config3", result3.PushNotificationConfig.Url); - Assert.Equal("token3", result3.PushNotificationConfig.Token); - - Assert.Null(resultNotFound); - } - - [Fact] - public async Task GetPushNotificationsAsync_ShouldReturnEmptyList_WhenNoConfigsExistForTask() - { - // Arrange - var sut = new InMemoryTaskStore(); - - // Act - var result = await sut.GetPushNotificationsAsync("task-without-configs"); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - } - - [Fact] - public async Task GetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetTaskAsync("test-id", cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetPushNotificationAsync("test-id", "config-id", cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.UpdateStatusAsync("test-id", TaskState.Working, cancellationToken: cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - var agentTask = new AgentTask { Id = "test-id", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.SetTaskAsync(agentTask, cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetPushNotificationConfigAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - var config = new TaskPushNotificationConfig(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.SetPushNotificationConfigAsync(config, cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task GetPushNotificationsAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = new InMemoryTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetPushNotificationsAsync("test-id", cts.Token); - - // Assert - Assert.True(task.IsCanceled); - await Assert.ThrowsAsync(() => task); - } -} \ No newline at end of file +namespace A2A.UnitTests.Server; + +public class InMemoryTaskStoreTests +{ + [Fact] + public async Task SaveAndGetTask_ShouldRoundTrip() + { + // Arrange + var sut = new InMemoryTaskStore(); + var task = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + + // Act + await sut.SaveTaskAsync("t1", task); + var result = await sut.GetTaskAsync("t1"); + + // Assert + Assert.NotNull(result); + Assert.Equal("t1", result!.Id); + Assert.Equal("ctx-1", result.ContextId); + Assert.Equal(TaskState.Submitted, result.Status.State); + } + + [Fact] + public async Task GetTaskAsync_ReturnsNull_WhenNotExists() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act + var result = await sut.GetTaskAsync("nonexistent"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task SaveTaskAsync_ShouldUpsert() + { + // Arrange + var sut = new InMemoryTaskStore(); + var task = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + await sut.SaveTaskAsync("t1", task); + + // Act — modify and save again + task.Status = new TaskStatus { State = TaskState.Working }; + await sut.SaveTaskAsync("t1", task); + + // Assert + var result = await sut.GetTaskAsync("t1"); + Assert.NotNull(result); + Assert.Equal(TaskState.Working, result!.Status.State); + } + + [Fact] + public async Task DeleteTaskAsync_ShouldRemoveTask() + { + // Arrange + var sut = new InMemoryTaskStore(); + var task = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + await sut.SaveTaskAsync("t1", task); + + // Act + await sut.DeleteTaskAsync("t1"); + + // Assert + var result = await sut.GetTaskAsync("t1"); + Assert.Null(result); + } + + [Fact] + public async Task DeleteTaskAsync_NoOp_WhenNotExists() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act & Assert — should not throw + await sut.DeleteTaskAsync("nonexistent"); + } + + [Fact] + public async Task ListTasksAsync_ShouldReturnAllTasks() + { + // Arrange + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Submitted } }); + await sut.SaveTaskAsync("t2", new AgentTask { Id = "t2", ContextId = "ctx-2", Status = new TaskStatus { State = TaskState.Working } }); + + // Act + var result = await sut.ListTasksAsync(new ListTasksRequest()); + + // Assert + Assert.NotNull(result.Tasks); + Assert.Equal(2, result.Tasks!.Count); + Assert.Equal(2, result.TotalSize); + } + + [Fact] + public async Task ListTasksAsync_ShouldFilterByContextId() + { + // Arrange + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Submitted } }); + await sut.SaveTaskAsync("t2", new AgentTask { Id = "t2", ContextId = "ctx-2", Status = new TaskStatus { State = TaskState.Working } }); + + // Act + var result = await sut.ListTasksAsync(new ListTasksRequest { ContextId = "ctx-1" }); + + // Assert + Assert.NotNull(result.Tasks); + Assert.Single(result.Tasks!); + Assert.Equal("t1", result.Tasks![0].Id); + } + + [Fact] + public async Task ListTasksAsync_ShouldFilterByStatus() + { + // Arrange + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Submitted } }); + await sut.SaveTaskAsync("t2", new AgentTask { Id = "t2", ContextId = "ctx-1", Status = new TaskStatus { State = TaskState.Completed } }); + + // Act + var result = await sut.ListTasksAsync(new ListTasksRequest { Status = TaskState.Completed }); + + // Assert + Assert.NotNull(result.Tasks); + Assert.Single(result.Tasks!); + Assert.Equal("t2", result.Tasks![0].Id); + } + + [Fact] + public async Task ListTasksAsync_ShouldNotMutateStoredData() + { + // Arrange — regression test: mutating a returned task must not affect stored state + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + History = [new Message { MessageId = "m1", Parts = [Part.FromText("original")] }], + }); + + // Act — get task, mutate it + var first = await sut.GetTaskAsync("t1"); + first!.History!.Add(new Message { MessageId = "m-injected", Parts = [Part.FromText("injected")] }); + first.Status = new TaskStatus { State = TaskState.Completed }; + + // Assert — re-read should return unmutated state + var second = await sut.GetTaskAsync("t1"); + Assert.NotNull(second); + Assert.Equal(TaskState.Working, second!.Status.State); + Assert.Single(second.History!); + Assert.Equal("m1", second.History![0].MessageId); + } + + [Fact] + public async Task ListTasksAsync_ShouldTrimHistoryByHistoryLength() + { + // Arrange + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + History = + [ + new Message { MessageId = "m1", Parts = [Part.FromText("first")] }, + new Message { MessageId = "m2", Parts = [Part.FromText("second")] }, + new Message { MessageId = "m3", Parts = [Part.FromText("third")] }, + ], + }); + + // Act — request historyLength = 1 (keep only last message) + var result = await sut.ListTasksAsync(new ListTasksRequest { HistoryLength = 1 }); + + // Assert + Assert.NotNull(result.Tasks); + Assert.Single(result.Tasks!); + var task = result.Tasks![0]; + Assert.NotNull(task.History); + Assert.Single(task.History!); + Assert.Equal("m3", task.History![0].MessageId); + } + + [Fact] + public async Task ListTasksAsync_HistoryLengthZero_ShouldRemoveHistory() + { + // Arrange + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + History = [new Message { MessageId = "m1", Parts = [Part.FromText("data")] }], + }); + + // Act + var result = await sut.ListTasksAsync(new ListTasksRequest { HistoryLength = 0 }); + + // Assert + var task = result.Tasks![0]; + Assert.Null(task.History); + } + + [Fact] + public async Task ListTasksAsync_ShouldPaginate() + { + var sut = new InMemoryTaskStore(); + // Save 5 tasks + for (int i = 1; i <= 5; i++) + await sut.SaveTaskAsync($"t{i}", new AgentTask { Id = $"t{i}", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Working } }); + + // Request page 1 (size 2) + var page1 = await sut.ListTasksAsync(new ListTasksRequest { PageSize = 2 }); + Assert.Equal(2, page1.Tasks!.Count); + Assert.Equal(5, page1.TotalSize); + Assert.False(string.IsNullOrEmpty(page1.NextPageToken)); + + // Request page 2 using NextPageToken + var page2 = await sut.ListTasksAsync(new ListTasksRequest { PageSize = 2, PageToken = page1.NextPageToken }); + Assert.Equal(2, page2.Tasks!.Count); + Assert.False(string.IsNullOrEmpty(page2.NextPageToken)); + + // Request page 3 (last page) + var page3 = await sut.ListTasksAsync(new ListTasksRequest { PageSize = 2, PageToken = page2.NextPageToken }); + Assert.Single(page3.Tasks!); + Assert.True(string.IsNullOrEmpty(page3.NextPageToken)); // no more pages + } + + [Fact] + public async Task ListTasksAsync_ShouldFilterByStatusTimestampAfter() + { + var sut = new InMemoryTaskStore(); + var now = DateTimeOffset.UtcNow; + await sut.SaveTaskAsync("t-old", new AgentTask { Id = "t-old", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Completed, Timestamp = now.AddHours(-2) } }); + await sut.SaveTaskAsync("t-new", new AgentTask { Id = "t-new", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Working, Timestamp = now } }); + + var result = await sut.ListTasksAsync(new ListTasksRequest { StatusTimestampAfter = now.AddHours(-1) }); + Assert.Single(result.Tasks!); + Assert.Equal("t-new", result.Tasks![0].Id); + } + + [Fact] + public async Task ListTasksAsync_ShouldExcludeArtifactsByDefault() + { + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask + { + Id = "t1", ContextId = "ctx", + Status = new TaskStatus { State = TaskState.Completed }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = [Part.FromText("data")] }] + }); + + // Default: artifacts excluded + var without = await sut.ListTasksAsync(new ListTasksRequest()); + Assert.Null(without.Tasks![0].Artifacts); + + // Explicitly include + var with = await sut.ListTasksAsync(new ListTasksRequest { IncludeArtifacts = true }); + Assert.NotNull(with.Tasks![0].Artifacts); + Assert.Single(with.Tasks![0].Artifacts!); + } + + [Fact] + public async Task ConcurrentSaveAndGet_ShouldNotCorrupt() + { + var sut = new InMemoryTaskStore(); + await sut.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = TaskState.Submitted } }); + + // Run 50 concurrent saves + gets + var tasks = Enumerable.Range(0, 50).Select(i => Task.Run(async () => + { + var state = i % 2 == 0 ? TaskState.Working : TaskState.Completed; + await sut.SaveTaskAsync("t1", new AgentTask { Id = "t1", ContextId = "ctx", Status = new TaskStatus { State = state } }); + var result = await sut.GetTaskAsync("t1"); + Assert.NotNull(result); + Assert.Equal("t1", result!.Id); + })); + + await Task.WhenAll(tasks); // Should not throw, corrupt, or deadlock + } +} diff --git a/tests/A2A.UnitTests/Server/MessageResponderTests.cs b/tests/A2A.UnitTests/Server/MessageResponderTests.cs new file mode 100644 index 00000000..5ac04832 --- /dev/null +++ b/tests/A2A.UnitTests/Server/MessageResponderTests.cs @@ -0,0 +1,70 @@ +namespace A2A.UnitTests.Server; + +public class MessageResponderTests +{ + [Fact] + public async Task ReplyAsync_Text_SetsRoleAndContextId() + { + var queue = new AgentEventQueue(); + var responder = new MessageResponder(queue, "ctx-1"); + + await responder.ReplyAsync("hello"); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + var msg = events[0].Message; + Assert.NotNull(msg); + Assert.Equal(Role.Agent, msg!.Role); + Assert.False(string.IsNullOrEmpty(msg.MessageId)); + Assert.Equal("ctx-1", msg.ContextId); + Assert.Equal("hello", msg.Parts![0].Text); + } + + [Fact] + public async Task ReplyAsync_Parts_SetsRoleAndContextId() + { + var queue = new AgentEventQueue(); + var responder = new MessageResponder(queue, "ctx-2"); + var parts = new List { Part.FromText("a"), Part.FromText("b") }; + + await responder.ReplyAsync(parts); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + var msg = events[0].Message; + Assert.NotNull(msg); + Assert.Equal(Role.Agent, msg!.Role); + Assert.Equal("ctx-2", msg.ContextId); + Assert.Equal(2, msg.Parts!.Count); + Assert.Equal("a", msg.Parts[0].Text); + Assert.Equal("b", msg.Parts[1].Text); + } + + [Fact] + public async Task ReplyAsync_GeneratesUniqueMessageIds() + { + var queue = new AgentEventQueue(); + var responder = new MessageResponder(queue, "ctx-3"); + + await responder.ReplyAsync("first"); + await responder.ReplyAsync("second"); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Equal(2, events.Count); + Assert.NotEqual(events[0].Message!.MessageId, events[1].Message!.MessageId); + } + + private static async Task> CollectEventsAsync(AgentEventQueue queue) + { + List events = []; + await foreach (var e in queue) + { + events.Add(e); + } + + return events; + } +} diff --git a/tests/A2A.UnitTests/Server/TaskProjectionTests.cs b/tests/A2A.UnitTests/Server/TaskProjectionTests.cs new file mode 100644 index 00000000..19b00ba7 --- /dev/null +++ b/tests/A2A.UnitTests/Server/TaskProjectionTests.cs @@ -0,0 +1,245 @@ +namespace A2A.UnitTests.Server; + +public class TaskProjectionTests +{ + [Fact] + public void Apply_WithTaskEvent_ReturnsTask() + { + // Arrange + var task = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + var evt = new StreamResponse { Task = task }; + + // Act + var result = TaskProjection.Apply(null, evt); + + // Assert + Assert.NotNull(result); + Assert.Equal("t1", result!.Id); + Assert.Equal(TaskState.Submitted, result.Status.State); + } + + [Fact] + public void Apply_WithStatusUpdate_UpdatesStatus() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Submitted }, + }; + var evt = new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.Equal(TaskState.Working, result!.Status.State); + } + + [Fact] + public void Apply_WithStatusUpdate_MovesSupersededMessageToHistory() + { + // Arrange + var statusMessage = new Message + { + MessageId = "sm1", + Role = Role.Agent, + Parts = [Part.FromText("working on it")], + }; + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working, Message = statusMessage }, + }; + var evt = new StreamResponse + { + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Completed }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.Equal(TaskState.Completed, result!.Status.State); + Assert.NotNull(result.History); + Assert.Single(result.History); + Assert.Equal("sm1", result.History[0].MessageId); + } + + [Fact] + public void Apply_WithArtifactUpdate_AddsNewArtifact() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + }; + var evt = new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Append = false, + Artifact = new Artifact { ArtifactId = "a1", Parts = [Part.FromText("data")] }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result!.Artifacts); + Assert.Single(result.Artifacts); + Assert.Equal("a1", result.Artifacts[0].ArtifactId); + } + + [Fact] + public void Apply_WithArtifactUpdate_ReplacesExistingArtifact() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = [Part.FromText("old")] }], + }; + var evt = new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Append = false, + Artifact = new Artifact { ArtifactId = "a1", Parts = [Part.FromText("new")] }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.Single(result!.Artifacts!); + Assert.Equal("new", result.Artifacts![0].Parts[0].Text); + } + + [Fact] + public void Apply_WithArtifactUpdate_AppendExtendsParts() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = [Part.FromText("chunk1")] }], + }; + var evt = new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Append = true, + Artifact = new Artifact { ArtifactId = "a1", Parts = [Part.FromText("chunk2")] }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.Single(result!.Artifacts!); + Assert.Equal(2, result.Artifacts![0].Parts.Count); + Assert.Equal("chunk1", result.Artifacts[0].Parts[0].Text); + Assert.Equal("chunk2", result.Artifacts[0].Parts[1].Text); + } + + [Fact] + public void Apply_WithArtifactUpdate_AppendAddsNonexistent() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = [Part.FromText("data")] }], + }; + var evt = new StreamResponse + { + ArtifactUpdate = new TaskArtifactUpdateEvent + { + TaskId = "t1", + ContextId = "ctx-1", + Append = true, + Artifact = new Artifact { ArtifactId = "a-new", Parts = [Part.FromText("extra")] }, + } + }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert — non-existent artifact added when append=true + Assert.NotNull(result); + Assert.Equal(2, result!.Artifacts!.Count); + Assert.Equal("a1", result.Artifacts[0].ArtifactId); + Assert.Equal("a-new", result.Artifacts[1].ArtifactId); + } + + [Fact] + public void Apply_WithMessage_AppendsToHistory() + { + // Arrange + var current = new AgentTask + { + Id = "t1", + ContextId = "ctx-1", + Status = new TaskStatus { State = TaskState.Working }, + }; + var msg = new Message + { + MessageId = "m1", + Role = Role.Agent, + Parts = [Part.FromText("hello")], + }; + var evt = new StreamResponse { Message = msg }; + + // Act + var result = TaskProjection.Apply(current, evt); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result!.History); + Assert.Single(result.History); + Assert.Equal("m1", result.History[0].MessageId); + } +} diff --git a/tests/A2A.UnitTests/Server/TaskUpdaterTests.cs b/tests/A2A.UnitTests/Server/TaskUpdaterTests.cs new file mode 100644 index 00000000..574fd642 --- /dev/null +++ b/tests/A2A.UnitTests/Server/TaskUpdaterTests.cs @@ -0,0 +1,151 @@ +namespace A2A.UnitTests.Server; + +public class TaskUpdaterTests +{ + [Fact] + public async Task GivenTaskUpdater_WhenSubmitAsync_ThenEnqueuesTaskWithSubmittedState() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.SubmitAsync(); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.NotNull(events[0].Task); + Assert.Equal("t1", events[0].Task!.Id); + Assert.Equal("ctx-1", events[0].Task!.ContextId); + Assert.Equal(TaskState.Submitted, events[0].Task!.Status.State); + } + + [Fact] + public async Task GivenTaskUpdater_WhenStartWorkAsync_ThenEnqueuesWorkingStatusUpdate() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.StartWorkAsync(); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.NotNull(events[0].StatusUpdate); + Assert.Equal(TaskState.Working, events[0].StatusUpdate!.Status.State); + } + + [Fact] + public async Task GivenTaskUpdater_WhenAddArtifactAsync_ThenEnqueuesArtifactWithGeneratedId() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.AddArtifactAsync([Part.FromText("hello")], name: "output"); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.NotNull(events[0].ArtifactUpdate); + Assert.False(string.IsNullOrEmpty(events[0].ArtifactUpdate!.Artifact.ArtifactId)); + Assert.Equal("output", events[0].ArtifactUpdate!.Artifact.Name); + Assert.Equal("hello", events[0].ArtifactUpdate!.Artifact.Parts[0].Text); + } + + [Fact] + public async Task GivenTaskUpdater_WhenAddArtifactWithExplicitId_ThenUsesProvidedId() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.AddArtifactAsync([Part.FromText("data")], artifactId: "custom-id"); + + queue.Complete(); + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.Equal("custom-id", events[0].ArtifactUpdate!.Artifact.ArtifactId); + } + + [Fact] + public async Task GivenTaskUpdater_WhenCompleteAsync_ThenEnqueuesCompletedAndCompletesQueue() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.CompleteAsync(); + + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.Equal(TaskState.Completed, events[0].StatusUpdate!.Status.State); + } + + [Fact] + public async Task GivenTaskUpdater_WhenFailAsync_ThenEnqueuesFailedAndCompletesQueue() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.FailAsync(); + + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.Equal(TaskState.Failed, events[0].StatusUpdate!.Status.State); + } + + [Fact] + public async Task GivenTaskUpdater_WhenCancelAsync_ThenEnqueuesCanceledAndCompletesQueue() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.CancelAsync(); + + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.Equal(TaskState.Canceled, events[0].StatusUpdate!.Status.State); + } + + [Fact] + public async Task GivenTaskUpdater_WhenRequireInputAsync_ThenEnqueuesInputRequiredAndCompletesQueue() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + var message = new Message { Role = Role.Agent, MessageId = "m1", Parts = [Part.FromText("need input")] }; + + await updater.RequireInputAsync(message); + + var events = await CollectEventsAsync(queue); + Assert.Single(events); + Assert.Equal(TaskState.InputRequired, events[0].StatusUpdate!.Status.State); + Assert.Equal("need input", events[0].StatusUpdate!.Status.Message!.Parts[0].Text); + } + + [Fact] + public async Task GivenTaskUpdater_WhenFullLifecycle_ThenProducesCorrectEventSequence() + { + var queue = new AgentEventQueue(); + var updater = new TaskUpdater(queue, "t1", "ctx-1"); + + await updater.SubmitAsync(); + await updater.StartWorkAsync(); + await updater.AddArtifactAsync([Part.FromText("result")]); + await updater.CompleteAsync(); + + var events = await CollectEventsAsync(queue); + Assert.Equal(4, events.Count); + Assert.NotNull(events[0].Task); // Submit + Assert.Equal(TaskState.Working, events[1].StatusUpdate!.Status.State); + Assert.NotNull(events[2].ArtifactUpdate); // Artifact + Assert.Equal(TaskState.Completed, events[3].StatusUpdate!.Status.State); + } + + private static async Task> CollectEventsAsync(AgentEventQueue queue) + { + List events = []; + await foreach (var e in queue) + { + events.Add(e); + } + + return events; + } +} diff --git a/tests/A2A.V0_3.UnitTests/A2A.V0_3.UnitTests.csproj b/tests/A2A.V0_3.UnitTests/A2A.V0_3.UnitTests.csproj new file mode 100644 index 00000000..ed439377 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/A2A.V0_3.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net10.0;net8.0 + enable + enable + + false + true + $(NoWarn);IDE1006 + A2A.V0_3.UnitTests + + + + + + + + + + + + + + + + + + diff --git a/tests/A2A.V0_3.UnitTests/Client/A2ACardResolverTests.cs b/tests/A2A.V0_3.UnitTests/Client/A2ACardResolverTests.cs new file mode 100644 index 00000000..0f767adb --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Client/A2ACardResolverTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Client; + +public class A2ACardResolverTests +{ + [Fact] + public async Task GetAgentCardAsync_ReturnsAgentCard() + { + // Arrange + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent", + Url = "http://localhost", + Version = "1.0.0", + Capabilities = new AgentCapabilities { Streaming = true, PushNotifications = false, StateTransitionHistory = true }, + Skills = [new AgentSkill { Id = "test", Name = "Test Skill", Description = "desc", Tags = [] }] + }; + var json = JsonSerializer.Serialize(agentCard); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + var handler = new MockHttpMessageHandler(response); + using var httpClient = new HttpClient(handler); + var resolver = new A2ACardResolver(new Uri("http://localhost"), httpClient); + + // Act + var result = await resolver.GetAgentCardAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(agentCard.Name, result.Name); + Assert.Equal(agentCard.Description, result.Description); + Assert.Equal(agentCard.Url, result.Url); + Assert.Equal(agentCard.Version, result.Version); + Assert.Equal(agentCard.Capabilities.Streaming, result.Capabilities.Streaming); + Assert.Single(result.Skills); + } + + [Fact] + public async Task GetAgentCardAsync_ThrowsA2AExceptionOnInvalidJson() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not-json", Encoding.UTF8, "application/json") + }; + var handler = new MockHttpMessageHandler(response); + using var httpClient = new HttpClient(handler); + var resolver = new A2ACardResolver(new Uri("http://localhost"), httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => resolver.GetAgentCardAsync()); + } + + [Fact] + public async Task GetAgentCardAsync_ThrowsA2AExceptionOnHttpError() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError); + var handler = new MockHttpMessageHandler(response); + using var httpClient = new HttpClient(handler); + var resolver = new A2ACardResolver(new Uri("http://localhost"), httpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => resolver.GetAgentCardAsync()); + Assert.NotNull(exception.InnerException); + Assert.IsType(exception.InnerException); + } +} diff --git a/tests/A2A.V0_3.UnitTests/Client/A2AClientTests.cs b/tests/A2A.V0_3.UnitTests/Client/A2AClientTests.cs new file mode 100644 index 00000000..4e01bcd5 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Client/A2AClientTests.cs @@ -0,0 +1,649 @@ +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Client; + +public class A2AClientTests +{ + [Fact] + public async Task SendMessageAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req); + + var sendParams = new MessageSendParams + { + Message = new AgentMessage + { + Parts = [new TextPart { Text = "Hello" }], + Role = MessageRole.User, + MessageId = "msg-1", + TaskId = "task-1", + ContextId = "ctx-1", + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } }, + ReferenceTaskIds = ["ref-1"] + }, + Configuration = new MessageSendConfiguration + { + AcceptedOutputModes = ["mode1"], + PushNotification = new PushNotificationConfig { Url = "http://push" }, + HistoryLength = 5, + Blocking = true + }, + Metadata = new Dictionary { { "baz", JsonDocument.Parse("\"qux\"").RootElement } } + }; + + // Act + await sut.SendMessageAsync(sendParams); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("message/send", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + + Assert.Equal(sendParams.Message.Parts.Count, parameters.Message.Parts.Count); + Assert.Equal(((TextPart)sendParams.Message.Parts[0]).Text, ((TextPart)parameters.Message.Parts[0]).Text); + Assert.Equal(sendParams.Message.Role, parameters.Message.Role); + Assert.Equal(sendParams.Message.MessageId, parameters.Message.MessageId); + Assert.Equal(sendParams.Message.TaskId, parameters.Message.TaskId); + Assert.Equal(sendParams.Message.ContextId, parameters.Message.ContextId); + Assert.Equal(sendParams.Message.Metadata["foo"].GetString(), parameters.Message.Metadata!["foo"].GetString()); + Assert.Equal(sendParams.Message.ReferenceTaskIds[0], parameters.Message.ReferenceTaskIds![0]); + + Assert.NotNull(parameters.Configuration); + Assert.Equal(sendParams.Configuration.AcceptedOutputModes[0], parameters.Configuration.AcceptedOutputModes![0]); + Assert.Equal(sendParams.Configuration.PushNotification.Url, parameters.Configuration.PushNotification!.Url); + Assert.Equal(sendParams.Configuration.HistoryLength, parameters.Configuration.HistoryLength); + Assert.Equal(sendParams.Configuration.Blocking, parameters.Configuration.Blocking); + + Assert.Equal(sendParams.Metadata["baz"].GetString(), parameters.Metadata!["baz"].GetString()); + } + + [Fact] + public async Task SendMessageAsync_MapsResponseCorrectly() + { + // Arrange + var expectedMessage = new AgentMessage + { + Role = MessageRole.Agent, + Parts = + [ + new TextPart { Text = "Test text" }, + new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, + ], + Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, + ReferenceTaskIds = ["ref1", "ref2"], + MessageId = "msg-123", + TaskId = "task-456", + ContextId = "ctx-789" + }; + + var sut = CreateA2AClient(expectedMessage); + + var sendParams = new MessageSendParams(); + + // Act + var result = await sut.SendMessageAsync(sendParams) as AgentMessage; + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedMessage.Role, result.Role); + Assert.Equal(expectedMessage.Parts.Count, result.Parts.Count); + Assert.IsType(result.Parts[0]); + Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)result.Parts[0]).Text); + Assert.IsType(result.Parts[1]); + Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)result.Parts[1]).Data["key"].GetString()); + Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), result.Metadata!["metaKey"].GetString()); + Assert.Equal(expectedMessage.ReferenceTaskIds, result.ReferenceTaskIds); + Assert.Equal(expectedMessage.MessageId, result.MessageId); + Assert.Equal(expectedMessage.TaskId, result.TaskId); + Assert.Equal(expectedMessage.ContextId, result.ContextId); + } + + [Fact] + public async Task GetTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new AgentTask { Id = "id-1", ContextId = "ctx-1" }, req => capturedRequest = req); + + var taskId = "task-1"; + + // Act + await sut.GetTaskAsync(taskId); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("tasks/get", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(taskId, parameters.Id); + } + + [Fact] + public async Task GetTaskAsync_MapsResponseCorrectly() + { + // Arrange + var expectedTask = new AgentTask + { + Id = "task-1", + ContextId = "ctx-ctx", + Status = new AgentTaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = { new TextPart { Text = "part" } } }], + History = [new AgentMessage { MessageId = "m1" }], + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } } + }; + + var sut = CreateA2AClient(expectedTask); + + // Act + var result = await sut.GetTaskAsync("task-1"); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedTask.Id, result.Id); + Assert.Equal(expectedTask.ContextId, result.ContextId); + Assert.Equal(expectedTask.Status.State, result.Status.State); + Assert.Equal(expectedTask.Artifacts![0].ArtifactId, result.Artifacts![0].ArtifactId); + Assert.Equal(((TextPart)expectedTask.Artifacts![0].Parts[0]).Text, ((TextPart)result.Artifacts![0].Parts[0]).Text); + Assert.Equal(expectedTask.History![0].MessageId, result.History![0].MessageId); + Assert.Equal(expectedTask.Metadata!["foo"].GetString(), result.Metadata!["foo"].GetString()); + } + + [Fact] + public async Task CancelTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new AgentTask { Id = "task-2" }, req => capturedRequest = req); + + var taskIdParams = new TaskIdParams + { + Id = "task-2", + Metadata = new Dictionary { { "meta", JsonDocument.Parse("\"val\"").RootElement } } + }; + + // Act + await sut.CancelTaskAsync(taskIdParams); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("tasks/cancel", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(taskIdParams.Id, parameters.Id); + Assert.Equal(taskIdParams.Metadata!["meta"].GetString(), parameters.Metadata!["meta"].GetString()); + } + + [Fact] + public async Task CancelTaskAsync_MapsResponseCorrectly() + { + // Arrange + var expectedTask = new AgentTask + { + Id = "task-1", + ContextId = "ctx-ctx", + Status = new AgentTaskStatus { State = TaskState.Working }, + Artifacts = [new Artifact { ArtifactId = "a1", Parts = { new TextPart { Text = "part" } } }], + History = [new AgentMessage { MessageId = "m1" }], + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } } + }; + + var sut = CreateA2AClient(expectedTask); + + var taskIdParams = new TaskIdParams { Id = "task-2" }; + + // Act + var result = await sut.CancelTaskAsync(taskIdParams); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedTask.Id, result.Id); + Assert.Equal(expectedTask.ContextId, result.ContextId); + Assert.Equal(expectedTask.Status.State, result.Status.State); + Assert.Equal(expectedTask.Artifacts![0].ArtifactId, result.Artifacts![0].ArtifactId); + Assert.Equal(((TextPart)expectedTask.Artifacts![0].Parts[0]).Text, ((TextPart)result.Artifacts![0].Parts[0]).Text); + Assert.Equal(expectedTask.History![0].MessageId, result.History![0].MessageId); + Assert.Equal(expectedTask.Metadata!["foo"].GetString(), result.Metadata!["foo"].GetString()); + } + + [Fact] + public async Task SetPushNotificationAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new TaskPushNotificationConfig() { TaskId = "id-1", PushNotificationConfig = new PushNotificationConfig() { Url = "url-1" } }, req => capturedRequest = req); + + var pushConfig = new TaskPushNotificationConfig + { + TaskId = "task-3", + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://push-url", + Id = "push-config-123", + Token = "tok", + Authentication = new PushNotificationAuthenticationInfo + { + Schemes = ["Bearer"], + } + } + }; + + // Act + await sut.SetPushNotificationAsync(pushConfig); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("tasks/pushNotificationConfig/set", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(pushConfig.TaskId, parameters.TaskId); + Assert.Equal(pushConfig.PushNotificationConfig.Url, parameters.PushNotificationConfig.Url); + Assert.Equal(pushConfig.PushNotificationConfig.Id, parameters.PushNotificationConfig.Id); + Assert.Equal(pushConfig.PushNotificationConfig.Token, parameters.PushNotificationConfig.Token); + Assert.Equal(pushConfig.PushNotificationConfig.Authentication!.Schemes, parameters.PushNotificationConfig.Authentication!.Schemes); + } + + [Fact] + public async Task SetPushNotificationAsync_MapsResponseCorrectly() + { + // Arrange + var expectedConfig = new TaskPushNotificationConfig + { + TaskId = "task-3", + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://push-url", + Id = "push-config-456", + Token = "tok", + Authentication = new PushNotificationAuthenticationInfo + { + Schemes = ["Bearer"], + } + } + }; + + var sut = CreateA2AClient(expectedConfig); + + // Act + var result = await sut.SetPushNotificationAsync(expectedConfig); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedConfig.TaskId, result.TaskId); + Assert.Equal(expectedConfig.PushNotificationConfig.Url, result.PushNotificationConfig.Url); + Assert.Equal(expectedConfig.PushNotificationConfig.Token, result.PushNotificationConfig.Token); + Assert.Equal(expectedConfig.PushNotificationConfig.Authentication!.Schemes, result.PushNotificationConfig.Authentication!.Schemes); + } + + [Fact] + public async Task GetPushNotificationAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var config = new TaskPushNotificationConfig { TaskId = "task-4", PushNotificationConfig = new PushNotificationConfig { Url = "url-1" } }; + + var sut = CreateA2AClient(config, req => capturedRequest = req); + + var notificationConfigParams = new GetTaskPushNotificationConfigParams + { + Id = "task-4", + Metadata = new Dictionary { { "meta", JsonDocument.Parse("\"val\"").RootElement } }, + PushNotificationConfigId = "config-123" + }; + + // Act + await sut.GetPushNotificationAsync(notificationConfigParams); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("tasks/pushNotificationConfig/get", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(notificationConfigParams.Id, parameters.Id); + Assert.Equal(notificationConfigParams.Metadata!["meta"].GetString(), parameters.Metadata!["meta"].GetString()); + Assert.Equal(notificationConfigParams.PushNotificationConfigId, parameters.PushNotificationConfigId); + } + + [Fact] + public async Task GetPushNotificationAsync_MapsResponseCorrectly() + { + // Arrange + var expectedConfig = new TaskPushNotificationConfig + { + TaskId = "task-4", + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://push-url2", + Token = "tok2", + Authentication = new PushNotificationAuthenticationInfo + { + Schemes = ["Bearer"] + } + } + }; + + var sut = CreateA2AClient(expectedConfig); + + var notificationConfigParams = new GetTaskPushNotificationConfigParams { Id = "task-4" }; + + // Act + var result = await sut.GetPushNotificationAsync(notificationConfigParams); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedConfig.TaskId, result.TaskId); + Assert.Equal(expectedConfig.PushNotificationConfig.Url, result.PushNotificationConfig.Url); + Assert.Equal(expectedConfig.PushNotificationConfig.Token, result.PushNotificationConfig.Token); + Assert.Equal(expectedConfig.PushNotificationConfig.Authentication!.Schemes, result.PushNotificationConfig.Authentication!.Schemes); + } + + [Fact] + public async Task GetPushNotificationAsync_WithPushNotificationConfigId_MapsRequestCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var config = new TaskPushNotificationConfig { TaskId = "task-5", PushNotificationConfig = new PushNotificationConfig { Url = "url-1" } }; + + var sut = CreateA2AClient(config, req => capturedRequest = req); + + var notificationConfigParams = new GetTaskPushNotificationConfigParams + { + Id = "task-5", + PushNotificationConfigId = "specific-config-id" + }; + + // Act + await sut.GetPushNotificationAsync(notificationConfigParams); + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(notificationConfigParams.Id, parameters.Id); + Assert.Equal(notificationConfigParams.PushNotificationConfigId, parameters.PushNotificationConfigId); + Assert.Null(parameters.Metadata); + } + + [Fact] + public async Task SendMessageStreamingAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req, isSse: true); + + var sendParams = new MessageSendParams + { + Message = new AgentMessage + { + Parts = [new TextPart { Text = "Hello" }], + Role = MessageRole.User, + MessageId = "msg-1", + TaskId = "task-1", + ContextId = "ctx-1", + Metadata = new Dictionary { { "foo", JsonDocument.Parse("\"bar\"").RootElement } }, + ReferenceTaskIds = ["ref-1"] + }, + Configuration = new MessageSendConfiguration + { + AcceptedOutputModes = ["mode1"], + PushNotification = new PushNotificationConfig { Url = "http://push" }, + HistoryLength = 5, + Blocking = true + }, + Metadata = new Dictionary { { "baz", JsonDocument.Parse("\"qux\"").RootElement } } + }; + + // Act + await foreach (var _ in sut.SendMessageStreamingAsync(sendParams)) + { + break; // Only need to trigger the request + } + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("message/stream", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(sendParams.Message.Parts.Count, parameters.Message.Parts.Count); + Assert.Equal(((TextPart)sendParams.Message.Parts[0]).Text, ((TextPart)parameters.Message.Parts[0]).Text); + Assert.Equal(sendParams.Message.Role, parameters.Message.Role); + Assert.Equal(sendParams.Message.MessageId, parameters.Message.MessageId); + Assert.Equal(sendParams.Message.TaskId, parameters.Message.TaskId); + Assert.Equal(sendParams.Message.ContextId, parameters.Message.ContextId); + Assert.Equal(sendParams.Message.Metadata["foo"].GetString(), parameters.Message.Metadata!["foo"].GetString()); + Assert.Equal(sendParams.Message.ReferenceTaskIds[0], parameters.Message.ReferenceTaskIds![0]); + Assert.NotNull(parameters.Configuration); + Assert.Equal(sendParams.Configuration.AcceptedOutputModes[0], parameters.Configuration.AcceptedOutputModes![0]); + Assert.Equal(sendParams.Configuration.PushNotification.Url, parameters.Configuration.PushNotification!.Url); + Assert.Equal(sendParams.Configuration.HistoryLength, parameters.Configuration.HistoryLength); + Assert.Equal(sendParams.Configuration.Blocking, parameters.Configuration.Blocking); + Assert.Equal(sendParams.Metadata["baz"].GetString(), parameters.Metadata!["baz"].GetString()); + } + + [Fact] + public async Task SendMessageStreamingAsync_MapsResponseCorrectly() + { + // Arrange + var expectedMessage = new AgentMessage + { + Role = MessageRole.Agent, + Parts = + [ + new TextPart { Text = "Test text" }, + new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, + ], + Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, + ReferenceTaskIds = ["ref1", "ref2"], + MessageId = "msg-123", + TaskId = "task-456", + ContextId = "ctx-789" + }; + + var sut = CreateA2AClient(expectedMessage, isSse: true); + + var sendParams = new MessageSendParams(); + + // Act + SseItem? result = null; + await foreach (var item in sut.SendMessageStreamingAsync(sendParams)) + { + result = item; + break; + } + + // Assert + Assert.NotNull(result); + var message = Assert.IsType(result.Value.Data); + Assert.Equal(expectedMessage.Role, message.Role); + Assert.Equal(expectedMessage.Parts.Count, message.Parts.Count); + Assert.IsType(message.Parts[0]); + Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)message.Parts[0]).Text); + Assert.IsType(message.Parts[1]); + Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)message.Parts[1]).Data["key"].GetString()); + Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), message.Metadata!["metaKey"].GetString()); + Assert.Equal(expectedMessage.ReferenceTaskIds, message.ReferenceTaskIds); + Assert.Equal(expectedMessage.MessageId, message.MessageId); + Assert.Equal(expectedMessage.TaskId, message.TaskId); + Assert.Equal(expectedMessage.ContextId, message.ContextId); + } + + [Fact] + public async Task SubscribeToTaskAsync_MapsRequestParamsCorrectly() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var sut = CreateA2AClient(new AgentMessage() { MessageId = "id-1", Role = MessageRole.User, Parts = [] }, req => capturedRequest = req, isSse: true); + + var taskId = "task-123"; + + // Act + await foreach (var _ in sut.SubscribeToTaskAsync(taskId)) + { + break; // Only need to trigger the request + } + + // Assert + Assert.NotNull(capturedRequest); + + var requestJson = JsonDocument.Parse(await capturedRequest.Content!.ReadAsStringAsync()); + Assert.Equal("tasks/resubscribe", requestJson.RootElement.GetProperty("method").GetString()); + Assert.True(Guid.TryParse(requestJson.RootElement.GetProperty("id").GetString(), out _)); + + var parameters = requestJson.RootElement.GetProperty("params").Deserialize(); + Assert.NotNull(parameters); + Assert.Equal(taskId, parameters.Id); + } + + [Fact] + public async Task SubscribeToTaskAsync_MapsResponseCorrectly() + { + // Arrange + var expectedMessage = new AgentMessage + { + Role = MessageRole.Agent, + Parts = + [ + new TextPart { Text = "Test text" }, + new DataPart { Data = new Dictionary { { "key", JsonDocument.Parse("\"value\"").RootElement } } }, + ], + Metadata = new Dictionary { { "metaKey", JsonDocument.Parse("\"metaValue\"").RootElement } }, + ReferenceTaskIds = ["ref1", "ref2"], + MessageId = "msg-123", + TaskId = "task-456", + ContextId = "ctx-789" + }; + + var sut = CreateA2AClient(expectedMessage, isSse: true); + + // Act + SseItem? result = null; + await foreach (var item in sut.SubscribeToTaskAsync("task-123")) + { + result = item; + break; + } + + // Assert + Assert.NotNull(result); + var message = Assert.IsType(result.Value.Data); + Assert.Equal(expectedMessage.Role, message.Role); + Assert.Equal(expectedMessage.Parts.Count, message.Parts.Count); + Assert.IsType(message.Parts[0]); + Assert.Equal(((TextPart)expectedMessage.Parts[0]).Text, ((TextPart)message.Parts[0]).Text); + Assert.IsType(message.Parts[1]); + Assert.Equal(((DataPart)expectedMessage.Parts[1]).Data["key"].GetString(), ((DataPart)message.Parts[1]).Data["key"].GetString()); + Assert.Equal(expectedMessage.Metadata["metaKey"].GetString(), message.Metadata!["metaKey"].GetString()); + Assert.Equal(expectedMessage.ReferenceTaskIds, message.ReferenceTaskIds); + Assert.Equal(expectedMessage.MessageId, message.MessageId); + Assert.Equal(expectedMessage.TaskId, message.TaskId); + Assert.Equal(expectedMessage.ContextId, message.ContextId); + } + + [Fact] + public async Task SendMessageStreamingAsync_ThrowsOnJsonRpcError() + { + // Arrange + var sut = CreateA2AClient(JsonRpcResponse.InvalidParamsResponse("test-id"), isSse: true); + + var sendParams = new MessageSendParams(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in sut.SendMessageStreamingAsync(sendParams)) + { + // Should throw before yielding any items + } + }); + + Assert.Equal(A2AErrorCode.InvalidParams, exception.ErrorCode); + Assert.Contains("Invalid parameters", exception.Message); + } + + [Fact] + public async Task SendMessageAsync_ThrowsOnJsonRpcError() + { + // Arrange + var sut = CreateA2AClient(JsonRpcResponse.MethodNotFoundResponse("test-id")); + + var sendParams = new MessageSendParams(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + { + await sut.SendMessageAsync(sendParams); + }); + + Assert.Equal(A2AErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains("Method not found", exception.Message); + } + + private static A2AClient CreateA2AClient(object result, Action? onRequest = null, bool isSse = false) + { + var response = new JsonRpcResponse + { + Id = "test-id", + Result = JsonSerializer.SerializeToNode(result) + }; + + return CreateA2AClient(response, onRequest, isSse); + } + + private static A2AClient CreateA2AClient(JsonRpcResponse jsonResponse, Action? onRequest = null, bool isSse = false) + { + var responseContent = JsonSerializer.Serialize(jsonResponse); + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + isSse ? $"event: message\ndata: {responseContent}\n\n" : responseContent, + Encoding.UTF8, + isSse ? "text/event-stream" : "application/json") + }; + + var handler = new MockHttpMessageHandler(response, onRequest); + + var httpClient = new HttpClient(handler); + + return new A2AClient(new Uri("http://localhost"), httpClient); + } +} diff --git a/tests/A2A.V0_3.UnitTests/Client/JsonRpcContentTests.cs b/tests/A2A.V0_3.UnitTests/Client/JsonRpcContentTests.cs new file mode 100644 index 00000000..21af3b84 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Client/JsonRpcContentTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Client +{ + public class JsonRpcContentTests + { + [Fact] + public async Task Constructor_SetsContentType_AndSerializesRequest() + { + // Arrange + using var data = JsonDocument.Parse("{\"foo\":\"bar\"}"); + + var request = new JsonRpcRequest + { + Id = "req-1", + Method = "testMethod", + Params = data.RootElement + }; + + var ms = new MemoryStream(); + + var sut = new JsonRpcContent(request); + + // Act + await sut.CopyToAsync(ms); + + ms.Position = 0; + using var doc = await JsonDocument.ParseAsync(ms); + + // Assert + Assert.Equal("application/json", sut.Headers.ContentType!.MediaType); + Assert.Equal("2.0", doc.RootElement.GetProperty("jsonrpc").GetString()); + Assert.Equal("req-1", doc.RootElement.GetProperty("id").GetString()); + Assert.Equal("testMethod", doc.RootElement.GetProperty("method").GetString()); + Assert.Equal("bar", doc.RootElement.GetProperty("params").GetProperty("foo").GetString()); + } + + [Fact] + public async Task Constructor_SetsContentType_AndSerializesResponse() + { + // Arrange + var response = JsonRpcResponse.CreateJsonRpcResponse("resp-1", new AgentTask + { + Id = "task-1", + ContextId = "ctx-1", + Status = new AgentTaskStatus { State = TaskState.Completed } + }); + var sut = new JsonRpcContent(response); + + // Act + var ms = new MemoryStream(); + await sut.CopyToAsync(ms); + ms.Position = 0; + using var doc = await JsonDocument.ParseAsync(ms); + + // Assert + Assert.Equal("application/json", sut.Headers.ContentType!.MediaType); + Assert.Equal("2.0", doc.RootElement.GetProperty("jsonrpc").GetString()); + Assert.Equal("resp-1", doc.RootElement.GetProperty("id").GetString()); + var result = doc.RootElement.GetProperty("result"); + Assert.Equal("task-1", result.GetProperty("id").GetString()); + Assert.Equal("ctx-1", result.GetProperty("contextId").GetString()); + Assert.Equal("completed", result.GetProperty("status").GetProperty("state").GetString()); + } + + [Fact] + public void ContentLength_IsNull() + { + // Arrange + var request = new JsonRpcRequest { Id = "id", Method = "m" }; + var sut = new JsonRpcContent(request); + + // Act + var length = sut.Headers.ContentLength; + + // Assert + Assert.Null(length); + } + + [Fact] + public async Task SerializeToStreamAsync_WritesToStream() + { + // Arrange + var request = new JsonRpcRequest { Id = "id", Method = "m" }; + var sut = new JsonRpcContent(request); + using var ms = new MemoryStream(); + + // Act + await sut.CopyToAsync(ms); + + // Assert + Assert.True(ms.Length > 0); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/GitHubIssues/Issue160.cs b/tests/A2A.V0_3.UnitTests/GitHubIssues/Issue160.cs new file mode 100644 index 00000000..4bbc4685 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/GitHubIssues/Issue160.cs @@ -0,0 +1,114 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.GitHubIssues +{ + public sealed class Issue160 + { + [Fact] + public void Issue_160_Passes() + { + var json = """ + { + "id": "aaa2d907-e493-483c-a569-6aa38c6951d4", + "jsonrpc": "2.0", + "result": { + "artifacts": [ + { + "artifactId": "artifact-1", + "description": null, + "extensions": null, + "metadata": null, + "name": "artifact-1", + "parts": [ + { + "kind": "text", + "metadata": null, + "text": "Artifact update from the Movie Agent" + } + ] + } + ], + "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", + "history": [ + { + "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", + "extensions": null, + "kind": "message", + "messageId": "From Dotnet", + "metadata": null, + "parts": [ + { + "kind": "text", + "metadata": null, + "text": "jimmy" + } + ], + "referenceTaskIds": null, + "role": "user", + "taskId": null + }, + { + "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", + "extensions": null, + "kind": "message", + "messageId": "488f7027-6805-4d1c-bafa-1bf55d438eb3", + "metadata": null, + "parts": [ + { + "kind": "text", + "metadata": null, + "text": "Generating code..." + } + ], + "referenceTaskIds": null, + "role": "agent", + "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" + }, + { + "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", + "extensions": null, + "kind": "message", + "messageId": "31e24763-63ae-4509-9ff2-11d789640ae4", + "metadata": null, + "parts": [], + "referenceTaskIds": null, + "role": "agent", + "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" + } + ], + "id": "6b349583-196e-444c-a0bd-a4f22f0753f0", + "kind": "task", + "metadata": null, + "status": { + "message": { + "contextId": "32fef1d4-a1e5-4cb2-83cb-177808deac39", + "extensions": null, + "kind": "message", + "messageId": "31e24763-63ae-4509-9ff2-11d789640ae4", + "metadata": null, + "parts": [], + "referenceTaskIds": null, + "role": "agent", + "taskId": "6b349583-196e-444c-a0bd-a4f22f0753f0" + }, + "state": "completed", + "timestamp": "2025-08-25T09:58:01.545" + } + } + } + """; + + var deserializedResponseObj = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedResponseObj); + + var task = deserializedResponseObj.Result.Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(task); + + Assert.Equal("6b349583-196e-444c-a0bd-a4f22f0753f0", task.Id); + Assert.Equal("32fef1d4-a1e5-4cb2-83cb-177808deac39", task.Status.Message?.ContextId); + + Assert.Equal(1, task.Artifacts?.Count); + Assert.Equal(3, task.History?.Count); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/A2AMethodsTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/A2AMethodsTests.cs new file mode 100644 index 00000000..a3220393 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/A2AMethodsTests.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class A2AMethodsTests +{ + [Fact] + public void IsStreamingMethod_ReturnsTrue_ForMessageStream() + { + // Arrange + var method = A2AMethods.MessageStream; + + // Act + var result = A2AMethods.IsStreamingMethod(method); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsStreamingMethod_ReturnsTrue_ForTaskSubscribe() + { + // Arrange + var method = A2AMethods.TaskSubscribe; + + // Act + var result = A2AMethods.IsStreamingMethod(method); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(A2AMethods.MessageSend)] + [InlineData(A2AMethods.TaskGet)] + [InlineData(A2AMethods.TaskCancel)] + [InlineData(A2AMethods.TaskPushNotificationConfigSet)] + [InlineData(A2AMethods.TaskPushNotificationConfigGet)] + [InlineData("unknown/method")] + public void IsStreamingMethod_ReturnsFalse_ForNonStreamingMethods(string method) + { + // Act + var result = A2AMethods.IsStreamingMethod(method); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("unknown/method")] + [InlineData("message/ssend")] + [InlineData("invalid")] + [InlineData("")] + public void IsValidMethod_ReturnsFalse_ForInvalidMethods(string method) + { + // Act + var result = A2AMethods.IsValidMethod(method); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsValidMethod_ReturnsFalse_ForNullMethod() + { + // Act + var result = A2AMethods.IsValidMethod(null!); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsValidMethod_ReturnsTrue_ForAllDefinedMethods() + { + // Arrange: Use reflection to get all const string fields from A2AMethods + var methodFields = typeof(A2AMethods) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(field => field.IsLiteral && field.FieldType == typeof(string)) + .ToList(); + + // Assert we found some methods (sanity check) + Assert.NotEmpty(methodFields); + + // Act & Assert: Each method constant should be valid + foreach (var field in methodFields) + { + var methodValue = (string)field.GetValue(null)!; + var isValid = A2AMethods.IsValidMethod(methodValue); + + Assert.True(isValid, $"Method '{methodValue}' (from field '{field.Name}') should be valid but IsValidMethod returned false. " + + "This likely means the method constant was added to A2AMethods but not included in the IsValidMethod implementation."); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/ErrorTypesTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/ErrorTypesTests.cs new file mode 100644 index 00000000..db648578 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/ErrorTypesTests.cs @@ -0,0 +1,112 @@ +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class ErrorTypesTests +{ + [Fact] + public void InternalError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.InternalErrorResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32603, sut.Error?.Code); + Assert.Equal("Internal error", sut.Error?.Message); + } + + [Fact] + public void TaskNotFoundError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.TaskNotFoundResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32001, sut.Error?.Code); + Assert.Equal("Task not found", sut.Error?.Message); + } + + [Fact] + public void TaskNotCancelableError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.TaskNotCancelableResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32002, sut.Error?.Code); + Assert.Equal("Task cannot be canceled", sut.Error?.Message); + } + + [Fact] + public void PushNotificationNotSupportedError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.PushNotificationNotSupportedResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32003, sut.Error?.Code); + Assert.Equal("Push notification not supported", sut.Error?.Message); + } + + [Fact] + public void UnsupportedOperationError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.UnsupportedOperationResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32004, sut.Error?.Code); + Assert.Equal("Unsupported operation", sut.Error?.Message); + } + + [Fact] + public void ContentTypeNotSupportedError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.ContentTypeNotSupportedResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32005, sut.Error?.Code); + Assert.Equal("Content type not supported", sut.Error?.Message); + } + + [Fact] + public void MethodNotFoundError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.MethodNotFoundResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32601, sut.Error?.Code); + Assert.Equal("Method not found", sut.Error?.Message); + } + + [Fact] + public void ParseError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.ParseErrorResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32700, sut.Error?.Code); + Assert.Equal("Invalid JSON payload", sut.Error?.Message); + } + + [Fact] + public void InvalidParamsError_HasExpectedCodeAndMessage() + { + // Act + var sut = JsonRpcResponse.InvalidParamsResponse("123"); + + // Assert + Assert.Equal("123", sut.Id); + Assert.Equal(-32602, sut.Error?.Code); + Assert.Equal("Invalid parameters", sut.Error?.Message); + } +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorResponseTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorResponseTests.cs new file mode 100644 index 00000000..abb3d919 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorResponseTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; + +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class JsonRpcErrorResponseTests +{ + [Fact] + public void JsonRpcErrorResponse_Properties_SetAndGet() + { + // Arrange + var error = new JsonRpcError { Code = 123, Message = "err" }; + + // Act + var sut = new JsonRpcResponse + { + Id = "id1", + JsonRpc = "2.0", + Error = error + }; + + // Assert + Assert.Equal("id1", sut.Id); + Assert.Equal("2.0", sut.JsonRpc); + Assert.Equal(error, sut.Error); + } + + [Fact] + public void JsonRpcErrorResponse_CanSetResult() + { + // Arrange + var node = JsonValue.Create(42); + + // Act + var sut = new JsonRpcResponse { Result = node }; + + // Assert + Assert.Equal(42, sut.Result!.GetValue()); + } + + [Fact] + public void CreateJsonRpcErrorResponse_WithValidException_CreatesCorrectResponse() + { + // Arrange + const string requestId = "test-request-123"; + const string errorMessage = "Test error message"; + const A2AErrorCode errorCode = A2AErrorCode.InvalidParams; + var exception = new A2AException(errorMessage, errorCode); + + // Act + var response = JsonRpcResponse.CreateJsonRpcErrorResponse(requestId, exception); + + // Assert + Assert.Equal(requestId, response.Id); + Assert.Equal("2.0", response.JsonRpc); + Assert.Null(response.Result); + Assert.NotNull(response.Error); + Assert.Equal((int)errorCode, response.Error.Code); + Assert.Equal(errorMessage, response.Error.Message); + } + + [Fact] + public void CreateJsonRpcErrorResponse_WithNullRequestId_CreatesCorrectResponse() + { + // Arrange + const string errorMessage = "Test error message"; + const A2AErrorCode errorCode = A2AErrorCode.MethodNotFound; + var exception = new A2AException(errorMessage, errorCode); + + // Act + var response = JsonRpcResponse.CreateJsonRpcErrorResponse(new JsonRpcId((string?)null), exception); + + // Assert + Assert.False(response.Id.HasValue); + Assert.Equal("2.0", response.JsonRpc); + Assert.Null(response.Result); + Assert.NotNull(response.Error); + Assert.Equal((int)errorCode, response.Error.Code); + Assert.Equal(errorMessage, response.Error.Message); + } +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorTests.cs new file mode 100644 index 00000000..bb648d22 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcErrorTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class JsonRpcErrorTests +{ + [Fact] + public void JsonRpcError_Properties_SetAndGet() + { + // Arrange + using var data = JsonDocument.Parse("{\"foo\":123}"); + + // Act + var sut = new JsonRpcError { Code = 42, Message = "msg", Data = data.RootElement }; + + // Assert + Assert.Equal(42, sut.Code); + Assert.Equal("msg", sut.Message); + Assert.Equal(123, sut.Data?.GetProperty("foo").GetInt32()); + } + + [Fact] + public void JsonRpcError_SerializesAndDeserializesCorrectly() + { + // Arrange + using var data = JsonDocument.Parse("{\"bar\":true}"); + + var sut = new JsonRpcError { Code = 1, Message = "m", Data = data.RootElement }; + + // Act + var json = sut.ToJson(); + var doc = JsonDocument.Parse(json); + var deserialized = JsonRpcError.FromJson(doc.RootElement); + + // Assert + Assert.Equal(sut.Code, deserialized.Code); + Assert.Equal(sut.Message, deserialized.Message); + Assert.Equal(sut.Data?.GetProperty("bar").GetBoolean(), deserialized.Data?.GetProperty("bar").GetBoolean()); + } +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs new file mode 100644 index 00000000..b29fb084 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/JsonRpcRequestConverterTests.cs @@ -0,0 +1,637 @@ +using A2A.V0_3; +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class JsonRpcRequestConverterTests +{ + private readonly JsonSerializerOptions _options; + + public JsonRpcRequestConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new JsonRpcRequestConverter()); + } + + #region Successful Deserialization Tests + + [Fact] + public void Read_ValidJsonRpcRequest_WithAllFields_ReturnsRequest() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [] + } + } + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.Equal("2.0", result.JsonRpc); + Assert.True(result.Id.IsString); + Assert.Equal("test-id", result.Id.AsString()); + Assert.Equal("message/send", result.Method); + Assert.True(result.Params.HasValue); + Assert.True(result.Params.Value.TryGetProperty("message", out _)); + } + + [Fact] + public void Read_ValidJsonRpcRequest_WithoutParams_ReturnsRequest() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "tasks/get" + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.Equal("2.0", result.JsonRpc); + Assert.True(result.Id.IsString); + Assert.Equal("test-id", result.Id.AsString()); + Assert.Equal("tasks/get", result.Method); + Assert.False(result.Params.HasValue); + } + + [Fact] + public void Read_ValidJsonRpcRequest_WithoutId_ReturnsRequest() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": {} + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.Equal("2.0", result.JsonRpc); + Assert.False(result.Id.HasValue); + Assert.Equal("message/send", result.Method); + Assert.True(result.Params.HasValue); + } + + [Theory] + [InlineData("\"string-id\"", "string-id", true, false)] + [InlineData("123", "123", false, true)] + [InlineData("null", null, false, false)] + public void Read_ValidIdTypes_ReturnsCorrectId(string idJson, string? expectedStringValue, bool shouldBeString, bool shouldBeNumber) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": {{idJson}}, + "method": "tasks/get" + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + + if (expectedStringValue == null) + { + Assert.False(result.Id.HasValue); + } + else if (shouldBeString) + { + Assert.True(result.Id.IsString); + Assert.Equal(expectedStringValue, result.Id.AsString()); + } + else if (shouldBeNumber) + { + Assert.True(result.Id.IsNumber); + Assert.Equal(123L, result.Id.AsNumber()); + } + } + + [Theory] + [InlineData("message/send")] + [InlineData("message/stream")] + [InlineData("tasks/get")] + [InlineData("tasks/cancel")] + [InlineData("tasks/resubscribe")] + [InlineData("tasks/pushNotificationConfig/set")] + [InlineData("tasks/pushNotificationConfig/get")] + public void Read_ValidMethods_ReturnsCorrectMethod(string method) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "{{method}}" + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.Equal(method, result.Method); + } + + #endregion + + #region Validation Error Tests + + [Fact] + public void Read_MissingJsonRpcField_ThrowsA2AException() + { + // Arrange + var json = """ + { + "id": "test-id", + "method": "tasks/get" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains("missing 'jsonrpc' field", exception.Message); + } + + [Theory] + [InlineData("\"1.0\"")] + [InlineData("\"3.0\"")] + [InlineData("\"invalid\"")] + [InlineData("null")] + public void Read_InvalidJsonRpcVersion_ThrowsA2AException(string versionJson) + { + // Arrange + var json = $$""" + { + "jsonrpc": {{versionJson}}, + "id": "test-id", + "method": "tasks/get" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains("'jsonrpc' field must be '2.0'", exception.Message); + } + + [Fact] + public void Read_MissingMethodField_ThrowsA2AException() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "id": "test-id" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains("missing 'method' field", exception.Message); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("null")] + public void Read_EmptyOrNullMethod_ThrowsA2AException(string methodJson) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": "test-id", + "method": {{methodJson}} + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains("missing 'method' field", exception.Message); + } + + [Theory] + [InlineData("\"invalid/method\"")] + [InlineData("\"unknown\"")] + [InlineData("\"message/invalid\"")] + public void Read_InvalidMethod_ThrowsA2AException(string methodJson) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": "test-id", + "method": {{methodJson}} + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains("not a valid A2A method", exception.Message); + } + + [Theory] + [InlineData("true")] + [InlineData("[]")] + [InlineData("42.1")] + public void Read_InvalidIdType_ThrowsA2AException(string idJson) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": {{idJson}}, + "method": "tasks/get" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidRequest, exception.ErrorCode); + Assert.Contains("'id' field must be a string, non-fractional number, or null", exception.Message); + } + + [Theory] + [InlineData("[]")] + [InlineData("\"string\"")] + [InlineData("123")] + [InlineData("true")] + public void Read_InvalidParamsType_ThrowsA2AException(string paramsJson) + { + // Arrange + var json = $$""" + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "tasks/get", + "params": {{paramsJson}} + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal(A2AErrorCode.InvalidParams, exception.ErrorCode); + Assert.Contains("'params' field must be an object", exception.Message); + } + + #endregion + + #region Error Context Tests + + [Fact] + public void Read_ErrorWithRequestId_IncludesRequestIdInException() + { + // Arrange + var json = """ + { + "jsonrpc": "1.0", + "id": "error-test-id", + "method": "tasks/get" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Equal("error-test-id", exception.GetRequestId()); + } + + [Fact] + public void Read_ErrorWithoutRequestId_HasNullRequestId() + { + // Arrange + var json = """ + { + "jsonrpc": "1.0", + "method": "tasks/get" + } + """; + + // Act & Assert + var exception = Assert.Throws(() => + JsonSerializer.Deserialize(json, _options)); + + Assert.Null(exception.GetRequestId()); + } + + #endregion + + #region Serialization Tests + + [Fact] + public void Write_ValidJsonRpcRequest_WithAllFields_WritesCorrectJson() + { + // Arrange + using var paramsDoc = JsonDocument.Parse("""{"key": "value"}"""); + var request = new JsonRpcRequest + { + JsonRpc = "2.0", + Id = "test-id", + Method = "message/send", + Params = paramsDoc.RootElement + }; + + // Act + var json = JsonSerializer.Serialize(request, _options); + + // Assert + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal("test-id", root.GetProperty("id").GetString()); + Assert.Equal("message/send", root.GetProperty("method").GetString()); + Assert.Equal("value", root.GetProperty("params").GetProperty("key").GetString()); + } + + [Fact] + public void Write_ValidJsonRpcRequest_WithoutParams_WritesCorrectJson() + { + // Arrange + var request = new JsonRpcRequest + { + JsonRpc = "2.0", + Id = "test-id", + Method = "tasks/get", + Params = null + }; + + // Act + var json = JsonSerializer.Serialize(request, _options); + + // Assert + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal("test-id", root.GetProperty("id").GetString()); + Assert.Equal("tasks/get", root.GetProperty("method").GetString()); + Assert.False(root.TryGetProperty("params", out _)); + } + + [Fact] + public void Write_ValidJsonRpcRequest_WithNullId_WritesCorrectJson() + { + // Arrange + var request = new JsonRpcRequest + { + JsonRpc = "2.0", + Id = new JsonRpcId((string?)null), + Method = "tasks/get", + Params = null + }; + + // Act + var json = JsonSerializer.Serialize(request, _options); + + // Assert + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("id").ValueKind); + Assert.Equal("tasks/get", root.GetProperty("method").GetString()); + } + + #endregion + + #region Round-trip Tests + + [Fact] + public void RoundTrip_ValidJsonRpcRequest_PreservesAllData() + { + // Arrange + using var paramsDoc = JsonDocument.Parse(""" + { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [] + } + } + """); + + var original = new JsonRpcRequest + { + JsonRpc = "2.0", + Id = "round-trip-test", + Method = "message/send", + Params = paramsDoc.RootElement + }; + + // Act + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.JsonRpc, deserialized.JsonRpc); + Assert.Equal(original.Id, deserialized.Id); + Assert.Equal(original.Method, deserialized.Method); + Assert.True(deserialized.Params.HasValue); + Assert.Equal("msg-1", deserialized.Params.Value.GetProperty("message").GetProperty("messageId").GetString()); + } + + [Theory] + [InlineData("message/send")] + [InlineData("message/stream")] + [InlineData("tasks/get")] + [InlineData("tasks/cancel")] + [InlineData("tasks/resubscribe")] + [InlineData("tasks/pushNotificationConfig/set")] + [InlineData("tasks/pushNotificationConfig/get")] + public void RoundTrip_AllValidMethods_PreservesMethod(string method) + { + // Arrange + var original = new JsonRpcRequest + { + JsonRpc = "2.0", + Id = "method-test", + Method = method, + Params = null + }; + + // Act + var json = JsonSerializer.Serialize(original, _options); + var deserialized = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(method, deserialized.Method); + } + + #endregion + + #region Edge Case Tests + + [Fact] + public void Read_ValidParamsNull_ReturnsRequestWithoutParams() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "tasks/get", + "params": null + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.False(result.Params.HasValue); + } + + [Fact] + public void Read_EmptyParamsObject_ReturnsRequestWithEmptyParams() + { + // Arrange + var json = """ + { + "jsonrpc": "2.0", + "id": "test-id", + "method": "tasks/get", + "params": {} + } + """; + + // Act + var result = JsonSerializer.Deserialize(json, _options); + + // Assert + Assert.NotNull(result); + Assert.True(result.Params.HasValue); + Assert.Equal(JsonValueKind.Object, result.Params.Value.ValueKind); + } + + #endregion + + #region ID Type Preservation Tests + + [Fact] + public void RoundTrip_NumericId_PreservesNumericType() + { + // Arrange + var originalJson = """ + { + "jsonrpc": "2.0", + "id": 123, + "method": "tasks/get" + } + """; + + // Act - deserialize and serialize back + var request = JsonSerializer.Deserialize(originalJson, _options); + var serializedJson = JsonSerializer.Serialize(request, _options); + + // Assert - check the request + Assert.NotNull(request); + Assert.True(request.Id.IsNumber); + Assert.Equal(123L, request.Id.AsNumber()); + Assert.False(request.Id.IsString); + + // Assert - check the serialized JSON maintains numeric type + using var doc = JsonDocument.Parse(serializedJson); + var idElement = doc.RootElement.GetProperty("id"); + Assert.Equal(JsonValueKind.Number, idElement.ValueKind); + Assert.Equal(123, idElement.GetInt32()); + + // Act - test response creation maintains type + var response = JsonRpcResponse.CreateJsonRpcResponse(request.Id, "test result"); + var responseJson = JsonSerializer.Serialize(response, A2AJsonUtilities.DefaultOptions); + + // Assert - response maintains numeric type + using var responseDoc = JsonDocument.Parse(responseJson); + var responseIdElement = responseDoc.RootElement.GetProperty("id"); + Assert.Equal(JsonValueKind.Number, responseIdElement.ValueKind); + Assert.Equal(123, responseIdElement.GetInt32()); + } + + [Fact] + public void RoundTrip_StringId_PreservesStringType() + { + // Arrange + var originalJson = """ + { + "jsonrpc": "2.0", + "id": "test-string-id", + "method": "tasks/get" + } + """; + + // Act - deserialize and serialize back + var request = JsonSerializer.Deserialize(originalJson, _options); + var serializedJson = JsonSerializer.Serialize(request, _options); + + // Assert - check the request + Assert.NotNull(request); + Assert.True(request.Id.IsString); + Assert.Equal("test-string-id", request.Id.AsString()); + Assert.False(request.Id.IsNumber); + + // Assert - check the serialized JSON maintains string type + using var doc = JsonDocument.Parse(serializedJson); + var idElement = doc.RootElement.GetProperty("id"); + Assert.Equal(JsonValueKind.String, idElement.ValueKind); + Assert.Equal("test-string-id", idElement.GetString()); + + // Act - test response creation maintains type + var response = JsonRpcResponse.CreateJsonRpcResponse(request.Id, "test result"); + var responseJson = JsonSerializer.Serialize(response, A2AJsonUtilities.DefaultOptions); + + // Assert - response maintains string type + using var responseDoc = JsonDocument.Parse(responseJson); + var responseIdElement = responseDoc.RootElement.GetProperty("id"); + Assert.Equal(JsonValueKind.String, responseIdElement.ValueKind); + Assert.Equal("test-string-id", responseIdElement.GetString()); + } + + #endregion +} diff --git a/tests/A2A.V0_3.UnitTests/JsonRpc/MethodNotFoundErrorTests.cs b/tests/A2A.V0_3.UnitTests/JsonRpc/MethodNotFoundErrorTests.cs new file mode 100644 index 00000000..498f10df --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/JsonRpc/MethodNotFoundErrorTests.cs @@ -0,0 +1,15 @@ +namespace A2A.V0_3.UnitTests.JsonRpc; + +public class MethodNotFoundErrorTests +{ + [Fact] + public void MethodNotFoundError_HasExpectedCodeAndMessage() + { + // Act + var sut = new MethodNotFoundError(); + + // Assert + Assert.Equal(-32601, sut.Code); + Assert.Equal("Method not found", sut.Message); + } +} diff --git a/tests/A2A.V0_3.UnitTests/MockHttpMessageHandler.cs b/tests/A2A.V0_3.UnitTests/MockHttpMessageHandler.cs new file mode 100644 index 00000000..4eaad6dd --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/MockHttpMessageHandler.cs @@ -0,0 +1,18 @@ +namespace A2A.V0_3.UnitTests; + +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpResponseMessage _response; + private readonly Action? _capture; + + public MockHttpMessageHandler(HttpResponseMessage response, Action? capture = null) + { + _response = response; + _capture = capture; + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _capture?.Invoke(request); + return Task.FromResult(_response); + } +} diff --git a/tests/A2A.UnitTests/Models/A2AEventTests.cs b/tests/A2A.V0_3.UnitTests/Models/A2AEventTests.cs similarity index 97% rename from tests/A2A.UnitTests/Models/A2AEventTests.cs rename to tests/A2A.V0_3.UnitTests/Models/A2AEventTests.cs index b035b0af..68cb8902 100644 --- a/tests/A2A.UnitTests/Models/A2AEventTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/A2AEventTests.cs @@ -1,292 +1,292 @@ -using System.Text.Json; -using Xunit.Abstractions; - -namespace A2A.UnitTests.Models -{ - public sealed class A2AEventTests(ITestOutputHelper testOutput) - { - private static readonly Dictionary expectedMetadata = new() - { - ["createdAt"] = "2023-01-01T00:00:00Z" - }; - - [Fact] - public void A2AEvent_Deserialize_Message_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "message", - "role": "user", - "messageId": "m-1", - "taskId": "t-1", - "contextId": "c-1", - "referenceTaskIds": [ "r-1", "r-2" ], - "parts": [ { "kind": "text", "text": "hi" } ], - "extensions": [ "foo", "bar" ], - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - var expectedReferenceTaskIds = new[] { "r-1", "r-2" }; - var expectedParts = new[] { new TextPart() { Text = "hi" } }; - var expectedExtensions = new[] { "foo", "bar" }; - - // Act - var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var message = Assert.IsType(a2aEvent); - - // Assert - Assert.Equal(MessageRole.User, message.Role); - Assert.Equal("m-1", message.MessageId); - Assert.Equal("t-1", message.TaskId); - Assert.Equal("c-1", message.ContextId); - Assert.Equal(expectedReferenceTaskIds, message.ReferenceTaskIds); - Assert.Single(message.Parts); - Assert.IsType(message.Parts[0]); - Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); - Assert.Equal(expectedExtensions, message.Extensions); - Assert.NotNull(message.Metadata); - Assert.Single(message.Metadata); - Assert.Equal(expectedMetadata["createdAt"], message.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AEvent_Deserialize_AgentTask_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "task", - "id": "t-3", - "contextId": "c-3", - "status": { "state": "submitted" }, - "artifacts": [ - { "artifactId": "f-1", "name": "file1.txt", "description": "A text file", "parts": [] } - ], - "history": [ - { "kind": "message", "role": "user", "messageId": "m-3", "parts": [] } - ], - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - var expectedArtifacts = new[] - { - new Artifact - { - ArtifactId = "f-1", - Name = "file1.txt", - Description = "A text file", - } - }; - var expectedHistory = new[] - { - new AgentMessage - { - Role = MessageRole.User, - MessageId = "m-3", - } - }; - - // Act - var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var agentTask = Assert.IsType(a2aEvent); - - // Assert - Assert.Equal("t-3", agentTask.Id); - Assert.Equal("c-3", agentTask.ContextId); - Assert.Equal(TaskState.Submitted, agentTask.Status.State); - Assert.NotNull(agentTask.Artifacts); - Assert.Single(agentTask.Artifacts); - Assert.Equal(expectedArtifacts[0].ArtifactId, agentTask.Artifacts[0].ArtifactId); - Assert.Equal(expectedArtifacts[0].Name, agentTask.Artifacts[0].Name); - Assert.Equal(expectedArtifacts[0].Description, agentTask.Artifacts[0].Description); - Assert.NotNull(agentTask.History); - Assert.Single(agentTask.History); - Assert.Equal(expectedHistory[0].Role, agentTask.History![0].Role); - Assert.Equal(expectedHistory[0].MessageId, agentTask.History![0].MessageId); - Assert.NotNull(agentTask.Metadata); - Assert.Single(agentTask.Metadata); - Assert.Equal(expectedMetadata["createdAt"], agentTask.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AEvent_Deserialize_TaskStatusUpdateEvent_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "status-update", - "taskId": "t-5", - "contextId": "c-5", - "status": { "state": "working" }, - "final": false, - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - - // Act - var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var taskStatusUpdateEvent = Assert.IsType(a2aEvent); - - // Assert - Assert.Equal("t-5", taskStatusUpdateEvent.TaskId); - Assert.Equal("c-5", taskStatusUpdateEvent.ContextId); - Assert.Equal(TaskState.Working, taskStatusUpdateEvent.Status.State); - Assert.False(taskStatusUpdateEvent.Final); - Assert.NotNull(taskStatusUpdateEvent.Metadata); - Assert.Single(taskStatusUpdateEvent.Metadata); - Assert.Equal(expectedMetadata["createdAt"], taskStatusUpdateEvent.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AEvent_Deserialize_TaskArtifactUpdateEvent_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "artifact-update", - "taskId": "t-7", - "contextId": "c-7", - "artifact": { - "artifactId": "a-1", - "parts": [ { "kind": "text", "text": "chunk" } ] - }, - "append": true, - "lastChunk": false, - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - var expectedArtifact = new Artifact - { - ArtifactId = "a-1", - Parts = [new TextPart { Text = "chunk" }] - }; - - // Act - var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var taskArtifactUpdateEvent = Assert.IsType(a2aEvent); - - // Assert - Assert.Equal("t-7", taskArtifactUpdateEvent.TaskId); - Assert.Equal("c-7", taskArtifactUpdateEvent.ContextId); - Assert.Equal(expectedArtifact.ArtifactId, taskArtifactUpdateEvent.Artifact.ArtifactId); - Assert.Single(taskArtifactUpdateEvent.Artifact.Parts); - Assert.IsType(taskArtifactUpdateEvent.Artifact.Parts[0]); - Assert.Equal((expectedArtifact.Parts[0] as TextPart)!.Text, (taskArtifactUpdateEvent.Artifact.Parts[0] as TextPart)!.Text); - Assert.True(taskArtifactUpdateEvent.Append); - Assert.False(taskArtifactUpdateEvent.LastChunk); - Assert.NotNull(taskArtifactUpdateEvent.Metadata); - Assert.Single(taskArtifactUpdateEvent.Metadata); - Assert.Equal(expectedMetadata["createdAt"], taskArtifactUpdateEvent.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AEvent_Deserialize_UnknownKind_Throws_A2AException() - { - // Arrange - const string json = """ - { - "kind": "lorem", - "foo": "bar" - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AEvent_Deserialize_MissingKind_Throws() - { - // Arrange - const string json = """ - { - "role": "user", - "messageId": "m-5", - "parts": [ { "kind": "text", "text": "hi" } ] - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AEvent_Deserialize_KindNotBeingFirst_Succeeds() - { - // Arrange - const string json = """ - { - "role": "user", - "kind": "message", - "parts": [ { "kind": "text", "text": "hi" } ], - "messageId": "m-7" - } - """; - var expectedParts = new[] { new TextPart() { Text = "hi" } }; - - // Act - var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var message = Assert.IsType(a2aEvent); - - // Assert - Assert.Equal(MessageRole.User, message.Role); - Assert.Equal("m-7", message.MessageId); - Assert.Single(message.Parts); - Assert.IsType(message.Parts[0]); - Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); - } - - [Fact] - public void A2AEvent_Serialize_AllKnownType_Succeeds() - { - // Arrange - var a2aEvents = new A2AEvent[] { - new AgentMessage { Role = MessageRole.User, MessageId = "m-7", Parts = [new TextPart { Text = "hello" }] }, - new AgentTask { Id = "t-9", ContextId = "c-9", Status = new AgentTaskStatus { State = TaskState.Submitted, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } }, - new TaskStatusUpdateEvent { TaskId = "t-10", ContextId = "c-10", Status = new AgentTaskStatus { State = TaskState.Working, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } }, - new TaskArtifactUpdateEvent { TaskId = "t-11", ContextId = "c-11" } - }; - var serializedA2aEvents = new string[] { - "{\"kind\":\"message\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}],\"messageId\":\"m-7\"}", - "{\"kind\":\"task\",\"id\":\"t-9\",\"contextId\":\"c-9\",\"status\":{\"state\":\"submitted\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"history\":[]}", - "{\"kind\":\"status-update\",\"status\":{\"state\":\"working\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"final\":false,\"taskId\":\"t-10\",\"contextId\":\"c-10\"}", - "{\"kind\":\"artifact-update\",\"artifact\":{\"artifactId\":\"\",\"parts\":[]},\"taskId\":\"t-11\",\"contextId\":\"c-11\"}" - }; - - for (var i = 0; i < a2aEvents.Length; i++) - { - // Act - var json = JsonSerializer.Serialize(a2aEvents[i], A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.Equal(serializedA2aEvents[i], json); - } - } - - [Theory] - [InlineData("{ \"kind\": 1 }")] - [InlineData("{ \"kind\": null }")] - [InlineData("{ \"kind\": \"unknown\" }")] - [InlineData("{ \"kind\": \"count\" }")] - [InlineData("{ \"kind\": \"neveravaluethatsgoingtooccurinthewild\" }")] - [InlineData("{ \"kind\": \"\" }")] - public void A2AEvent_Deserialize_BadValue_Throws(string json) - { - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - testOutput.WriteLine($"Exception: {ex}"); - - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - } -} +using System.Text.Json; +using Xunit.Abstractions; + +namespace A2A.V0_3.UnitTests.Models +{ + public sealed class A2AEventTests(ITestOutputHelper testOutput) + { + private static readonly Dictionary expectedMetadata = new() + { + ["createdAt"] = "2023-01-01T00:00:00Z" + }; + + [Fact] + public void A2AEvent_Deserialize_Message_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "message", + "role": "user", + "messageId": "m-1", + "taskId": "t-1", + "contextId": "c-1", + "referenceTaskIds": [ "r-1", "r-2" ], + "parts": [ { "kind": "text", "text": "hi" } ], + "extensions": [ "foo", "bar" ], + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + var expectedReferenceTaskIds = new[] { "r-1", "r-2" }; + var expectedParts = new[] { new TextPart() { Text = "hi" } }; + var expectedExtensions = new[] { "foo", "bar" }; + + // Act + var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var message = Assert.IsType(a2aEvent); + + // Assert + Assert.Equal(MessageRole.User, message.Role); + Assert.Equal("m-1", message.MessageId); + Assert.Equal("t-1", message.TaskId); + Assert.Equal("c-1", message.ContextId); + Assert.Equal(expectedReferenceTaskIds, message.ReferenceTaskIds); + Assert.Single(message.Parts); + Assert.IsType(message.Parts[0]); + Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); + Assert.Equal(expectedExtensions, message.Extensions); + Assert.NotNull(message.Metadata); + Assert.Single(message.Metadata); + Assert.Equal(expectedMetadata["createdAt"], message.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AEvent_Deserialize_AgentTask_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "task", + "id": "t-3", + "contextId": "c-3", + "status": { "state": "submitted" }, + "artifacts": [ + { "artifactId": "f-1", "name": "file1.txt", "description": "A text file", "parts": [] } + ], + "history": [ + { "kind": "message", "role": "user", "messageId": "m-3", "parts": [] } + ], + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + var expectedArtifacts = new[] + { + new Artifact + { + ArtifactId = "f-1", + Name = "file1.txt", + Description = "A text file", + } + }; + var expectedHistory = new[] + { + new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-3", + } + }; + + // Act + var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var agentTask = Assert.IsType(a2aEvent); + + // Assert + Assert.Equal("t-3", agentTask.Id); + Assert.Equal("c-3", agentTask.ContextId); + Assert.Equal(TaskState.Submitted, agentTask.Status.State); + Assert.NotNull(agentTask.Artifacts); + Assert.Single(agentTask.Artifacts); + Assert.Equal(expectedArtifacts[0].ArtifactId, agentTask.Artifacts[0].ArtifactId); + Assert.Equal(expectedArtifacts[0].Name, agentTask.Artifacts[0].Name); + Assert.Equal(expectedArtifacts[0].Description, agentTask.Artifacts[0].Description); + Assert.NotNull(agentTask.History); + Assert.Single(agentTask.History); + Assert.Equal(expectedHistory[0].Role, agentTask.History![0].Role); + Assert.Equal(expectedHistory[0].MessageId, agentTask.History![0].MessageId); + Assert.NotNull(agentTask.Metadata); + Assert.Single(agentTask.Metadata); + Assert.Equal(expectedMetadata["createdAt"], agentTask.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AEvent_Deserialize_TaskStatusUpdateEvent_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "status-update", + "taskId": "t-5", + "contextId": "c-5", + "status": { "state": "working" }, + "final": false, + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + + // Act + var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var taskStatusUpdateEvent = Assert.IsType(a2aEvent); + + // Assert + Assert.Equal("t-5", taskStatusUpdateEvent.TaskId); + Assert.Equal("c-5", taskStatusUpdateEvent.ContextId); + Assert.Equal(TaskState.Working, taskStatusUpdateEvent.Status.State); + Assert.False(taskStatusUpdateEvent.Final); + Assert.NotNull(taskStatusUpdateEvent.Metadata); + Assert.Single(taskStatusUpdateEvent.Metadata); + Assert.Equal(expectedMetadata["createdAt"], taskStatusUpdateEvent.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AEvent_Deserialize_TaskArtifactUpdateEvent_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "artifact-update", + "taskId": "t-7", + "contextId": "c-7", + "artifact": { + "artifactId": "a-1", + "parts": [ { "kind": "text", "text": "chunk" } ] + }, + "append": true, + "lastChunk": false, + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + var expectedArtifact = new Artifact + { + ArtifactId = "a-1", + Parts = [new TextPart { Text = "chunk" }] + }; + + // Act + var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var taskArtifactUpdateEvent = Assert.IsType(a2aEvent); + + // Assert + Assert.Equal("t-7", taskArtifactUpdateEvent.TaskId); + Assert.Equal("c-7", taskArtifactUpdateEvent.ContextId); + Assert.Equal(expectedArtifact.ArtifactId, taskArtifactUpdateEvent.Artifact.ArtifactId); + Assert.Single(taskArtifactUpdateEvent.Artifact.Parts); + Assert.IsType(taskArtifactUpdateEvent.Artifact.Parts[0]); + Assert.Equal((expectedArtifact.Parts[0] as TextPart)!.Text, (taskArtifactUpdateEvent.Artifact.Parts[0] as TextPart)!.Text); + Assert.True(taskArtifactUpdateEvent.Append); + Assert.False(taskArtifactUpdateEvent.LastChunk); + Assert.NotNull(taskArtifactUpdateEvent.Metadata); + Assert.Single(taskArtifactUpdateEvent.Metadata); + Assert.Equal(expectedMetadata["createdAt"], taskArtifactUpdateEvent.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AEvent_Deserialize_UnknownKind_Throws_A2AException() + { + // Arrange + const string json = """ + { + "kind": "lorem", + "foo": "bar" + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AEvent_Deserialize_MissingKind_Throws() + { + // Arrange + const string json = """ + { + "role": "user", + "messageId": "m-5", + "parts": [ { "kind": "text", "text": "hi" } ] + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AEvent_Deserialize_KindNotBeingFirst_Succeeds() + { + // Arrange + const string json = """ + { + "role": "user", + "kind": "message", + "parts": [ { "kind": "text", "text": "hi" } ], + "messageId": "m-7" + } + """; + var expectedParts = new[] { new TextPart() { Text = "hi" } }; + + // Act + var a2aEvent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var message = Assert.IsType(a2aEvent); + + // Assert + Assert.Equal(MessageRole.User, message.Role); + Assert.Equal("m-7", message.MessageId); + Assert.Single(message.Parts); + Assert.IsType(message.Parts[0]); + Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); + } + + [Fact] + public void A2AEvent_Serialize_AllKnownType_Succeeds() + { + // Arrange + var a2aEvents = new A2AEvent[] { + new AgentMessage { Role = MessageRole.User, MessageId = "m-7", Parts = [new TextPart { Text = "hello" }] }, + new AgentTask { Id = "t-9", ContextId = "c-9", Status = new AgentTaskStatus { State = TaskState.Submitted, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } }, + new TaskStatusUpdateEvent { TaskId = "t-10", ContextId = "c-10", Status = new AgentTaskStatus { State = TaskState.Working, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } }, + new TaskArtifactUpdateEvent { TaskId = "t-11", ContextId = "c-11" } + }; + var serializedA2aEvents = new string[] { + "{\"kind\":\"message\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}],\"messageId\":\"m-7\"}", + "{\"kind\":\"task\",\"id\":\"t-9\",\"contextId\":\"c-9\",\"status\":{\"state\":\"submitted\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"history\":[]}", + "{\"kind\":\"status-update\",\"status\":{\"state\":\"working\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"final\":false,\"taskId\":\"t-10\",\"contextId\":\"c-10\"}", + "{\"kind\":\"artifact-update\",\"artifact\":{\"artifactId\":\"\",\"parts\":[]},\"taskId\":\"t-11\",\"contextId\":\"c-11\"}" + }; + + for (var i = 0; i < a2aEvents.Length; i++) + { + // Act + var json = JsonSerializer.Serialize(a2aEvents[i], A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.Equal(serializedA2aEvents[i], json); + } + } + + [Theory] + [InlineData("{ \"kind\": 1 }")] + [InlineData("{ \"kind\": null }")] + [InlineData("{ \"kind\": \"unknown\" }")] + [InlineData("{ \"kind\": \"count\" }")] + [InlineData("{ \"kind\": \"neveravaluethatsgoingtooccurinthewild\" }")] + [InlineData("{ \"kind\": \"\" }")] + public void A2AEvent_Deserialize_BadValue_Throws(string json) + { + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + testOutput.WriteLine($"Exception: {ex}"); + + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + } +} diff --git a/tests/A2A.UnitTests/Models/A2AResponseTests.cs b/tests/A2A.V0_3.UnitTests/Models/A2AResponseTests.cs similarity index 96% rename from tests/A2A.UnitTests/Models/A2AResponseTests.cs rename to tests/A2A.V0_3.UnitTests/Models/A2AResponseTests.cs index 7f9f263a..1e3f682e 100644 --- a/tests/A2A.UnitTests/Models/A2AResponseTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/A2AResponseTests.cs @@ -1,244 +1,244 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models; - -public class A2AResponseTests -{ - private static readonly Dictionary expectedMetadata = new() - { - ["createdAt"] = "2023-01-01T00:00:00Z" - }; - - [Fact] - public void A2AResponse_Deserialize_Message_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "message", - "role": "user", - "messageId": "m-2", - "taskId": "t-2", - "contextId": "c-2", - "referenceTaskIds": [ "r-3", "r-4" ], - "parts": [ { "kind": "text", "text": "hi" } ], - "extensions": [ "foo", "bar" ], - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - var expectedReferenceTaskIds = new[] { "r-3", "r-4" }; - var expectedParts = new[] { new TextPart { Text = "hi" } }; - var expectedExtensions = new[] { "foo", "bar" }; - - // Act - var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var message = Assert.IsType(a2aResponse); - - // Assert - Assert.Equal(MessageRole.User, message.Role); - Assert.Equal("m-2", message.MessageId); - Assert.Equal("t-2", message.TaskId); - Assert.Equal("c-2", message.ContextId); - Assert.Equal(expectedReferenceTaskIds, message.ReferenceTaskIds); - Assert.Single(message.Parts); - Assert.IsType(message.Parts[0]); - Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); - Assert.Equal(expectedExtensions, message.Extensions); - Assert.NotNull(message.Metadata); - Assert.Single(message.Metadata); - Assert.Equal(expectedMetadata["createdAt"], message.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AResponse_Deserialize_AgentTask_Succeeds() - { - // Arrange - const string json = """ - { - "kind": "task", - "id": "t-4", - "contextId": "c-4", - "status": { "state": "submitted" }, - "artifacts": [ - { "artifactId": "f-2", "name": "file2.txt", "description": "A text file", "parts": [] } - ], - "history": [ - { "kind": "message", "role": "user", "messageId": "m-4", "parts": [ { "kind": "text", "text": "go" } ] } - ], - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - var expectedArtifacts = new[] - { - new Artifact - { - ArtifactId = "f-2", - Name = "file2.txt", - Description = "A text file", - } - }; - var expectedHistory = new[] - { - new AgentMessage - { - Role = MessageRole.User, - MessageId = "m-4", - } - }; - - // Act - var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var agentTask = Assert.IsType(a2aResponse); - - // Assert - Assert.Equal("t-4", agentTask.Id); - Assert.Equal("c-4", agentTask.ContextId); - Assert.Equal(TaskState.Submitted, agentTask.Status.State); - Assert.NotNull(agentTask.Artifacts); - Assert.Single(agentTask.Artifacts); - Assert.Equal(expectedArtifacts[0].ArtifactId, agentTask.Artifacts[0].ArtifactId); - Assert.Equal(expectedArtifacts[0].Name, agentTask.Artifacts[0].Name); - Assert.Equal(expectedArtifacts[0].Description, agentTask.Artifacts[0].Description); - Assert.NotNull(agentTask.History); - Assert.Single(agentTask.History); - Assert.Equal(expectedHistory[0].Role, agentTask.History![0].Role); - Assert.Equal(expectedHistory[0].MessageId, agentTask.History![0].MessageId); - Assert.NotNull(agentTask.Metadata); - Assert.Single(agentTask.Metadata); - Assert.Equal(expectedMetadata["createdAt"], agentTask.Metadata["createdAt"].GetString()); - } - - [Fact] - public void A2AResponse_Deserialize_TaskStatusUpdateEvent_Throws() - { - // Arrange - const string json = """ - { - "kind": "status-update", - "taskId": "t-6", - "contextId": "c-6", - "status": { "state": "working" }, - "final": false, - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AResponse_Deserialize_TaskArtifactUpdateEvent_Throws() - { - // Arrange - const string json = """ - { - "kind": "artifact-update", - "taskId": "t-8", - "contextId": "c-8", - "artifact": { - "artifactId": "a-2", - "parts": [ { "kind": "text", "text": "chunk" } ] - }, - "append": true, - "lastChunk": false, - "metadata": { - "createdAt": "2023-01-01T00:00:00Z" - } - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AResponse_Deserialize_UnknownKind_Throws() - { - // Arrange - const string json = """ - { - "kind": "unknown", - "foo": "bar" - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AResponse_Deserialize_MissingKind_Throws() - { - // Arrange - const string json = """ - { - "role": "user", - "messageId": "m-6", - "parts": [ { "kind": "text", "text": "hi" } ] - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AResponse_Deserialize_KindNotBeingFirst_Succeeds() - { - // Arrange - const string json = """ - { - "role": "user", - "kind": "message", - "parts": [ { "kind": "text", "text": "hi" } ], - "messageId": "m-7" - } - """; - var expectedParts = new[] { new TextPart() { Text = "hi" } }; - - // Act - var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - var message = Assert.IsType(a2aResponse); - - // Assert - Assert.Equal(MessageRole.User, message.Role); - Assert.Equal("m-7", message.MessageId); - Assert.Single(message.Parts); - Assert.IsType(message.Parts[0]); - Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); - } - - [Fact] - public void A2AResponse_Serialize_AllKnownType_Succeeds() - { - // Arrange - var a2aResponses = new A2AResponse[] { - new AgentMessage { Role = MessageRole.User, MessageId = "m-8", Parts = [new TextPart { Text = "hello" }] }, - new AgentTask { Id = "t-12", ContextId = "c-12", Status = new AgentTaskStatus { State = TaskState.Submitted, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } } - }; - var serializedA2aResponses = new string[] { - "{\"kind\":\"message\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}],\"messageId\":\"m-8\"}", - "{\"kind\":\"task\",\"id\":\"t-12\",\"contextId\":\"c-12\",\"status\":{\"state\":\"submitted\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"history\":[]}" - }; - - for (var i = 0; i < a2aResponses.Length; i++) - { - // Act - var json = JsonSerializer.Serialize(a2aResponses[i], A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.Equal(serializedA2aResponses[i], json); - } - } -} +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class A2AResponseTests +{ + private static readonly Dictionary expectedMetadata = new() + { + ["createdAt"] = "2023-01-01T00:00:00Z" + }; + + [Fact] + public void A2AResponse_Deserialize_Message_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "message", + "role": "user", + "messageId": "m-2", + "taskId": "t-2", + "contextId": "c-2", + "referenceTaskIds": [ "r-3", "r-4" ], + "parts": [ { "kind": "text", "text": "hi" } ], + "extensions": [ "foo", "bar" ], + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + var expectedReferenceTaskIds = new[] { "r-3", "r-4" }; + var expectedParts = new[] { new TextPart { Text = "hi" } }; + var expectedExtensions = new[] { "foo", "bar" }; + + // Act + var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var message = Assert.IsType(a2aResponse); + + // Assert + Assert.Equal(MessageRole.User, message.Role); + Assert.Equal("m-2", message.MessageId); + Assert.Equal("t-2", message.TaskId); + Assert.Equal("c-2", message.ContextId); + Assert.Equal(expectedReferenceTaskIds, message.ReferenceTaskIds); + Assert.Single(message.Parts); + Assert.IsType(message.Parts[0]); + Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); + Assert.Equal(expectedExtensions, message.Extensions); + Assert.NotNull(message.Metadata); + Assert.Single(message.Metadata); + Assert.Equal(expectedMetadata["createdAt"], message.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AResponse_Deserialize_AgentTask_Succeeds() + { + // Arrange + const string json = """ + { + "kind": "task", + "id": "t-4", + "contextId": "c-4", + "status": { "state": "submitted" }, + "artifacts": [ + { "artifactId": "f-2", "name": "file2.txt", "description": "A text file", "parts": [] } + ], + "history": [ + { "kind": "message", "role": "user", "messageId": "m-4", "parts": [ { "kind": "text", "text": "go" } ] } + ], + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + var expectedArtifacts = new[] + { + new Artifact + { + ArtifactId = "f-2", + Name = "file2.txt", + Description = "A text file", + } + }; + var expectedHistory = new[] + { + new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-4", + } + }; + + // Act + var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var agentTask = Assert.IsType(a2aResponse); + + // Assert + Assert.Equal("t-4", agentTask.Id); + Assert.Equal("c-4", agentTask.ContextId); + Assert.Equal(TaskState.Submitted, agentTask.Status.State); + Assert.NotNull(agentTask.Artifacts); + Assert.Single(agentTask.Artifacts); + Assert.Equal(expectedArtifacts[0].ArtifactId, agentTask.Artifacts[0].ArtifactId); + Assert.Equal(expectedArtifacts[0].Name, agentTask.Artifacts[0].Name); + Assert.Equal(expectedArtifacts[0].Description, agentTask.Artifacts[0].Description); + Assert.NotNull(agentTask.History); + Assert.Single(agentTask.History); + Assert.Equal(expectedHistory[0].Role, agentTask.History![0].Role); + Assert.Equal(expectedHistory[0].MessageId, agentTask.History![0].MessageId); + Assert.NotNull(agentTask.Metadata); + Assert.Single(agentTask.Metadata); + Assert.Equal(expectedMetadata["createdAt"], agentTask.Metadata["createdAt"].GetString()); + } + + [Fact] + public void A2AResponse_Deserialize_TaskStatusUpdateEvent_Throws() + { + // Arrange + const string json = """ + { + "kind": "status-update", + "taskId": "t-6", + "contextId": "c-6", + "status": { "state": "working" }, + "final": false, + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AResponse_Deserialize_TaskArtifactUpdateEvent_Throws() + { + // Arrange + const string json = """ + { + "kind": "artifact-update", + "taskId": "t-8", + "contextId": "c-8", + "artifact": { + "artifactId": "a-2", + "parts": [ { "kind": "text", "text": "chunk" } ] + }, + "append": true, + "lastChunk": false, + "metadata": { + "createdAt": "2023-01-01T00:00:00Z" + } + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AResponse_Deserialize_UnknownKind_Throws() + { + // Arrange + const string json = """ + { + "kind": "unknown", + "foo": "bar" + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AResponse_Deserialize_MissingKind_Throws() + { + // Arrange + const string json = """ + { + "role": "user", + "messageId": "m-6", + "parts": [ { "kind": "text", "text": "hi" } ] + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AResponse_Deserialize_KindNotBeingFirst_Succeeds() + { + // Arrange + const string json = """ + { + "role": "user", + "kind": "message", + "parts": [ { "kind": "text", "text": "hi" } ], + "messageId": "m-7" + } + """; + var expectedParts = new[] { new TextPart() { Text = "hi" } }; + + // Act + var a2aResponse = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + var message = Assert.IsType(a2aResponse); + + // Assert + Assert.Equal(MessageRole.User, message.Role); + Assert.Equal("m-7", message.MessageId); + Assert.Single(message.Parts); + Assert.IsType(message.Parts[0]); + Assert.Equal(expectedParts[0].Text, (message.Parts[0] as TextPart)!.Text); + } + + [Fact] + public void A2AResponse_Serialize_AllKnownType_Succeeds() + { + // Arrange + var a2aResponses = new A2AResponse[] { + new AgentMessage { Role = MessageRole.User, MessageId = "m-8", Parts = [new TextPart { Text = "hello" }] }, + new AgentTask { Id = "t-12", ContextId = "c-12", Status = new AgentTaskStatus { State = TaskState.Submitted, Timestamp = DateTimeOffset.Parse("2023-01-01T00:00:00+00:00", null) } } + }; + var serializedA2aResponses = new string[] { + "{\"kind\":\"message\",\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"hello\"}],\"messageId\":\"m-8\"}", + "{\"kind\":\"task\",\"id\":\"t-12\",\"contextId\":\"c-12\",\"status\":{\"state\":\"submitted\",\"timestamp\":\"2023-01-01T00:00:00+00:00\"},\"history\":[]}" + }; + + for (var i = 0; i < a2aResponses.Length; i++) + { + // Act + var json = JsonSerializer.Serialize(a2aResponses[i], A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.Equal(serializedA2aResponses[i], json); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/AIContentExtensionsTests.cs b/tests/A2A.V0_3.UnitTests/Models/AIContentExtensionsTests.cs new file mode 100644 index 00000000..c1558acc --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/AIContentExtensionsTests.cs @@ -0,0 +1,246 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace A2A.V0_3.UnitTests.Models +{ + public sealed class AIContentExtensionsTests + { + [Fact] + public void ToChatMessage_ThrowsOnNullAgentMessage() + { + Assert.Throws("agentMessage", () => ((AgentMessage)null!).ToChatMessage()); + } + + [Fact] + public void ToChatMessage_ConvertsAgentRoleAndParts() + { + var text = new TextPart { Text = "hello" }; + var file = new FilePart { File = new FileContent(new Uri("https://example.com")) { MimeType = "text/plain" } }; + var bytes = new byte[] { 1, 2, 3 }; + var fileBytes = new FilePart { File = new FileContent(Convert.ToBase64String(bytes)) { MimeType = "application/octet-stream", Name = "b.bin" } }; + var data = new DataPart { Data = new Dictionary { ["k"] = JsonSerializer.SerializeToElement("v") } }; + var agent = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "mid-1", + Parts = new List { text, file, fileBytes, data } + }; + + var chat = agent.ToChatMessage(); + + Assert.Equal(ChatRole.Assistant, chat.Role); + Assert.Same(agent, chat.RawRepresentation); + Assert.Equal(agent.MessageId, chat.MessageId); + Assert.Equal(agent.Parts.Count, chat.Contents.Count); + + // Validate all content mappings + var c0 = Assert.IsType(chat.Contents[0]); + Assert.Equal("hello", c0.Text); + + var c1 = Assert.IsType(chat.Contents[1]); + Assert.Equal(new Uri("https://example.com"), c1.Uri); + Assert.Equal("text/plain", c1.MediaType); + + var c2 = Assert.IsType(chat.Contents[2]); + Assert.Equal(new byte[] { 1, 2, 3 }, c2.Data); + Assert.Equal("application/octet-stream", c2.MediaType); + + var c3 = Assert.IsType(chat.Contents[3]); + Assert.Same(data, c3.RawRepresentation); + } + + [Fact] + public void ToChatMessage_CopiesMetadataToAdditionalProperties() + { + var metadata = new Dictionary + { + ["num"] = JsonSerializer.SerializeToElement(42), + ["str"] = JsonSerializer.SerializeToElement("value") + }; + var agent = new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-meta", + Parts = new List(), + Metadata = metadata + }; + + var chat = agent.ToChatMessage(); + Assert.NotNull(chat.AdditionalProperties); + Assert.Equal(2, chat.AdditionalProperties!.Count); + Assert.True(chat.AdditionalProperties.TryGetValue("num", out var numObj)); + Assert.True(chat.AdditionalProperties.TryGetValue("str", out var strObj)); + var numJe = Assert.IsType(numObj); + var strJe = Assert.IsType(strObj); + Assert.Equal(42, numJe.GetInt32()); + Assert.Equal("value", strJe.GetString()); + } + + [Fact] + public void ToAgentMessage_ThrowsOnNullChatMessage() + { + Assert.Throws("chatMessage", () => ((ChatMessage)null!).ToAgentMessage()); + } + + [Fact] + public void ToAgentMessage_ReturnsExistingAgentMessageWhenRawRepresentationMatches() + { + var original = new AgentMessage { MessageId = "m1", Parts = new List { new TextPart { Text = "hi" } } }; + var chat = original.ToChatMessage(); + + Assert.Same(original, chat.RawRepresentation); + Assert.Same(original, chat.ToAgentMessage()); + } + + [Fact] + public void ToAgentMessage_GeneratesMessageIdAndConvertsParts() + { + var chat = new ChatMessage + { + Role = ChatRole.Assistant, + MessageId = null, + Contents = new List + { + new TextContent("hello"), + new UriContent(new Uri("https://example.com/file.txt"), "text/plain") + } + }; + + var msg = chat.ToAgentMessage(); + + Assert.Equal(MessageRole.Agent, msg.Role); + Assert.False(string.IsNullOrWhiteSpace(msg.MessageId)); + Assert.Equal(2, msg.Parts.Count); + Assert.IsType(msg.Parts[0]); + Assert.IsType(msg.Parts[1]); + } + + [Fact] + public void ToAIContent_ThrowsOnNullPart() + { + Assert.Throws("part", () => ((Part)null!).ToAIContent()); + } + + [Fact] + public void ToAIContent_ConvertsTextPart() + { + var part = new TextPart { Text = "abc" }; + var content = part.ToAIContent(); + var tc = Assert.IsType(content); + Assert.Equal("abc", tc.Text); + Assert.Same(part, content.RawRepresentation); + } + + [Fact] + public void ToAIContent_ConvertsFilePartWithUri() + { + var uri = new Uri("https://example.com/data.json"); + var part = new FilePart { File = new FileContent(uri) { MimeType = "application/json" } }; + var content = part.ToAIContent(); + var uc = Assert.IsType(content); + Assert.Equal(uri, uc.Uri); + Assert.Equal("application/json", uc.MediaType); + } + + [Fact] + public void ToAIContent_ConvertsFilePartWithBytes() + { + var raw = new byte[] { 10, 20, 30 }; + var b64 = Convert.ToBase64String(raw); + var part = new FilePart { File = new FileContent(b64) { MimeType = null, Name = "r.bin" } }; + var content = part.ToAIContent(); + var dc = Assert.IsType(content); + Assert.Equal(raw, dc.Data); + Assert.Equal("application/octet-stream", dc.MediaType); // default applied + } + + [Fact] + public void ToAIContent_ConvertsDataPartWithMetadata() + { + var metaValue = JsonSerializer.SerializeToElement(123); + var part = new DataPart + { + Data = new Dictionary { ["x"] = JsonSerializer.SerializeToElement("y") }, + Metadata = new Dictionary { ["m"] = metaValue } + }; + var content = part.ToAIContent(); + Assert.IsType(content); + Assert.NotNull(content.AdditionalProperties); + Assert.True(content.AdditionalProperties!.TryGetValue("m", out var obj)); + Assert.True(obj is JsonElement je && je.GetInt32() == 123); + } + + [Fact] + public void ToAIContent_FallsBackToBaseAIContentForUnknownPart() + { + var part = new CustomPart(); + var content = part.ToAIContent(); + Assert.Equal(typeof(AIContent), content.GetType()); + Assert.Same(part, content.RawRepresentation); + } + + [Fact] + public void ToPart_ThrowsOnNullContent() + { + Assert.Throws("content", () => ((AIContent)null!).ToPart()); + } + + [Fact] + public void ToPart_ReturnsExistingPartWhenRawRepresentationPresent() + { + var part = new TextPart { Text = "hi" }; + Assert.Same(part, part.ToAIContent().ToPart()); + } + + [Fact] + public void ToPart_ConvertsTextContent() + { + var content = new TextContent("hello"); + var part = content.ToPart(); + var tp = Assert.IsType(part); + Assert.Equal("hello", tp.Text); + } + + [Fact] + public void ToPart_ConvertsUriContent() + { + var uri = new Uri("https://example.com/a.txt"); + var content = new UriContent(uri, "text/plain"); + var part = content.ToPart(); + var fp = Assert.IsType(part); + Assert.NotNull(fp.File.Uri); + Assert.Equal(uri, fp.File.Uri); + Assert.Equal("text/plain", fp.File.MimeType); + } + + [Fact] + public void ToPart_ConvertsDataContent() + { + var payload = new byte[] { 1, 2, 3, 4 }; + var content = new DataContent(payload, "application/custom"); + var part = content.ToPart(); + var fp = Assert.IsType(part); + Assert.NotNull(fp.File.Bytes); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, Convert.FromBase64String(fp.File.Bytes)); + Assert.Equal("application/custom", fp.File.MimeType); + } + + [Fact] + public void ToPart_TransfersAdditionalPropertiesToMetadata() + { + var content = new TextContent("hello"); + content.AdditionalProperties ??= new(); + content.AdditionalProperties["s"] = "str"; + content.AdditionalProperties["i"] = 42; + + var part = content.ToPart(); + Assert.NotNull(part); + Assert.NotNull(part!.Metadata); + Assert.True(part.Metadata!.ContainsKey("s")); + Assert.True(part.Metadata!.ContainsKey("i")); + Assert.Equal("str", part.Metadata["s"].GetString()); + Assert.Equal(42, part.Metadata["i"].GetInt32()); + } + private sealed class CustomPart() : Part("custom-kind") { } + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/AdditionalPropertiesExtensionsTests.cs b/tests/A2A.V0_3.UnitTests/Models/AdditionalPropertiesExtensionsTests.cs new file mode 100644 index 00000000..1b3947af --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/AdditionalPropertiesExtensionsTests.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace A2A.V0_3.UnitTests.Models +{ + public sealed class AdditionalPropertiesExtensionsTests + { + [Fact] + public void ToAdditionalProperties_ReturnsNullForNullMetadata() + { + Dictionary? metadata = null; + var result = metadata.ToAdditionalProperties(); + Assert.Null(result); + } + + [Fact] + public void ToAdditionalProperties_ReturnsNullForEmptyMetadata() + { + var metadata = new Dictionary(); + var result = metadata.ToAdditionalProperties(); + Assert.Null(result); + } + + [Fact] + public void ToAdditionalProperties_ConvertsMetadataToAdditionalProperties() + { + var metadata = new Dictionary + { + ["num"] = JsonSerializer.SerializeToElement(42), + ["str"] = JsonSerializer.SerializeToElement("value"), + ["bool"] = JsonSerializer.SerializeToElement(true) + }; + + var result = metadata.ToAdditionalProperties(); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.True(result.TryGetValue("num", out var numObj)); + Assert.True(result.TryGetValue("str", out var strObj)); + Assert.True(result.TryGetValue("bool", out var boolObj)); + var numJe = Assert.IsType(numObj); + var strJe = Assert.IsType(strObj); + var boolJe = Assert.IsType(boolObj); + Assert.Equal(42, numJe.GetInt32()); + Assert.Equal("value", strJe.GetString()); + Assert.True(boolJe.GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_ReturnsNullForNullAdditionalProperties() + { + AdditionalPropertiesDictionary? additionalProperties = null; + var result = additionalProperties.ToA2AMetadata(); + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_ReturnsNullForEmptyAdditionalProperties() + { + var additionalProperties = new AdditionalPropertiesDictionary(); + var result = additionalProperties.ToA2AMetadata(); + Assert.Null(result); + } + + [Fact] + public void ToA2AMetadata_ConvertsAdditionalPropertiesToMetadata() + { + var additionalProperties = new AdditionalPropertiesDictionary + { + ["num"] = 42, + ["str"] = "value", + ["bool"] = true + }; + + var result = additionalProperties.ToA2AMetadata(); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.True(result.TryGetValue("num", out var numJe)); + Assert.True(result.TryGetValue("str", out var strJe)); + Assert.True(result.TryGetValue("bool", out var boolJe)); + Assert.Equal(42, numJe.GetInt32()); + Assert.Equal("value", strJe.GetString()); + Assert.True(boolJe.GetBoolean()); + } + + [Fact] + public void ToA2AMetadata_HandlesJsonElementValues() + { + var additionalProperties = new AdditionalPropertiesDictionary + { + ["nested"] = JsonSerializer.SerializeToElement(new { key = "val" }) + }; + + var result = additionalProperties.ToA2AMetadata(); + + Assert.NotNull(result); + Assert.Single(result!); + Assert.True(result.TryGetValue("nested", out var nestedJe)); + Assert.Equal(JsonValueKind.Object, nestedJe.ValueKind); + Assert.Equal("val", nestedJe.GetProperty("key").GetString()); + } + + [Fact] + public void RoundTrip_ToA2AMetadata_And_ToAdditionalProperties() + { + var originalProps = new AdditionalPropertiesDictionary + { + ["number"] = 123, + ["text"] = "hello" + }; + + var metadata = originalProps.ToA2AMetadata(); + Assert.NotNull(metadata); + + var restoredProps = metadata.ToAdditionalProperties(); + Assert.NotNull(restoredProps); + Assert.Equal(2, restoredProps!.Count); + + // Values are now JsonElements after the round trip + Assert.True(restoredProps.TryGetValue("number", out var numObj)); + Assert.True(restoredProps.TryGetValue("text", out var textObj)); + var numJe = Assert.IsType(numObj); + var textJe = Assert.IsType(textObj); + Assert.Equal(123, numJe.GetInt32()); + Assert.Equal("hello", textJe.GetString()); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/AgentCardSignatureTests.cs b/tests/A2A.V0_3.UnitTests/Models/AgentCardSignatureTests.cs new file mode 100644 index 00000000..c1048a4d --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/AgentCardSignatureTests.cs @@ -0,0 +1,80 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class AgentCardSignatureTests +{ + [Fact] + public void AgentCardSignature_SerializesAndDeserializesCorrectly() + { + // Arrange + var signature = new AgentCardSignature + { + Protected = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + Signature = "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ", + Header = new Dictionary + { + { "kid", "key-1" }, + { "alg", "ES256" } + } + }; + + // Act + var json = JsonSerializer.Serialize(signature, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(signature.Protected, deserialized.Protected); + Assert.Equal(signature.Signature, deserialized.Signature); + Assert.NotNull(deserialized.Header); + Assert.Equal(2, deserialized.Header.Count); + Assert.True(deserialized.Header.ContainsKey("kid")); + Assert.True(deserialized.Header.ContainsKey("alg")); + } + + [Fact] + public void AgentCardSignature_WithoutHeader_SerializesCorrectly() + { + // Arrange + var signature = new AgentCardSignature + { + Protected = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + Signature = "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + }; + + // Act + var json = JsonSerializer.Serialize(signature, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(signature.Protected, deserialized.Protected); + Assert.Equal(signature.Signature, deserialized.Signature); + Assert.Null(deserialized.Header); + + // Verify JSON doesn't include null header + Assert.DoesNotContain("\"header\"", json); + } + + [Fact] + public void AgentCardSignature_MatchesSpecExample() + { + // Arrange - JSON example from the A2A spec + var specJson = """ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + """; + + // Act + var deserialized = JsonSerializer.Deserialize(specJson, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", deserialized.Protected); + Assert.Equal("QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ", deserialized.Signature); + Assert.Null(deserialized.Header); + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/AgentCardTests.cs b/tests/A2A.V0_3.UnitTests/Models/AgentCardTests.cs new file mode 100644 index 00000000..f433542b --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/AgentCardTests.cs @@ -0,0 +1,301 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class AgentCardTests +{ + private const string ExpectedJson = """ + { + "name": "Test Agent", + "description": "A test agent for MVP serialization", + "url": "https://example.com/agent", + "provider": { + "organization": "Test Org", + "url": "https://testorg.com" + }, + "version": "1.0.0", + "protocolVersion": "0.3.0", + "documentationUrl": "https://docs.example.com", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "stateTransitionHistory": true + }, + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + } + }, + "security": [ + { + "apiKey": [] + } + ], + "defaultInputModes": ["text", "image"], + "defaultOutputModes": ["text", "json"], + "skills": [ + { + "id": "test-skill", + "name": "Test Skill", + "description": "A test skill", + "tags": ["test", "skill"], + "examples": ["Example usage"], + "inputModes": ["text"], + "outputModes": ["text"], + "security": [ + { + "oauth": ["read"] + } + ] + } + ], + "supportsAuthenticatedExtendedCard": true, + "additionalInterfaces": [ + { + "transport": "JSONRPC", + "url": "https://jsonrpc.example.com/agent" + } + ], + "preferredTransport": "GRPC", + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + ] + } + """; + + [Fact] + public void AgentCard_Deserialize_AllPropertiesCorrect() + { + // Act + var deserializedCard = JsonSerializer.Deserialize(ExpectedJson, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserializedCard); + Assert.Equal("Test Agent", deserializedCard.Name); + Assert.Equal("A test agent for MVP serialization", deserializedCard.Description); + Assert.Equal("https://example.com/agent", deserializedCard.Url); + Assert.Equal("1.0.0", deserializedCard.Version); + Assert.Equal("0.3.0", deserializedCard.ProtocolVersion); + Assert.Equal("https://docs.example.com", deserializedCard.DocumentationUrl); + Assert.True(deserializedCard.SupportsAuthenticatedExtendedCard); + + // Provider + Assert.NotNull(deserializedCard.Provider); + Assert.Equal("Test Org", deserializedCard.Provider.Organization); + Assert.Equal("https://testorg.com", deserializedCard.Provider.Url); + + // Capabilities + Assert.NotNull(deserializedCard.Capabilities); + Assert.True(deserializedCard.Capabilities.Streaming); + Assert.False(deserializedCard.Capabilities.PushNotifications); + Assert.True(deserializedCard.Capabilities.StateTransitionHistory); + + // Security + Assert.NotNull(deserializedCard.SecuritySchemes); + Assert.Single(deserializedCard.SecuritySchemes); + Assert.Contains("apiKey", deserializedCard.SecuritySchemes.Keys); + var apisec = Assert.IsType(deserializedCard.SecuritySchemes["apiKey"]); + Assert.False(string.IsNullOrWhiteSpace(apisec.Name)); + Assert.False(string.IsNullOrWhiteSpace(apisec.KeyLocation)); + + Assert.NotNull(deserializedCard.Security); + Assert.Single(deserializedCard.Security); + + var securityRequirement = deserializedCard.Security[0]; + Assert.NotNull(securityRequirement); + Assert.Single(securityRequirement); + Assert.True(securityRequirement.ContainsKey("apiKey")); + Assert.Empty(securityRequirement["apiKey"]); + + // Input/Output modes + Assert.Equal(new List { "text", "image" }, deserializedCard.DefaultInputModes); + Assert.Equal(new List { "text", "json" }, deserializedCard.DefaultOutputModes); + + // Skills + Assert.NotNull(deserializedCard.Skills); + Assert.Single(deserializedCard.Skills); + var skill = deserializedCard.Skills[0]; + Assert.Equal("test-skill", skill.Id); + Assert.Equal("Test Skill", skill.Name); + Assert.Equal("A test skill", skill.Description); + Assert.NotNull(skill.Tags); + Assert.Equal(2, skill.Tags.Count); + Assert.Contains("test", skill.Tags); + Assert.Contains("skill", skill.Tags); + + // Skill Security + Assert.NotNull(skill.Security); + Assert.Single(skill.Security); + var skillSecurityRequirement = skill.Security[0]; + Assert.NotNull(skillSecurityRequirement); + Assert.Single(skillSecurityRequirement); + Assert.True(skillSecurityRequirement.ContainsKey("oauth")); + Assert.Equal(["read"], skillSecurityRequirement["oauth"]); + + // Transport properties + Assert.Equal("GRPC", deserializedCard.PreferredTransport.Label); + Assert.NotNull(deserializedCard.AdditionalInterfaces); + Assert.Single(deserializedCard.AdditionalInterfaces); + Assert.Equal("JSONRPC", deserializedCard.AdditionalInterfaces[0].Transport.Label); + Assert.Equal("https://jsonrpc.example.com/agent", deserializedCard.AdditionalInterfaces[0].Url); + + // Signatures + Assert.NotNull(deserializedCard.Signatures); + Assert.Single(deserializedCard.Signatures); + var signature = deserializedCard.Signatures[0]; + Assert.Equal("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", signature.Protected); + Assert.Equal("QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ", signature.Signature); + Assert.Null(signature.Header); + } + + [Fact] + public void AgentCard_Serialize_ProducesExpectedJson() + { + // Arrange + var agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for MVP serialization", + Url = "https://example.com/agent", + Provider = new AgentProvider + { + Organization = "Test Org", + Url = "https://testorg.com" + }, + Version = "1.0.0", + ProtocolVersion = "0.3.0", + DocumentationUrl = "https://docs.example.com", + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + StateTransitionHistory = true + }, + SecuritySchemes = new Dictionary + { + ["apiKey"] = new ApiKeySecurityScheme("X-API-Key", "header") + }, + Security = new List> + { + new() + { + ["apiKey"] = [] + } + }, + DefaultInputModes = ["text", "image"], + DefaultOutputModes = ["text", "json"], + Skills = [ + new AgentSkill + { + Id = "test-skill", + Name = "Test Skill", + Description = "A test skill", + Tags = ["test", "skill"], + Examples = ["Example usage"], + InputModes = ["text"], + OutputModes = ["text"], + Security = [ + new Dictionary + { + ["oauth"] = ["read"] + } + ] + } + ], + SupportsAuthenticatedExtendedCard = true, + AdditionalInterfaces = [ + new AgentInterface + { + Transport = AgentTransport.JsonRpc, + Url = "https://jsonrpc.example.com/agent" + } + ], + PreferredTransport = new AgentTransport("GRPC"), + Signatures = [ + new AgentCardSignature + { + Protected = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + Signature = "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + ] + }; + + // Act + var serializedJson = JsonSerializer.Serialize(agentCard, A2AJsonUtilities.DefaultOptions); + + // Assert - Compare objects instead of raw JSON strings to avoid formatting/ordering issues + // and provide more meaningful error messages when properties don't match + var expectedCard = JsonSerializer.Deserialize(ExpectedJson, A2AJsonUtilities.DefaultOptions); + var actualCard = JsonSerializer.Deserialize(serializedJson, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(actualCard); + Assert.NotNull(expectedCard); + + // Compare key properties + Assert.Equal(expectedCard.Name, actualCard.Name); + Assert.Equal(expectedCard.Description, actualCard.Description); + Assert.Equal(expectedCard.Url, actualCard.Url); + Assert.Equal(expectedCard.Version, actualCard.Version); + Assert.Equal(expectedCard.ProtocolVersion, actualCard.ProtocolVersion); + Assert.Equal(expectedCard.DocumentationUrl, actualCard.DocumentationUrl); + Assert.Equal(expectedCard.SupportsAuthenticatedExtendedCard, actualCard.SupportsAuthenticatedExtendedCard); + + // Provider + Assert.Equal(expectedCard.Provider?.Organization, actualCard.Provider?.Organization); + Assert.Equal(expectedCard.Provider?.Url, actualCard.Provider?.Url); + + // Capabilities + Assert.Equal(expectedCard.Capabilities?.Streaming, actualCard.Capabilities?.Streaming); + Assert.Equal(expectedCard.Capabilities?.PushNotifications, actualCard.Capabilities?.PushNotifications); + Assert.Equal(expectedCard.Capabilities?.StateTransitionHistory, actualCard.Capabilities?.StateTransitionHistory); + + // Input/Output modes + Assert.Equal(expectedCard.DefaultInputModes, actualCard.DefaultInputModes); + Assert.Equal(expectedCard.DefaultOutputModes, actualCard.DefaultOutputModes); + + // Skills + Assert.Equal(expectedCard.Skills?.Count, actualCard.Skills?.Count); + if (expectedCard.Skills?.Count > 0 && actualCard.Skills?.Count > 0) + { + var expectedSkill = expectedCard.Skills[0]; + var actualSkill = actualCard.Skills[0]; + Assert.Equal(expectedSkill.Id, actualSkill.Id); + Assert.Equal(expectedSkill.Name, actualSkill.Name); + Assert.Equal(expectedSkill.Description, actualSkill.Description); + Assert.Equal(expectedSkill.Tags, actualSkill.Tags); + Assert.Equal(expectedSkill.Examples, actualSkill.Examples); + Assert.Equal(expectedSkill.InputModes, actualSkill.InputModes); + Assert.Equal(expectedSkill.OutputModes, actualSkill.OutputModes); + + // Skill Security + Assert.Equal(expectedSkill.Security?.Count, actualSkill.Security?.Count); + if (expectedSkill.Security?.Count > 0 && actualSkill.Security?.Count > 0) + { + Assert.Equal(expectedSkill.Security[0].Count, actualSkill.Security[0].Count); + foreach (var kvp in expectedSkill.Security[0]) + { + Assert.True(actualSkill.Security[0].ContainsKey(kvp.Key)); + Assert.Equal(kvp.Value, actualSkill.Security[0][kvp.Key]); + } + } + } + + // Transport properties + Assert.Equal(expectedCard.PreferredTransport.Label, actualCard.PreferredTransport.Label); + Assert.Equal(expectedCard.AdditionalInterfaces?.Count, actualCard.AdditionalInterfaces?.Count); + if (expectedCard.AdditionalInterfaces?.Count > 0 && actualCard.AdditionalInterfaces?.Count > 0) + { + Assert.Equal(expectedCard.AdditionalInterfaces[0].Transport.Label, actualCard.AdditionalInterfaces[0].Transport.Label); + Assert.Equal(expectedCard.AdditionalInterfaces[0].Url, actualCard.AdditionalInterfaces[0].Url); + } + + // Security schemes + Assert.Equal(expectedCard.SecuritySchemes?.Count, actualCard.SecuritySchemes?.Count); + Assert.Equal(expectedCard.Security?.Count, actualCard.Security?.Count); + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/AgentSkillTests.cs b/tests/A2A.V0_3.UnitTests/Models/AgentSkillTests.cs new file mode 100644 index 00000000..d4f295c4 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/AgentSkillTests.cs @@ -0,0 +1,156 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class AgentSkillTests +{ + [Fact] + public void AgentSkill_SecurityProperty_SerializesCorrectly() + { + // Arrange + var skill = new AgentSkill + { + Id = "test-skill", + Name = "Test Skill", + Description = "A test skill with security", + Tags = ["test", "security"], + Security = + [ + new Dictionary + { + { "oauth", ["read", "write"] } + }, + new Dictionary + { + { "api-key", [] }, + { "mtls", [] } + } + ] + }; + + // Act + var json = JsonSerializer.Serialize(skill, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.Contains("\"security\"", json); + Assert.Contains("\"oauth\"", json); + Assert.Contains("\"api-key\"", json); + Assert.Contains("\"mtls\"", json); + Assert.Contains("\"read\"", json); + Assert.Contains("\"write\"", json); + } + + [Fact] + public void AgentSkill_SecurityProperty_DeserializesCorrectly() + { + // Arrange + var json = """ + { + "id": "secure-skill", + "name": "Secure Skill", + "description": "A skill with security requirements", + "tags": ["secure"], + "security": [ + { + "google": ["oidc"] + }, + { + "api-key": [], + "mtls": [] + } + ] + } + """; + + // Act + var skill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(skill); + Assert.Equal("secure-skill", skill.Id); + Assert.Equal("Secure Skill", skill.Name); + Assert.Equal("A skill with security requirements", skill.Description); + + Assert.NotNull(skill.Security); + Assert.Equal(2, skill.Security.Count); + + // First security requirement (google oidc) + var firstRequirement = skill.Security[0]; + Assert.Single(firstRequirement); + Assert.Contains("google", firstRequirement.Keys); + Assert.Equal(["oidc"], firstRequirement["google"]); + + // Second security requirement (api-key AND mtls) + var secondRequirement = skill.Security[1]; + Assert.Equal(2, secondRequirement.Count); + Assert.Contains("api-key", secondRequirement.Keys); + Assert.Contains("mtls", secondRequirement.Keys); + Assert.Empty(secondRequirement["api-key"]); + Assert.Empty(secondRequirement["mtls"]); + } + + [Fact] + public void AgentSkill_SecurityProperty_CanBeNull() + { + // Arrange + var skill = new AgentSkill + { + Id = "simple-skill", + Name = "Simple Skill", + Description = "A skill without security requirements", + Tags = ["simple"], + Security = null + }; + + // Act + var json = JsonSerializer.Serialize(skill, A2AJsonUtilities.DefaultOptions); + var deserializedSkill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.DoesNotContain("\"security\"", json); // Should be omitted when null + Assert.NotNull(deserializedSkill); + Assert.Null(deserializedSkill.Security); + } + + [Fact] + public void AgentSkill_WithAllProperties_SerializesAndDeserializesCorrectly() + { + // Arrange + var originalSkill = new AgentSkill + { + Id = "full-skill", + Name = "Full Skill", + Description = "A skill with all properties", + Tags = ["complete", "test"], + Examples = ["Example usage 1", "Example usage 2"], + InputModes = ["text", "image"], + OutputModes = ["text", "json"], + Security = + [ + new Dictionary + { + { "oauth", ["read"] } + } + ] + }; + + // Act + var json = JsonSerializer.Serialize(originalSkill, A2AJsonUtilities.DefaultOptions); + var deserializedSkill = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserializedSkill); + Assert.Equal(originalSkill.Id, deserializedSkill.Id); + Assert.Equal(originalSkill.Name, deserializedSkill.Name); + Assert.Equal(originalSkill.Description, deserializedSkill.Description); + Assert.Equal(originalSkill.Tags, deserializedSkill.Tags); + Assert.Equal(originalSkill.Examples, deserializedSkill.Examples); + Assert.Equal(originalSkill.InputModes, deserializedSkill.InputModes); + Assert.Equal(originalSkill.OutputModes, deserializedSkill.OutputModes); + + Assert.NotNull(deserializedSkill.Security); + Assert.Single(deserializedSkill.Security); + Assert.Contains("oauth", deserializedSkill.Security[0].Keys); + Assert.Equal(["read"], deserializedSkill.Security[0]["oauth"]); + } +} diff --git a/tests/A2A.UnitTests/Models/AgentTransportTests.cs b/tests/A2A.V0_3.UnitTests/Models/AgentTransportTests.cs similarity index 95% rename from tests/A2A.UnitTests/Models/AgentTransportTests.cs rename to tests/A2A.V0_3.UnitTests/Models/AgentTransportTests.cs index 85e43f0f..36ac8364 100644 --- a/tests/A2A.UnitTests/Models/AgentTransportTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/AgentTransportTests.cs @@ -1,121 +1,121 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models; - -public class AgentTransportTests -{ - [Fact] - public void Constructor_SetsLabelCorrectly() - { - // Arrange & Act - var sut = new AgentTransport("JSONRPC"); - - // Assert - Assert.Equal("JSONRPC", sut.Label); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Constructor_ThrowsOnNullOrWhitespace(string? label) - { - // Act & Assert - Assert.Throws(() => new AgentTransport(label!)); - } - - [Fact] - public void Equality_Works_CaseInsensitive() - { - // Arrange - var sut = new AgentTransport("jsonrpc"); - var other = new AgentTransport("JSONRPC"); - - // Act & Assert - Assert.True(sut == other); - Assert.False(sut != other); - Assert.True(sut.Equals(other)); - Assert.True(sut.Equals((object)other)); - Assert.Equal(sut.GetHashCode(), other.GetHashCode()); - } - - [Fact] - public void ToString_ReturnsLabel() - { - // Arrange - var sut = new AgentTransport("HTTP+JSON"); - - // Act - var result = sut.ToString(); - - // Assert - Assert.Equal("HTTP+JSON", result); - } - - [Fact] - public void JsonRpc_StaticProperty_ReturnsExpected() - { - // Act - var sut = AgentTransport.JsonRpc; - - // Assert - Assert.Equal("JSONRPC", sut.Label); - } - - [Fact] - public void CanSerializeAndDeserialize() - { - // Arrange - var sut = new AgentTransport("GRPC"); - - // Act - var json = JsonSerializer.Serialize(sut); - var deserialized = JsonSerializer.Deserialize(json); - - // Assert - Assert.Equal(sut, deserialized); - Assert.Equal("GRPC", deserialized!.Label); - } - - [Fact] - public void SerializesToSimpleString() - { - // Arrange - var sut = new AgentTransport("JSONRPC"); - - // Act - var json = JsonSerializer.Serialize(sut); - - // Assert - Assert.Equal("\"JSONRPC\"", json); // Should be a simple quoted string, not an object - } - - [Fact] - public void SerializesCorrectlyWithinAgentInterface() - { - // Arrange - var agentInterface = new AgentInterface - { - Transport = new AgentTransport("GRPC"), - Url = "https://example.com/agent" - }; - - // Act - var json = JsonSerializer.Serialize(agentInterface); - - // Assert - // Should contain "transport":"GRPC", not "transport":{"transport":"GRPC"} - Assert.Contains("\"transport\":\"GRPC\"", json); - Assert.DoesNotContain("\"transport\":{\"transport\":", json); - } - - [Theory] - [InlineData("\"\"")] - [InlineData("\" \"")] - public void Deserialize_ThrowsJsonException_WhenStringValueIsEmptyOrWhitespace(string invalidJson) - { - // Act & Assert - var exception = Assert.Throws(() => JsonSerializer.Deserialize(invalidJson)); - Assert.Equal("AgentTransport string value cannot be null or whitespace.", exception.Message); - } -} +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class AgentTransportTests +{ + [Fact] + public void Constructor_SetsLabelCorrectly() + { + // Arrange & Act + var sut = new AgentTransport("JSONRPC"); + + // Assert + Assert.Equal("JSONRPC", sut.Label); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsOnNullOrWhitespace(string? label) + { + // Act & Assert + Assert.Throws(() => new AgentTransport(label!)); + } + + [Fact] + public void Equality_Works_CaseInsensitive() + { + // Arrange + var sut = new AgentTransport("jsonrpc"); + var other = new AgentTransport("JSONRPC"); + + // Act & Assert + Assert.True(sut == other); + Assert.False(sut != other); + Assert.True(sut.Equals(other)); + Assert.True(sut.Equals((object)other)); + Assert.Equal(sut.GetHashCode(), other.GetHashCode()); + } + + [Fact] + public void ToString_ReturnsLabel() + { + // Arrange + var sut = new AgentTransport("HTTP+JSON"); + + // Act + var result = sut.ToString(); + + // Assert + Assert.Equal("HTTP+JSON", result); + } + + [Fact] + public void JsonRpc_StaticProperty_ReturnsExpected() + { + // Act + var sut = AgentTransport.JsonRpc; + + // Assert + Assert.Equal("JSONRPC", sut.Label); + } + + [Fact] + public void CanSerializeAndDeserialize() + { + // Arrange + var sut = new AgentTransport("GRPC"); + + // Act + var json = JsonSerializer.Serialize(sut); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.Equal(sut, deserialized); + Assert.Equal("GRPC", deserialized!.Label); + } + + [Fact] + public void SerializesToSimpleString() + { + // Arrange + var sut = new AgentTransport("JSONRPC"); + + // Act + var json = JsonSerializer.Serialize(sut); + + // Assert + Assert.Equal("\"JSONRPC\"", json); // Should be a simple quoted string, not an object + } + + [Fact] + public void SerializesCorrectlyWithinAgentInterface() + { + // Arrange + var agentInterface = new AgentInterface + { + Transport = new AgentTransport("GRPC"), + Url = "https://example.com/agent" + }; + + // Act + var json = JsonSerializer.Serialize(agentInterface); + + // Assert + // Should contain "transport":"GRPC", not "transport":{"transport":"GRPC"} + Assert.Contains("\"transport\":\"GRPC\"", json); + Assert.DoesNotContain("\"transport\":{\"transport\":", json); + } + + [Theory] + [InlineData("\"\"")] + [InlineData("\" \"")] + public void Deserialize_ThrowsJsonException_WhenStringValueIsEmptyOrWhitespace(string invalidJson) + { + // Act & Assert + var exception = Assert.Throws(() => JsonSerializer.Deserialize(invalidJson)); + Assert.Equal("AgentTransport string value cannot be null or whitespace.", exception.Message); + } +} diff --git a/tests/A2A.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs b/tests/A2A.V0_3.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs similarity index 94% rename from tests/A2A.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs rename to tests/A2A.V0_3.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs index 04f4da2b..e4f20f50 100644 --- a/tests/A2A.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/BaseKindDiscriminatorConverterEdgeTests.cs @@ -1,35 +1,35 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models; - -public sealed class BaseKindDiscriminatorConverterEdgeTests -{ - // Using Part as it has unknown/count semantics baked in and a short payload - - [Fact] - public void Part_Deserialize_Kind_Index_OutOfRange_ThrowsUnknownKind() - { - // Arrange: craft an enum value beyond mapping length - // PartKind.Count = 4, mapping is length 4 with index 0 null (Unknown), valid indices 1..3 - const string json = "{ \"kind\": \"count\" }"; - - // Act - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - - // Assert: should hit the mapping range check and throw Unknown kind - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } - - [Fact] - public void A2AEvent_Deserialize_Kind_Index_Zero_Null_Mapping_ThrowsUnknownKind() - { - // Arrange: A2AEventKind.Unknown maps to index 0 which is null in the mapping - const string json = "{ \"kind\": \"unknown\" }"; - - // Act - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - - // Assert - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - } -} +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public sealed class BaseKindDiscriminatorConverterEdgeTests +{ + // Using Part as it has unknown/count semantics baked in and a short payload + + [Fact] + public void Part_Deserialize_Kind_Index_OutOfRange_ThrowsUnknownKind() + { + // Arrange: craft an enum value beyond mapping length + // PartKind.Count = 4, mapping is length 4 with index 0 null (Unknown), valid indices 1..3 + const string json = "{ \"kind\": \"count\" }"; + + // Act + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + + // Assert: should hit the mapping range check and throw Unknown kind + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } + + [Fact] + public void A2AEvent_Deserialize_Kind_Index_Zero_Null_Mapping_ThrowsUnknownKind() + { + // Arrange: A2AEventKind.Unknown maps to index 0 which is null in the mapping + const string json = "{ \"kind\": \"unknown\" }"; + + // Act + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + + // Assert + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + } +} diff --git a/tests/A2A.UnitTests/Models/FileContentSpecComplianceTests.cs b/tests/A2A.V0_3.UnitTests/Models/FileContentSpecComplianceTests.cs similarity index 96% rename from tests/A2A.UnitTests/Models/FileContentSpecComplianceTests.cs rename to tests/A2A.V0_3.UnitTests/Models/FileContentSpecComplianceTests.cs index fa9312cc..9377b108 100644 --- a/tests/A2A.UnitTests/Models/FileContentSpecComplianceTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/FileContentSpecComplianceTests.cs @@ -1,171 +1,171 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models; - -public class FileContentSpecComplianceTests -{ - [Fact] - public void FileContent_Deserialize_WithoutKind_ShouldSucceed_ForBytesContent() - { - // Arrange: JSON according to A2A spec (without "kind" property) - const string json = """ - { - "name": "example.txt", - "mimeType": "text/plain", - "bytes": "SGVsbG8gV29ybGQ=" - } - """; - - // Act & Assert: This should work according to A2A spec - var fileContent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - - Assert.NotNull(fileContent); - Assert.Equal("example.txt", fileContent.Name); - Assert.Equal("text/plain", fileContent.MimeType); - Assert.Equal("SGVsbG8gV29ybGQ=", fileContent.Bytes); - } - - [Fact] - public void FileContent_Deserialize_WithoutKind_ShouldSucceed_ForUriContent() - { - // Arrange: JSON according to A2A spec (without "kind" property) - const string json = """ - { - "name": "example.txt", - "mimeType": "text/plain", - "uri": "https://example.com/file.txt" - } - """; - - // Act & Assert: This should work according to A2A spec - var fileContent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - - Assert.NotNull(fileContent); - Assert.Equal("example.txt", fileContent.Name); - Assert.Equal("text/plain", fileContent.MimeType); - Assert.NotNull(fileContent.Uri); - Assert.Equal("https://example.com/file.txt", fileContent.Uri.ToString()); - } - - [Fact] - public void FileContent_Serialize_ShouldNotIncludeKind() - { - // Arrange - var fileWithBytes = new FileContent("SGVsbG8=") - { - Name = "test.txt", - MimeType = "text/plain", - }; - - // Act - var json = JsonSerializer.Serialize(fileWithBytes, A2AJsonUtilities.DefaultOptions); - - // Assert: Should not contain "kind" property - Assert.DoesNotContain("\"kind\"", json); - Assert.Contains("\"bytes\"", json); - Assert.Contains("\"name\"", json); - Assert.Contains("\"mimeType\"", json); - } - - [Fact] - public void FileContent_Serialize_UriContent_ShouldNotIncludeKind() - { - // Arrange - var fileWithUri = new FileContent(new Uri("https://example.com/test.txt")) - { - Name = "test.txt", - MimeType = "text/plain", - }; - - // Act - var json = JsonSerializer.Serialize(fileWithUri, A2AJsonUtilities.DefaultOptions); - - // Assert: Should not contain "kind" property - Assert.DoesNotContain("\"kind\"", json); - Assert.Contains("\"uri\"", json); - Assert.Contains("\"name\"", json); - Assert.Contains("\"mimeType\"", json); - } - - [Fact] - public void FileContent_Deserialize_WithBothBytesAndUri_ShouldThrow() - { - // Arrange: Invalid JSON with both bytes and uri - const string json = """ - { - "name": "example.txt", - "mimeType": "text/plain", - "bytes": "SGVsbG8=", - "uri": "https://example.com/file.txt" - } - """; - - // Act & Assert - var ex = Assert.Throws(() => - JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - Assert.Contains("Only one of 'bytes' or 'uri' must be specified", ex.Message); - } - - [Fact] - public void FileContent_Deserialize_WithNeitherBytesNorUri_ShouldThrow() - { - // Arrange: Invalid JSON with neither bytes nor uri - const string json = """ - { - "name": "example.txt", - "mimeType": "text/plain" - } - """; - - // Act & Assert - var ex = Assert.Throws(() => - JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - - Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); - Assert.Contains("must have either 'bytes' or 'uri'", ex.Message); - } - - [Fact] - public void FileContent_RoundTrip_Bytes_ShouldWork() - { - // Arrange - var original = new FileContent("SGVsbG8gV29ybGQ=") - { - Name = "test.txt", - MimeType = "text/plain", - }; - - // Act: Serialize and deserialize - var json = JsonSerializer.Serialize(original, A2AJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Name, deserialized.Name); - Assert.Equal(original.MimeType, deserialized.MimeType); - Assert.Equal(original.Bytes, deserialized.Bytes); - } - - [Fact] - public void FileContent_RoundTrip_Uri_ShouldWork() - { - // Arrange - var original = new FileContent(new Uri("https://example.com/test.txt")) - { - Name = "test.txt", - MimeType = "text/plain", - }; - - // Act: Serialize and deserialize - var json = JsonSerializer.Serialize(original, A2AJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.NotNull(deserialized); - Assert.Equal(original.Name, deserialized.Name); - Assert.Equal(original.MimeType, deserialized.MimeType); - Assert.Equal(original.Uri, deserialized.Uri); - } -} \ No newline at end of file +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class FileContentSpecComplianceTests +{ + [Fact] + public void FileContent_Deserialize_WithoutKind_ShouldSucceed_ForBytesContent() + { + // Arrange: JSON according to A2A spec (without "kind" property) + const string json = """ + { + "name": "example.txt", + "mimeType": "text/plain", + "bytes": "SGVsbG8gV29ybGQ=" + } + """; + + // Act & Assert: This should work according to A2A spec + var fileContent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(fileContent); + Assert.Equal("example.txt", fileContent.Name); + Assert.Equal("text/plain", fileContent.MimeType); + Assert.Equal("SGVsbG8gV29ybGQ=", fileContent.Bytes); + } + + [Fact] + public void FileContent_Deserialize_WithoutKind_ShouldSucceed_ForUriContent() + { + // Arrange: JSON according to A2A spec (without "kind" property) + const string json = """ + { + "name": "example.txt", + "mimeType": "text/plain", + "uri": "https://example.com/file.txt" + } + """; + + // Act & Assert: This should work according to A2A spec + var fileContent = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(fileContent); + Assert.Equal("example.txt", fileContent.Name); + Assert.Equal("text/plain", fileContent.MimeType); + Assert.NotNull(fileContent.Uri); + Assert.Equal("https://example.com/file.txt", fileContent.Uri.ToString()); + } + + [Fact] + public void FileContent_Serialize_ShouldNotIncludeKind() + { + // Arrange + var fileWithBytes = new FileContent("SGVsbG8=") + { + Name = "test.txt", + MimeType = "text/plain", + }; + + // Act + var json = JsonSerializer.Serialize(fileWithBytes, A2AJsonUtilities.DefaultOptions); + + // Assert: Should not contain "kind" property + Assert.DoesNotContain("\"kind\"", json); + Assert.Contains("\"bytes\"", json); + Assert.Contains("\"name\"", json); + Assert.Contains("\"mimeType\"", json); + } + + [Fact] + public void FileContent_Serialize_UriContent_ShouldNotIncludeKind() + { + // Arrange + var fileWithUri = new FileContent(new Uri("https://example.com/test.txt")) + { + Name = "test.txt", + MimeType = "text/plain", + }; + + // Act + var json = JsonSerializer.Serialize(fileWithUri, A2AJsonUtilities.DefaultOptions); + + // Assert: Should not contain "kind" property + Assert.DoesNotContain("\"kind\"", json); + Assert.Contains("\"uri\"", json); + Assert.Contains("\"name\"", json); + Assert.Contains("\"mimeType\"", json); + } + + [Fact] + public void FileContent_Deserialize_WithBothBytesAndUri_ShouldThrow() + { + // Arrange: Invalid JSON with both bytes and uri + const string json = """ + { + "name": "example.txt", + "mimeType": "text/plain", + "bytes": "SGVsbG8=", + "uri": "https://example.com/file.txt" + } + """; + + // Act & Assert + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + Assert.Contains("Only one of 'bytes' or 'uri' must be specified", ex.Message); + } + + [Fact] + public void FileContent_Deserialize_WithNeitherBytesNorUri_ShouldThrow() + { + // Arrange: Invalid JSON with neither bytes nor uri + const string json = """ + { + "name": "example.txt", + "mimeType": "text/plain" + } + """; + + // Act & Assert + var ex = Assert.Throws(() => + JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + + Assert.Equal(A2AErrorCode.InvalidRequest, ex.ErrorCode); + Assert.Contains("must have either 'bytes' or 'uri'", ex.Message); + } + + [Fact] + public void FileContent_RoundTrip_Bytes_ShouldWork() + { + // Arrange + var original = new FileContent("SGVsbG8gV29ybGQ=") + { + Name = "test.txt", + MimeType = "text/plain", + }; + + // Act: Serialize and deserialize + var json = JsonSerializer.Serialize(original, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Bytes, deserialized.Bytes); + } + + [Fact] + public void FileContent_RoundTrip_Uri_ShouldWork() + { + // Arrange + var original = new FileContent(new Uri("https://example.com/test.txt")) + { + Name = "test.txt", + MimeType = "text/plain", + }; + + // Act: Serialize and deserialize + var json = JsonSerializer.Serialize(original, A2AJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.MimeType, deserialized.MimeType); + Assert.Equal(original.Uri, deserialized.Uri); + } +} diff --git a/tests/A2A.UnitTests/Models/MessageSendParamsTests.cs b/tests/A2A.V0_3.UnitTests/Models/MessageSendParamsTests.cs similarity index 95% rename from tests/A2A.UnitTests/Models/MessageSendParamsTests.cs rename to tests/A2A.V0_3.UnitTests/Models/MessageSendParamsTests.cs index e34bc4ad..9ac99126 100644 --- a/tests/A2A.UnitTests/Models/MessageSendParamsTests.cs +++ b/tests/A2A.V0_3.UnitTests/Models/MessageSendParamsTests.cs @@ -1,104 +1,104 @@ -using System.Text.Json; - -namespace A2A.UnitTests.Models -{ - public sealed class MessageSendParamsTests - { - [Theory] - [InlineData("task")] - [InlineData("foo")] - public void MessageSendParams_Deserialize_InvalidKind_Throws(string invalidKind) - { - // Arrange - var json = $$""" - { - "message": { - "kind": "{{invalidKind}}", - "id": "t-13", - "contextId": "c-13", - "status": { "state": "submitted" } - } - } - """; - - // Act / Assert - var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); - } - - [Fact] - public void MessageSendParams_Serialized_HasKindOnMessage() - { - // Arrange - var msp = new MessageSendParams - { - Message = new AgentMessage - { - Role = MessageRole.User, - MessageId = "m-8", - Parts = [new TextPart { Text = "hello" }] - } - }; - - var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); - - Assert.Contains("\"kind\":\"message\"", serialized); - } - - [Fact] - public void MessageSendParams_SerializationRoundTrip_Succeeds() - { - // Arrange - var msp = new MessageSendParams - { - Message = new AgentMessage - { - Role = MessageRole.User, - MessageId = "m-8", - Parts = [new TextPart { Text = "hello" }] - } - }; - - var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); - - var deserialized = JsonSerializer.Deserialize(serialized, A2AJsonUtilities.DefaultOptions); - - Assert.NotNull(deserialized); - Assert.Equal(msp.Message.Role, deserialized.Message.Role); - Assert.Equal(msp.Message.MessageId, deserialized.Message.MessageId); - Assert.NotNull(deserialized.Message.Parts); - Assert.Single(deserialized.Message.Parts); - var part = Assert.IsType(deserialized?.Message.Parts[0]); - Assert.Equal("hello", part.Text); - } - - [Fact] - public void MessageSendConfiguration_Serialized_UsesPushNotificationConfigPropertyName() - { - // Arrange - var msp = new MessageSendParams - { - Message = new AgentMessage - { - Role = MessageRole.User, - MessageId = "m-8", - Parts = [new TextPart { Text = "hello" }] - }, - Configuration = new MessageSendConfiguration - { - AcceptedOutputModes = ["text"], - PushNotification = new PushNotificationConfig - { - Url = "https://example.com/webhook" - } - } - }; - - // Act - var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); - - // Assert - Assert.Contains("\"pushNotificationConfig\"", serialized); - Assert.DoesNotContain("\"pushNotification\":", serialized); - } - } -} +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models +{ + public sealed class MessageSendParamsTests + { + [Theory] + [InlineData("task")] + [InlineData("foo")] + public void MessageSendParams_Deserialize_InvalidKind_Throws(string invalidKind) + { + // Arrange + var json = $$""" + { + "message": { + "kind": "{{invalidKind}}", + "id": "t-13", + "contextId": "c-13", + "status": { "state": "submitted" } + } + } + """; + + // Act / Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, A2AJsonUtilities.DefaultOptions)); + } + + [Fact] + public void MessageSendParams_Serialized_HasKindOnMessage() + { + // Arrange + var msp = new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-8", + Parts = [new TextPart { Text = "hello" }] + } + }; + + var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); + + Assert.Contains("\"kind\":\"message\"", serialized); + } + + [Fact] + public void MessageSendParams_SerializationRoundTrip_Succeeds() + { + // Arrange + var msp = new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-8", + Parts = [new TextPart { Text = "hello" }] + } + }; + + var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); + + var deserialized = JsonSerializer.Deserialize(serialized, A2AJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(msp.Message.Role, deserialized.Message.Role); + Assert.Equal(msp.Message.MessageId, deserialized.Message.MessageId); + Assert.NotNull(deserialized.Message.Parts); + Assert.Single(deserialized.Message.Parts); + var part = Assert.IsType(deserialized?.Message.Parts[0]); + Assert.Equal("hello", part.Text); + } + + [Fact] + public void MessageSendConfiguration_Serialized_UsesPushNotificationConfigPropertyName() + { + // Arrange + var msp = new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + MessageId = "m-8", + Parts = [new TextPart { Text = "hello" }] + }, + Configuration = new MessageSendConfiguration + { + AcceptedOutputModes = ["text"], + PushNotification = new PushNotificationConfig + { + Url = "https://example.com/webhook" + } + } + }; + + // Act + var serialized = JsonSerializer.Serialize(msp, A2AJsonUtilities.DefaultOptions); + + // Assert + Assert.Contains("\"pushNotificationConfig\"", serialized); + Assert.DoesNotContain("\"pushNotification\":", serialized); + } + } +} diff --git a/tests/A2A.V0_3.UnitTests/Models/SecuritySchemeTests.cs b/tests/A2A.V0_3.UnitTests/Models/SecuritySchemeTests.cs new file mode 100644 index 00000000..4897f632 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Models/SecuritySchemeTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json; + +namespace A2A.V0_3.UnitTests.Models; + +public class SecuritySchemeTests +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public void SecurityScheme_DescriptionProperty_SerializesCorrectly() + { + // Arrange + SecurityScheme scheme = new ApiKeySecurityScheme("X-API-Key", "header"); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions) as ApiKeySecurityScheme; + + // Assert + Assert.Contains("\"type\": \"apiKey\"", json); + Assert.Contains("\"description\": \"API key for authentication\"", json); + Assert.NotNull(deserialized); + Assert.Equal("API key for authentication", deserialized.Description); + } + + [Fact] + public void SecurityScheme_DescriptionProperty_CanBeNull() + { + // Arrange + SecurityScheme scheme = new HttpAuthSecurityScheme("bearer", null); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions) as HttpAuthSecurityScheme; + + // Assert + Assert.DoesNotContain("\"description\"", json); + Assert.Contains("\"type\": \"http\"", json); + Assert.NotNull(deserialized); + Assert.Null(deserialized.Description); + } + + [Fact] + public void ApiKeySecurityScheme_SerializesAndDeserializesCorrectly() + { + // Arrange + SecurityScheme scheme = new ApiKeySecurityScheme("X-API-Key", "header"); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var d = JsonSerializer.Deserialize(json, s_jsonOptions); + + // Assert + Assert.Contains("\"type\": \"apiKey\"", json); + Assert.Contains("\"description\":", json); + + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("API key for authentication", deserialized.Description); + Assert.Equal("X-API-Key", deserialized.Name); + Assert.Equal("header", deserialized.KeyLocation); + } + + [Fact] + public void HttpAuthSecurityScheme_SerializesAndDeserializesCorrectly() + { + // Arrange + SecurityScheme scheme = new HttpAuthSecurityScheme("bearer"); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var d = JsonSerializer.Deserialize(json, s_jsonOptions); + + // Assert + Assert.Contains("\"type\": \"http\"", json); + Assert.DoesNotContain("\"description\"", json); + + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("bearer", deserialized.Scheme); + Assert.Null(deserialized.Description); + } + + [Fact] + public void OAuth2SecurityScheme_SerializesAndDeserializesCorrectly() + { + // Arrange + var flows = new OAuthFlows + { + Password = new(new("https://example.com/token"), scopes: new Dictionary() { ["read"] = "Read access", ["write"] = "Write access" }), + }; + SecurityScheme scheme = new OAuth2SecurityScheme(flows, "OAuth2 authentication"); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var d = JsonSerializer.Deserialize(json, s_jsonOptions); + + // Assert + Assert.Contains("\"description\": \"OAuth2 authentication\"", json); + + var deserialized = Assert.IsType(d); Assert.Contains("\"type\": \"oauth2\"", json); + Assert.NotNull(deserialized); + Assert.Equal("OAuth2 authentication", deserialized.Description); + Assert.NotNull(deserialized.Flows); + Assert.Null(deserialized.Flows.ClientCredentials); + Assert.Null(deserialized.Flows.Implicit); + Assert.Null(deserialized.Flows.AuthorizationCode); + Assert.NotNull(deserialized.Flows.Password); + Assert.Equal("https://example.com/token", deserialized.Flows.Password.TokenUrl.ToString()); + Assert.NotNull(deserialized.Flows.Password.Scopes); + Assert.Equal(2, deserialized.Flows.Password.Scopes.Count); + Assert.Contains("read", deserialized.Flows.Password.Scopes.Keys); + Assert.Contains("write", deserialized.Flows.Password.Scopes.Keys); + Assert.Equal("Read access", deserialized.Flows.Password.Scopes["read"]); + Assert.Equal("Write access", deserialized.Flows.Password.Scopes["write"]); + } + + [Fact] + public void OAuth2SecurityScheme_DeserializesFromRawJsonCorrectly() + { + // Arrange + var rawJson = """ + { + "type": "oauth2", + "description": "OAuth2 authentication", + "flows": { + "password": { + "tokenUrl": "https://example.com/token", + "scopes": { + "read": "Read access", + "write": "Write access" + } + } + } + } + """; + + // Act + var d = JsonSerializer.Deserialize(rawJson, s_jsonOptions); + + // Assert + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("OAuth2 authentication", deserialized.Description); + Assert.NotNull(deserialized.Flows); + Assert.Null(deserialized.Flows.ClientCredentials); + Assert.Null(deserialized.Flows.Implicit); + Assert.Null(deserialized.Flows.AuthorizationCode); + Assert.NotNull(deserialized.Flows.Password); + Assert.Equal("https://example.com/token", deserialized.Flows.Password.TokenUrl.ToString()); + Assert.NotNull(deserialized.Flows.Password.Scopes); + Assert.Equal(2, deserialized.Flows.Password.Scopes.Count); + Assert.Contains("read", deserialized.Flows.Password.Scopes.Keys); + Assert.Contains("write", deserialized.Flows.Password.Scopes.Keys); + Assert.Equal("Read access", deserialized.Flows.Password.Scopes["read"]); + Assert.Equal("Write access", deserialized.Flows.Password.Scopes["write"]); + } + + [Fact] + public void OpenIdConnectSecurityScheme_SerializesAndDeserializesCorrectly() + { + // Arrange + SecurityScheme scheme = new OpenIdConnectSecurityScheme(new("https://example.com/.well-known/openid_configuration"), "OpenID Connect authentication"); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var d = JsonSerializer.Deserialize(json, s_jsonOptions); + + // Assert + Assert.Contains("\"type\": \"openIdConnect\"", json); + Assert.Contains("\"description\": \"OpenID Connect authentication\"", json); + + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("OpenID Connect authentication", deserialized.Description); + Assert.Equal("https://example.com/.well-known/openid_configuration", deserialized.OpenIdConnectUrl.ToString()); + } + + [Fact] + public void MutualTlsSecurityScheme_DeserializesFromBaseSecurityScheme() + { + // Arrange + SecurityScheme scheme = new MutualTlsSecurityScheme(); + + // Act + var json = JsonSerializer.Serialize(scheme, s_jsonOptions); + var d = JsonSerializer.Deserialize(json, s_jsonOptions); + + // Assert + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("Mutual TLS authentication", deserialized.Description); + } + + [Fact] + public void OpenIdConnectSecurityScheme_DeserializesFromRawJsonCorrectly() + { + // Arrange + var rawJson = """ + { + "type": "openIdConnect", + "description": "OpenID Connect authentication", + "openIdConnectUrl": "https://example.com/.well-known/openid_configuration" + } + """; + + // Act + var d = JsonSerializer.Deserialize(rawJson, s_jsonOptions); + + // Assert + var deserialized = Assert.IsType(d); + Assert.NotNull(deserialized); + Assert.Equal("OpenID Connect authentication", deserialized.Description); + Assert.Equal("https://example.com/.well-known/openid_configuration", deserialized.OpenIdConnectUrl.ToString()); + } +} diff --git a/tests/A2A.V0_3.UnitTests/ParsingTests.cs b/tests/A2A.V0_3.UnitTests/ParsingTests.cs new file mode 100644 index 00000000..cd0056f3 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/ParsingTests.cs @@ -0,0 +1,169 @@ +using System.Text; +using System.Text.Json; + +namespace A2A.V0_3.UnitTests; + +public class ParsingTests +{ + [Fact] + public void RoundTripTaskSendParams() + { + // Arrange + var taskSendParams = new MessageSendParams + { + Message = new AgentMessage() + { + Parts = + [ + new TextPart() + { + Text = "Hello, World!", + } + ], + }, + }; + var json = JsonSerializer.Serialize(taskSendParams); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var deserializedParams = JsonSerializer.Deserialize(stream); + + // Act + var result = deserializedParams; + + // Assert + Assert.NotNull(result); + Assert.Equal(((TextPart)taskSendParams.Message.Parts[0]).Text, ((TextPart)result.Message.Parts[0]).Text); + } + + [Fact] + public void JsonRpcTaskSend() + { + // Arrange + var taskSendParams = new MessageSendParams + { + Message = new AgentMessage() + { + Parts = + [ + new TextPart() + { + Text = "Hello, World!", + } + ], + }, + }; + var jsonRpcRequest = new JsonRpcRequest + { + Method = A2AMethods.MessageSend, + Params = JsonSerializer.SerializeToElement(taskSendParams), + }; + var json = JsonSerializer.Serialize(jsonRpcRequest); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var deserializedRequest = JsonSerializer.Deserialize(stream); + + // Act + var result = deserializedRequest?.Params?.Deserialize(); + + // Assert + Assert.NotNull(result); + Assert.Equal(((TextPart)taskSendParams.Message.Parts[0]).Text, ((TextPart)result.Message.Parts[0]).Text); + } + + [Fact] + public void RoundTripTaskStatusUpdateEvent() + { + // Arrange + var taskStatusUpdateEvent = new TaskStatusUpdateEvent + { + TaskId = "test-task", + ContextId = "test-session", + Status = new AgentTaskStatus + { + State = TaskState.Working, + } + }; + var json = JsonSerializer.Serialize(taskStatusUpdateEvent); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var deserializedEvent = JsonSerializer.Deserialize(stream); + + // Act + var result = deserializedEvent; + + // Assert + Assert.NotNull(result); + Assert.Equal(taskStatusUpdateEvent.TaskId, result.TaskId); + Assert.Equal(taskStatusUpdateEvent.ContextId, result.ContextId); + Assert.Equal(taskStatusUpdateEvent.Status.State, result.Status.State); + } + + [Fact] + public void RoundTripArtifactUpdateEvent() + { + // Arrange + var taskArtifactUpdateEvent = new TaskArtifactUpdateEvent + { + TaskId = "test-task", + ContextId = "test-session", + Artifact = new Artifact + { + Parts = + [ + new TextPart + { + Text = "Hello, World!", + } + ], + } + }; + var json = JsonSerializer.Serialize(taskArtifactUpdateEvent); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + // Deserialize using the base class + // This is important to ensure polymorphic deserialization works correctly + var deserializedEvent = JsonSerializer.Deserialize(stream); + + // Act + var result = deserializedEvent; + + // Assert + Assert.NotNull(result); + Assert.Equal(taskArtifactUpdateEvent.TaskId, result.TaskId); + Assert.Equal(taskArtifactUpdateEvent.ContextId, result.ContextId); + Assert.Equal(taskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text, result.Artifact.Parts[0].AsTextPart().Text); + } + + [Fact] + public void RoundTripJsonRpcResponseWithArtifactUpdateStatus() + { + // Arrange + var taskArtifactUpdateEvent = new TaskArtifactUpdateEvent + { + TaskId = "test-task", + ContextId = "test-session", + Artifact = new Artifact + { + Parts = + [ + new TextPart + { + Text = "Hello, World!", + } + ], + } + }; + var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("test-id", taskArtifactUpdateEvent); + var json = JsonSerializer.Serialize(jsonRpcResponse); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var deserializedResponse = JsonSerializer.Deserialize(stream); + // Deserialize using the base class + // This is important to ensure polymorphic deserialization works correctly + var resultObject = (deserializedResponse?.Result).Deserialize(); + // Act + + // Assert + Assert.NotNull(resultObject); + var resultTaskArtifactUpdateEvent = resultObject as TaskArtifactUpdateEvent; + Assert.NotNull(resultTaskArtifactUpdateEvent); + Assert.Equal(taskArtifactUpdateEvent.TaskId, resultTaskArtifactUpdateEvent.TaskId); + Assert.Equal(taskArtifactUpdateEvent.ContextId, resultTaskArtifactUpdateEvent.ContextId); + Assert.Equal(taskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text, resultTaskArtifactUpdateEvent.Artifact.Parts[0].AsTextPart().Text); + } +} diff --git a/tests/A2A.V0_3.UnitTests/Properties/AssemblyInfo.cs b/tests/A2A.V0_3.UnitTests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..4bd58466 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: ExcludeFromCodeCoverage] diff --git a/tests/A2A.AspNetCore.UnitTests/DistributedCacheTaskStoreTests.cs b/tests/A2A.V0_3.UnitTests/Server/InMemoryTaskStoreTests.cs similarity index 71% rename from tests/A2A.AspNetCore.UnitTests/DistributedCacheTaskStoreTests.cs rename to tests/A2A.V0_3.UnitTests/Server/InMemoryTaskStoreTests.cs index f21fcf52..b870bdec 100644 --- a/tests/A2A.AspNetCore.UnitTests/DistributedCacheTaskStoreTests.cs +++ b/tests/A2A.V0_3.UnitTests/Server/InMemoryTaskStoreTests.cs @@ -1,332 +1,283 @@ -using A2A.AspNetCore.Caching; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; - -namespace A2A.UnitTests.Server; - -public class DistributedCacheTaskStoreTests -{ - [Fact] - public async Task SetTaskAsync_And_GetTaskAsync_ShouldStoreAndRetrieveTask() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var task = new AgentTask { Id = "task1", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - - // Act - await sut.SetTaskAsync(task); - var result = await sut.GetTaskAsync("task1"); - - // Assert - Assert.NotNull(result); - Assert.Equal("task1", result!.Id); - Assert.Equal(TaskState.Submitted, result.Status.State); - } - - [Fact] - public async Task GetTaskAsync_ShouldReturnNull_WhenTaskDoesNotExist() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act - var result = await sut.GetTaskAsync("nonexistent"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetTaskAsync_ShouldThrowArgumentException_WhenTaskIdIsNullOrEmpty() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act - var task = sut.GetTaskAsync(string.Empty); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldUpdateTaskStatus() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var task = new AgentTask { Id = "task2", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - await sut.SetTaskAsync(task); - var message = new AgentMessage { MessageId = "msg1" }; - - // Act - var status = await sut.UpdateStatusAsync("task2", TaskState.Working, message); - var updatedTask = await sut.GetTaskAsync("task2"); - - // Assert - Assert.Equal(TaskState.Working, status.State); - Assert.Equal(TaskState.Working, updatedTask!.Status.State); - Assert.Equal("msg1", status.Message!.MessageId); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldThrow_WhenTaskDoesNotExist() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act & Assert - var x = await Assert.ThrowsAsync(() => sut.UpdateStatusAsync("notfound", TaskState.Completed)); - Assert.Equal(A2AErrorCode.TaskNotFound, x.ErrorCode); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnNull_WhenTaskDoesNotExist() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act - var result = await sut.GetPushNotificationAsync("missing", "config-missing"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnNull_WhenConfigDoesNotExist() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - await sut.SetPushNotificationConfigAsync(new TaskPushNotificationConfig { TaskId = "task-id", PushNotificationConfig = new() { Id = "config-id" } }); - - // Act - var result = await sut.GetPushNotificationAsync("task-id", "config-missing"); - - // Assert - Assert.Null(result); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnCorrectConfig_WhenMultipleConfigsExistForSameTask() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var taskId = "task-with-multiple-configs"; - - var config1 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config1", - Id = "config-id-1", - Token = "token1" - } - }; - - var config2 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config2", - Id = "config-id-2", - Token = "token2" - } - }; - - var config3 = new TaskPushNotificationConfig - { - TaskId = taskId, - PushNotificationConfig = new PushNotificationConfig - { - Url = "http://config3", - Id = "config-id-3", - Token = "token3" - } - }; - - // Act - Store multiple configs for the same task - await sut.SetPushNotificationConfigAsync(config1); - await sut.SetPushNotificationConfigAsync(config2); - await sut.SetPushNotificationConfigAsync(config3); - - // Get specific configs by both taskId and notificationConfigId - var result1 = await sut.GetPushNotificationAsync(taskId, "config-id-1"); - var result2 = await sut.GetPushNotificationAsync(taskId, "config-id-2"); - var result3 = await sut.GetPushNotificationAsync(taskId, "config-id-3"); - var resultNotFound = await sut.GetPushNotificationAsync(taskId, "non-existent-config"); - - // Assert - Verify each call returns the correct specific config - Assert.NotNull(result1); - Assert.Equal(taskId, result1!.TaskId); - Assert.Equal("config-id-1", result1.PushNotificationConfig.Id); - Assert.Equal("http://config1", result1.PushNotificationConfig.Url); - Assert.Equal("token1", result1.PushNotificationConfig.Token); - - Assert.NotNull(result2); - Assert.Equal(taskId, result2!.TaskId); - Assert.Equal("config-id-2", result2.PushNotificationConfig.Id); - Assert.Equal("http://config2", result2.PushNotificationConfig.Url); - Assert.Equal("token2", result2.PushNotificationConfig.Token); - - Assert.NotNull(result3); - Assert.Equal(taskId, result3!.TaskId); - Assert.Equal("config-id-3", result3.PushNotificationConfig.Id); - Assert.Equal("http://config3", result3.PushNotificationConfig.Url); - Assert.Equal("token3", result3.PushNotificationConfig.Token); - - Assert.Null(resultNotFound); - } - - [Fact] - public async Task GetPushNotificationsAsync_ShouldReturnEmptyList_WhenNoConfigsExistForTask() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act - var result = await sut.GetPushNotificationsAsync("task-without-configs"); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - } - - [Fact] - public async Task GetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetTaskAsync("test-id", cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task GetPushNotificationAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetPushNotificationAsync("test-id", "config-id", cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task UpdateStatusAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.UpdateStatusAsync("test-id", TaskState.Working, cancellationToken: cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var agentTask = new AgentTask { Id = "test-id", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.SetTaskAsync(agentTask, cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetPushNotificationConfigAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var config = new TaskPushNotificationConfig(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.SetPushNotificationConfigAsync(config, cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetPushNotificationConfigAsync_ShouldThrowArgumentNullException_WhenConfigIsNull() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - // Act - var task = sut.SetPushNotificationConfigAsync(null!); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task SetPushNotificationConfigAsync_ShouldThrowArgumentException_WhenTaskIdIsNullOrEmpty() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - var config = new TaskPushNotificationConfig - { - TaskId = string.Empty, - PushNotificationConfig = new PushNotificationConfig { Id = "config-id" } - }; - - // Act - var task = sut.SetPushNotificationConfigAsync(config); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - [Fact] - public async Task GetPushNotificationsAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() - { - // Arrange - var sut = BuildDistributedCacheTaskStore(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act - var task = sut.GetPushNotificationsAsync("test-id", cts.Token); - - // Assert - await Assert.ThrowsAsync(() => task); - } - - static DistributedCacheTaskStore BuildDistributedCacheTaskStore() - { - var memoryCache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); - return new DistributedCacheTaskStore(memoryCache); - } -} \ No newline at end of file +namespace A2A.V0_3.UnitTests.Server; + +public class InMemoryTaskStoreTests +{ + [Fact] + public async Task SetTaskAsync_And_GetTaskAsync_ShouldStoreAndRetrieveTask() + { + // Arrange + var sut = new InMemoryTaskStore(); + var task = new AgentTask { Id = "task1", Status = new AgentTaskStatus { State = TaskState.Submitted } }; + + // Act + await sut.SetTaskAsync(task); + var result = await sut.GetTaskAsync("task1"); + + // Assert + Assert.NotNull(result); + Assert.Equal("task1", result!.Id); + Assert.Equal(TaskState.Submitted, result.Status.State); + } + + [Fact] + public async Task GetTaskAsync_ShouldReturnNull_WhenTaskDoesNotExist() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act + var result = await sut.GetTaskAsync("nonexistent"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task UpdateStatusAsync_ShouldUpdateTaskStatus() + { + // Arrange + var sut = new InMemoryTaskStore(); + var task = new AgentTask { Id = "task2", Status = new AgentTaskStatus { State = TaskState.Submitted } }; + await sut.SetTaskAsync(task); + var message = new AgentMessage { MessageId = "msg1" }; + + // Act + var status = await sut.UpdateStatusAsync("task2", TaskState.Working, message); + var updatedTask = await sut.GetTaskAsync("task2"); + + // Assert + Assert.Equal(TaskState.Working, status.State); + Assert.Equal(TaskState.Working, updatedTask!.Status.State); + Assert.Equal("msg1", status.Message!.MessageId); + } + + [Fact] + public async Task UpdateStatusAsync_ShouldThrow_WhenTaskDoesNotExist() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => sut.UpdateStatusAsync("notfound", TaskState.Completed)); + Assert.Equal(A2AErrorCode.TaskNotFound, ex.ErrorCode); + } + + [Fact] + public async Task GetPushNotificationAsync_ShouldReturnNull_WhenTaskDoesNotExist() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act + var result = await sut.GetPushNotificationAsync("missing", "config-missing"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPushNotificationAsync_ShouldReturnNull_WhenConfigDoesNotExist() + { + // Arrange + var sut = new InMemoryTaskStore(); + + await sut.SetPushNotificationConfigAsync(new TaskPushNotificationConfig { TaskId = "task-id", PushNotificationConfig = new() { Id = "config-id" } }); + + // Act + var result = await sut.GetPushNotificationAsync("task-id", "config-missing"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPushNotificationAsync_ShouldReturnCorrectConfig_WhenMultipleConfigsExistForSameTask() + { + // Arrange + var sut = new InMemoryTaskStore(); + var taskId = "task-with-multiple-configs"; + + var config1 = new TaskPushNotificationConfig + { + TaskId = taskId, + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://config1", + Id = "config-id-1", + Token = "token1" + } + }; + + var config2 = new TaskPushNotificationConfig + { + TaskId = taskId, + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://config2", + Id = "config-id-2", + Token = "token2" + } + }; + + var config3 = new TaskPushNotificationConfig + { + TaskId = taskId, + PushNotificationConfig = new PushNotificationConfig + { + Url = "http://config3", + Id = "config-id-3", + Token = "token3" + } + }; + + // Act - Store multiple configs for the same task + await sut.SetPushNotificationConfigAsync(config1); + await sut.SetPushNotificationConfigAsync(config2); + await sut.SetPushNotificationConfigAsync(config3); + + // Get specific configs by both taskId and notificationConfigId + var result1 = await sut.GetPushNotificationAsync(taskId, "config-id-1"); + var result2 = await sut.GetPushNotificationAsync(taskId, "config-id-2"); + var result3 = await sut.GetPushNotificationAsync(taskId, "config-id-3"); + var resultNotFound = await sut.GetPushNotificationAsync(taskId, "non-existent-config"); + + // Assert - Verify each call returns the correct specific config + Assert.NotNull(result1); + Assert.Equal(taskId, result1!.TaskId); + Assert.Equal("config-id-1", result1.PushNotificationConfig.Id); + Assert.Equal("http://config1", result1.PushNotificationConfig.Url); + Assert.Equal("token1", result1.PushNotificationConfig.Token); + + Assert.NotNull(result2); + Assert.Equal(taskId, result2!.TaskId); + Assert.Equal("config-id-2", result2.PushNotificationConfig.Id); + Assert.Equal("http://config2", result2.PushNotificationConfig.Url); + Assert.Equal("token2", result2.PushNotificationConfig.Token); + + Assert.NotNull(result3); + Assert.Equal(taskId, result3!.TaskId); + Assert.Equal("config-id-3", result3.PushNotificationConfig.Id); + Assert.Equal("http://config3", result3.PushNotificationConfig.Url); + Assert.Equal("token3", result3.PushNotificationConfig.Token); + + Assert.Null(resultNotFound); + } + + [Fact] + public async Task GetPushNotificationsAsync_ShouldReturnEmptyList_WhenNoConfigsExistForTask() + { + // Arrange + var sut = new InMemoryTaskStore(); + + // Act + var result = await sut.GetPushNotificationsAsync("task-without-configs"); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.GetTaskAsync("test-id", cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task GetPushNotificationAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.GetPushNotificationAsync("test-id", "config-id", cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task UpdateStatusAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.UpdateStatusAsync("test-id", TaskState.Working, cancellationToken: cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task SetTaskAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + var agentTask = new AgentTask { Id = "test-id", Status = new AgentTaskStatus { State = TaskState.Submitted } }; + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.SetTaskAsync(agentTask, cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task SetPushNotificationConfigAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + var config = new TaskPushNotificationConfig(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.SetPushNotificationConfigAsync(config, cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } + + [Fact] + public async Task GetPushNotificationsAsync_ShouldReturnCanceledTask_WhenCancellationTokenIsCanceled() + { + // Arrange + var sut = new InMemoryTaskStore(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act + var task = sut.GetPushNotificationsAsync("test-id", cts.Token); + + // Assert + Assert.True(task.IsCanceled); + await Assert.ThrowsAsync(() => task); + } +} diff --git a/tests/A2A.UnitTests/Server/TaskManagerTests.cs b/tests/A2A.V0_3.UnitTests/Server/TaskManagerTests.cs similarity index 97% rename from tests/A2A.UnitTests/Server/TaskManagerTests.cs rename to tests/A2A.V0_3.UnitTests/Server/TaskManagerTests.cs index a0244ff8..94dd1a0e 100644 --- a/tests/A2A.UnitTests/Server/TaskManagerTests.cs +++ b/tests/A2A.V0_3.UnitTests/Server/TaskManagerTests.cs @@ -1,4 +1,4 @@ -namespace A2A.UnitTests.Server; +namespace A2A.V0_3.UnitTests.Server; public class TaskManagerTests { @@ -40,10 +40,10 @@ public async Task OnMessageReceivedCanReturnTaskOrMessage(bool stream) }; }; - if (stream) - { - Assert.IsType(await taskManager.SendMessageStreamingAsync(firstMessage).SingleAsync()); - Assert.IsType(await taskManager.SendMessageStreamingAsync(secondMessage).SingleAsync()); + if (stream) + { + Assert.IsType(await taskManager.SendMessageStreamingAsync(firstMessage).SingleAsync()); + Assert.IsType(await taskManager.SendMessageStreamingAsync(secondMessage).SingleAsync()); } else { @@ -197,9 +197,9 @@ public async Task CreateSendSubscribeTask() taskManager.OnTaskCreated = async (task, ct) => { await taskManager.UpdateStatusAsync(task.Id, TaskState.Working, final: true, cancellationToken: ct); - }; - - var taskSendParams = CreateMessageSendParams("Hello, World!"); + }; + + var taskSendParams = CreateMessageSendParams("Hello, World!"); var taskEvents = taskManager.SendMessageStreamingAsync(taskSendParams); var taskCount = 0; await foreach (var taskEvent in taskEvents) @@ -216,10 +216,10 @@ public async Task EnsureTaskIsFirstReturnedEventFromMessageStream() taskManager.OnTaskCreated = async (task, ct) => { await taskManager.UpdateStatusAsync(task.Id, TaskState.Working, final: true, cancellationToken: ct); - }; - - var taskSendParams = CreateMessageSendParams("Hello, World!"); - var taskEvents = taskManager.SendMessageStreamingAsync(taskSendParams); + }; + + var taskSendParams = CreateMessageSendParams("Hello, World!"); + var taskEvents = taskManager.SendMessageStreamingAsync(taskSendParams); var isFirstEvent = true; await foreach (var taskEvent in taskEvents) @@ -359,10 +359,10 @@ public async Task SubscribeToTaskAsync_ReturnsEnumerator_WhenTaskExists() var events = new List(); var processorStarted = new TaskCompletionSource(); - var processor = Task.Run(async () => - { - await foreach (var i in sut.SendMessageStreamingAsync(sendParams)) - { + var processor = Task.Run(async () => + { + await foreach (var i in sut.SendMessageStreamingAsync(sendParams)) + { events.Add(i); if (events.Count is 1) { @@ -639,7 +639,7 @@ public async Task SendMessageAsync_ShouldThrowOperationCanceledException_WhenCan await Assert.ThrowsAsync(() => taskManager.SendMessageAsync(messageSendParams, cts.Token)); } - [Fact] + [Fact] public async Task SendMessageStreamingAsync_ShouldThrowOperationCanceledException_WhenCancellationTokenIsCanceled() { // Arrange @@ -649,7 +649,7 @@ public async Task SendMessageStreamingAsync_ShouldThrowOperationCanceledExceptio using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - // Act & Assert + // Act & Assert await Assert.ThrowsAsync(() => taskManager.SendMessageStreamingAsync(messageSendParams, cts.Token).ToArrayAsync().AsTask()); } @@ -706,121 +706,121 @@ public async Task UpdateStatusAsync_ShouldThrowOperationCanceledException_WhenCa // Act & Assert await Assert.ThrowsAsync(() => taskManager.UpdateStatusAsync("test-id", TaskState.Working, cancellationToken: cts.Token)); - } - - [Fact] - public async Task ReturnArtifactAsync_ShouldThrowOperationCanceledException_WhenCancellationTokenIsCanceled() - { - // Arrange - var taskManager = new TaskManager(); - var artifact = new Artifact(); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - // Act & Assert - await Assert.ThrowsAsync(() => taskManager.ReturnArtifactAsync("test-id", artifact, cts.Token)); - } - - [Fact] - public async Task SendMessageAsync_ShouldThrowA2AException_WhenTaskIdSpecifiedButTaskDoesNotExist() - { - // Arrange - var taskManager = new TaskManager(); - var messageSendParams = new MessageSendParams - { - Message = new AgentMessage - { - TaskId = "non-existent-task-id", - Parts = [new TextPart { Text = "Hello, World!" }] - } - }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => taskManager.SendMessageAsync(messageSendParams)); - Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); - } - - [Fact] - public async Task SendMessageStreamingAsync_ShouldThrowA2AException_WhenTaskIdSpecifiedButTaskDoesNotExist() - { - // Arrange - var taskManager = new TaskManager(); - var messageSendParams = new MessageSendParams - { - Message = new AgentMessage - { - TaskId = "non-existent-task-id", - Parts = [new TextPart { Text = "Hello, World!" }] - } - }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => taskManager.SendMessageStreamingAsync(messageSendParams).ToArrayAsync().AsTask()); - Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); - } - - [Fact] - public async Task SendMessageAsync_ShouldCreateNewTask_WhenNoTaskIdSpecified() - { - // Arrange - var taskManager = new TaskManager(); - var messageSendParams = new MessageSendParams - { - Message = new AgentMessage - { - // No TaskId specified - Parts = [new TextPart { Text = "Hello, World!" }] - } - }; - - // Act - var result = await taskManager.SendMessageAsync(messageSendParams); - - // Assert - var task = Assert.IsType(result); - Assert.NotNull(task.Id); - Assert.NotEmpty(task.Id); - } - - [Fact] - public async Task SendMessageStreamingAsync_ShouldCreateNewTask_WhenNoTaskIdSpecified() - { - // Arrange - var taskManager = new TaskManager(); - var messageSendParams = new MessageSendParams - { - Message = new AgentMessage - { - // No TaskId specified - Parts = [new TextPart { Text = "Hello, World!" }] - } - }; - - // Act - var events = new List(); - await foreach (var evt in taskManager.SendMessageStreamingAsync(messageSendParams)) - { - events.Add(evt); - break; // Just get the first event (which should be the task) - } - - // Assert - Assert.Single(events); - var task = Assert.IsType(events[0]); - Assert.NotNull(task.Id); - Assert.NotEmpty(task.Id); - } - - private static MessageSendParams CreateMessageSendParams(string text) - => new MessageSendParams - { - Message = CreateMessage(text) - }; - - private static AgentMessage CreateMessage(string text) - => new AgentMessage - { - Parts = [new TextPart { Text = text }] + } + + [Fact] + public async Task ReturnArtifactAsync_ShouldThrowOperationCanceledException_WhenCancellationTokenIsCanceled() + { + // Arrange + var taskManager = new TaskManager(); + var artifact = new Artifact(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act & Assert + await Assert.ThrowsAsync(() => taskManager.ReturnArtifactAsync("test-id", artifact, cts.Token)); + } + + [Fact] + public async Task SendMessageAsync_ShouldThrowA2AException_WhenTaskIdSpecifiedButTaskDoesNotExist() + { + // Arrange + var taskManager = new TaskManager(); + var messageSendParams = new MessageSendParams + { + Message = new AgentMessage + { + TaskId = "non-existent-task-id", + Parts = [new TextPart { Text = "Hello, World!" }] + } + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => taskManager.SendMessageAsync(messageSendParams)); + Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); + } + + [Fact] + public async Task SendMessageStreamingAsync_ShouldThrowA2AException_WhenTaskIdSpecifiedButTaskDoesNotExist() + { + // Arrange + var taskManager = new TaskManager(); + var messageSendParams = new MessageSendParams + { + Message = new AgentMessage + { + TaskId = "non-existent-task-id", + Parts = [new TextPart { Text = "Hello, World!" }] + } + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => taskManager.SendMessageStreamingAsync(messageSendParams).ToArrayAsync().AsTask()); + Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); + } + + [Fact] + public async Task SendMessageAsync_ShouldCreateNewTask_WhenNoTaskIdSpecified() + { + // Arrange + var taskManager = new TaskManager(); + var messageSendParams = new MessageSendParams + { + Message = new AgentMessage + { + // No TaskId specified + Parts = [new TextPart { Text = "Hello, World!" }] + } + }; + + // Act + var result = await taskManager.SendMessageAsync(messageSendParams); + + // Assert + var task = Assert.IsType(result); + Assert.NotNull(task.Id); + Assert.NotEmpty(task.Id); + } + + [Fact] + public async Task SendMessageStreamingAsync_ShouldCreateNewTask_WhenNoTaskIdSpecified() + { + // Arrange + var taskManager = new TaskManager(); + var messageSendParams = new MessageSendParams + { + Message = new AgentMessage + { + // No TaskId specified + Parts = [new TextPart { Text = "Hello, World!" }] + } + }; + + // Act + var events = new List(); + await foreach (var evt in taskManager.SendMessageStreamingAsync(messageSendParams)) + { + events.Add(evt); + break; // Just get the first event (which should be the task) + } + + // Assert + Assert.Single(events); + var task = Assert.IsType(events[0]); + Assert.NotNull(task.Id); + Assert.NotEmpty(task.Id); + } + + private static MessageSendParams CreateMessageSendParams(string text) + => new MessageSendParams + { + Message = CreateMessage(text) + }; + + private static AgentMessage CreateMessage(string text) + => new AgentMessage + { + Parts = [new TextPart { Text = text }] }; } diff --git a/tests/A2A.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs b/tests/A2A.V0_3.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs similarity index 95% rename from tests/A2A.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs rename to tests/A2A.V0_3.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs index ab96c781..3769d5e7 100644 --- a/tests/A2A.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs +++ b/tests/A2A.V0_3.UnitTests/Server/TaskUpdateEventEnumeratorTests.cs @@ -1,87 +1,87 @@ -namespace A2A.UnitTests.Server; - -public class TaskUpdateEventEnumeratorTests -{ - [Fact] - public async Task NotifyEvent_ShouldYieldEvent() - { - // Arrange - var enumerator = new TaskUpdateEventEnumerator(); - var evt = new TaskStatusUpdateEvent { TaskId = "t1", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - enumerator.NotifyEvent(evt); - - // Act - List yielded = []; - await foreach (var e in enumerator.WithCancellation(new CancellationTokenSource(100).Token)) - { - yielded.Add(e); - break; // Only one event expected - } - - // Assert - Assert.Single(yielded); - Assert.Equal(evt, yielded[0]); - } - - [Fact] - public async Task NotifyFinalEvent_ShouldYieldAndComplete() - { - // Arrange - var enumerator = new TaskUpdateEventEnumerator(); - var evt = new TaskStatusUpdateEvent { TaskId = "t2", Status = new AgentTaskStatus { State = TaskState.Completed }, Final = true }; - enumerator.NotifyFinalEvent(evt); - - // Act - List yielded = []; - await foreach (var e in enumerator) - { - yielded.Add(e); - } - - // Assert - Assert.Single(yielded); - Assert.Equal(evt, yielded[0]); - } - - [Fact] - public async Task MultipleEvents_ShouldYieldInOrder() - { - // Arrange - var enumerator = new TaskUpdateEventEnumerator(); - var evt1 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Submitted } }; - var evt2 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Working } }; - var evt3 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Completed }, Final = true }; - enumerator.NotifyEvent(evt1); - enumerator.NotifyEvent(evt2); - enumerator.NotifyFinalEvent(evt3); - - // Act - List yielded = []; - await foreach (var e in enumerator) - { - yielded.Add(e); - } - - // Assert - Assert.Equal(3, yielded.Count); - Assert.Equal(evt1, yielded[0]); - Assert.Equal(evt2, yielded[1]); - Assert.Equal(evt3, yielded[2]); - } - - [Fact] - public async Task Enumerator_ShouldSupportCancellation() - { - // Arrange - var enumerator = new TaskUpdateEventEnumerator(); - var cts = new CancellationTokenSource(50); - // Act & Assert - await Assert.ThrowsAsync(async () => - { - await foreach (var _ in enumerator.WithCancellation(cts.Token)) - { - // Should not yield - } - }); - } -} +namespace A2A.V0_3.UnitTests.Server; + +public class TaskUpdateEventEnumeratorTests +{ + [Fact] + public async Task NotifyEvent_ShouldYieldEvent() + { + // Arrange + var enumerator = new TaskUpdateEventEnumerator(); + var evt = new TaskStatusUpdateEvent { TaskId = "t1", Status = new AgentTaskStatus { State = TaskState.Submitted } }; + enumerator.NotifyEvent(evt); + + // Act + List yielded = []; + await foreach (var e in enumerator.WithCancellation(new CancellationTokenSource(100).Token)) + { + yielded.Add(e); + break; // Only one event expected + } + + // Assert + Assert.Single(yielded); + Assert.Equal(evt, yielded[0]); + } + + [Fact] + public async Task NotifyFinalEvent_ShouldYieldAndComplete() + { + // Arrange + var enumerator = new TaskUpdateEventEnumerator(); + var evt = new TaskStatusUpdateEvent { TaskId = "t2", Status = new AgentTaskStatus { State = TaskState.Completed }, Final = true }; + enumerator.NotifyFinalEvent(evt); + + // Act + List yielded = []; + await foreach (var e in enumerator) + { + yielded.Add(e); + } + + // Assert + Assert.Single(yielded); + Assert.Equal(evt, yielded[0]); + } + + [Fact] + public async Task MultipleEvents_ShouldYieldInOrder() + { + // Arrange + var enumerator = new TaskUpdateEventEnumerator(); + var evt1 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Submitted } }; + var evt2 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Working } }; + var evt3 = new TaskStatusUpdateEvent { TaskId = "t3", Status = new AgentTaskStatus { State = TaskState.Completed }, Final = true }; + enumerator.NotifyEvent(evt1); + enumerator.NotifyEvent(evt2); + enumerator.NotifyFinalEvent(evt3); + + // Act + List yielded = []; + await foreach (var e in enumerator) + { + yielded.Add(e); + } + + // Assert + Assert.Equal(3, yielded.Count); + Assert.Equal(evt1, yielded[0]); + Assert.Equal(evt2, yielded[1]); + Assert.Equal(evt3, yielded[2]); + } + + [Fact] + public async Task Enumerator_ShouldSupportCancellation() + { + // Arrange + var enumerator = new TaskUpdateEventEnumerator(); + var cts = new CancellationTokenSource(50); + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in enumerator.WithCancellation(cts.Token)) + { + // Should not yield + } + }); + } +} diff --git a/tests/A2A.V0_3.UnitTests/V03IntegrationTests.cs b/tests/A2A.V0_3.UnitTests/V03IntegrationTests.cs new file mode 100644 index 00000000..6f041ae4 --- /dev/null +++ b/tests/A2A.V0_3.UnitTests/V03IntegrationTests.cs @@ -0,0 +1,300 @@ +using System.Net; +using System.Text.Json; + +namespace A2A.V0_3.UnitTests; + +/// +/// End-to-end integration tests for V0.3 backward compatibility. +/// Uses a loopback HTTP handler to route A2AClient requests through +/// TaskManager and validate the full serialization round-trip. +/// +public class V03IntegrationTests +{ + private static (A2AClient client, TaskManager taskManager) CreateTestHarness(Action? onRequest = null) + { + var taskManager = new TaskManager(taskStore: new InMemoryTaskStore()); + var handler = new LoopbackHandler(taskManager, onRequest); + var httpClient = new HttpClient(handler); + var client = new A2AClient(new Uri("http://localhost/test"), httpClient); + return (client, taskManager); + } + + [Fact] + public async Task SendMessage_ReturnsTask_RoundTrip() + { + var (client, taskManager) = CreateTestHarness(); + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + var task = await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, cancellationToken: ct); + return (await taskManager.GetTaskAsync(new TaskQueryParams { Id = task.Id }, ct))!; + }; + + var response = await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = [new TextPart { Text = "Hello V0.3!" }] + } + }); + + Assert.NotNull(response); + var task = Assert.IsType(response); + Assert.NotEmpty(task.Id); + Assert.Equal(TaskState.Completed, task.Status.State); + } + + [Fact] + public async Task SendMessage_WithTextAndDataParts_RoundTrip() + { + var (client, taskManager) = CreateTestHarness(); + List? capturedParts = null; + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + capturedParts = sendParams.Message.Parts; + return await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + }; + + var response = await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = + [ + new TextPart { Text = "Hello!" }, + new DataPart { Data = new Dictionary + { + ["key"] = JsonDocument.Parse("\"value\"").RootElement + }}, + ] + } + }); + + Assert.NotNull(response); + Assert.NotNull(capturedParts); + Assert.Equal(2, capturedParts.Count); + Assert.IsType(capturedParts[0]); + Assert.Equal("Hello!", ((TextPart)capturedParts[0]).Text); + Assert.IsType(capturedParts[1]); + } + + [Fact] + public async Task GetTask_RoundTrip() + { + var (client, taskManager) = CreateTestHarness(); + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + return await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + }; + + // Create a task first + var sendResponse = await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = [new TextPart { Text = "Create task" }] + } + }); + + var createdTask = Assert.IsType(sendResponse); + + // Now get it by ID + var retrieved = await client.GetTaskAsync(createdTask.Id); + + Assert.NotNull(retrieved); + Assert.Equal(createdTask.Id, retrieved.Id); + Assert.Equal(createdTask.ContextId, retrieved.ContextId); + } + + [Fact] + public async Task CancelTask_RoundTrip() + { + var (client, taskManager) = CreateTestHarness(); + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + return await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + }; + + taskManager.OnTaskCancelled = (task, ct) => Task.CompletedTask; + + // Create a task + var sendResponse = await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = [new TextPart { Text = "Cancel me" }] + } + }); + + var createdTask = Assert.IsType(sendResponse); + + // Cancel it + var canceled = await client.CancelTaskAsync(new TaskIdParams { Id = createdTask.Id }); + + Assert.NotNull(canceled); + Assert.Equal(createdTask.Id, canceled.Id); + Assert.Equal(TaskState.Canceled, canceled.Status.State); + } + + [Fact] + public async Task GetTask_NonExistent_ThrowsA2AException() + { + var (client, _) = CreateTestHarness(); + + var ex = await Assert.ThrowsAsync( + () => client.GetTaskAsync("non-existent-id")); + Assert.Equal(A2AErrorCode.TaskNotFound, ex.ErrorCode); + } + + [Fact] + public async Task TaskState_SerializesAsKebabCase() + { + var (client, taskManager) = CreateTestHarness(); + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + var task = await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + await taskManager.UpdateStatusAsync(task.Id, TaskState.InputRequired, cancellationToken: ct); + return (await taskManager.GetTaskAsync(new TaskQueryParams { Id = task.Id }, ct))!; + }; + + var response = await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = [new TextPart { Text = "test" }] + } + }); + + var task = Assert.IsType(response); + Assert.Equal(TaskState.InputRequired, task.Status.State); + + // Verify kebab-case serialization + var json = JsonSerializer.Serialize(task.Status.State, A2AJsonUtilities.DefaultOptions); + Assert.Equal("\"input-required\"", json); + } + + [Fact] + public async Task MethodNames_UseSlashDelimitedFormat() + { + string? capturedMethod = null; + + var (client, taskManager) = CreateTestHarness(onRequest: body => + { + var doc = JsonDocument.Parse(body); + capturedMethod = doc.RootElement.GetProperty("method").GetString(); + }); + + taskManager.OnMessageReceived = async (sendParams, ct) => + { + return await taskManager.CreateTaskAsync(sendParams.Message.ContextId, sendParams.Message.TaskId, ct); + }; + + await client.SendMessageAsync(new MessageSendParams + { + Message = new AgentMessage + { + Role = MessageRole.User, + Parts = [new TextPart { Text = "test" }] + } + }); + + Assert.Equal("message/send", capturedMethod); + } + + /// + /// Loopback HTTP handler that routes JSON-RPC requests through a V0.3 TaskManager. + /// + private sealed class LoopbackHandler : HttpMessageHandler + { + private readonly TaskManager _taskManager; + private readonly Action? _onRequest; + + public LoopbackHandler(TaskManager taskManager, Action? onRequest = null) + { + _taskManager = taskManager; + _onRequest = onRequest; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content!.ReadAsStringAsync(cancellationToken); + _onRequest?.Invoke(body); + + var rpcRequest = JsonSerializer.Deserialize(body, A2AJsonUtilities.DefaultOptions) + ?? throw new InvalidOperationException("Failed to deserialize JSON-RPC request"); + + JsonRpcResponse rpcResponse; + try + { + rpcResponse = await RouteRequestAsync(rpcRequest, cancellationToken); + } + catch (A2AException ex) + { + rpcResponse = JsonRpcResponse.CreateJsonRpcErrorResponse(rpcRequest.Id, ex); + } + + var responseJson = JsonSerializer.Serialize(rpcResponse, A2AJsonUtilities.DefaultOptions); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json") + }; + } + + private async Task RouteRequestAsync( + JsonRpcRequest rpcRequest, CancellationToken ct) + { + var parameters = rpcRequest.Params; + + switch (rpcRequest.Method) + { + case A2AMethods.MessageSend: + var sendParams = parameters?.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Invalid params", A2AErrorCode.InvalidParams); + var sendResult = await _taskManager.SendMessageAsync(sendParams, ct); + return JsonRpcResponse.CreateJsonRpcResponse(rpcRequest.Id, sendResult); + + case A2AMethods.TaskGet: + var getParams = parameters?.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Invalid params", A2AErrorCode.InvalidParams); + var task = await _taskManager.GetTaskAsync(getParams, ct) + ?? throw new A2AException("Task not found", A2AErrorCode.TaskNotFound); + return JsonRpcResponse.CreateJsonRpcResponse(rpcRequest.Id, task); + + case A2AMethods.TaskCancel: + var cancelParams = parameters?.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Invalid params", A2AErrorCode.InvalidParams); + var canceled = await _taskManager.CancelTaskAsync(cancelParams, ct) + ?? throw new A2AException("Task not found", A2AErrorCode.TaskNotFound); + return JsonRpcResponse.CreateJsonRpcResponse(rpcRequest.Id, canceled); + + case A2AMethods.TaskPushNotificationConfigSet: + var setPushParams = parameters?.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Invalid params", A2AErrorCode.InvalidParams); + var setResult = await _taskManager.SetPushNotificationAsync(setPushParams, ct) + ?? throw new A2AException("Failed to set push notification config", A2AErrorCode.InternalError); + return JsonRpcResponse.CreateJsonRpcResponse(rpcRequest.Id, setResult); + + case A2AMethods.TaskPushNotificationConfigGet: + var getPushParams = parameters?.Deserialize(A2AJsonUtilities.DefaultOptions) + ?? throw new A2AException("Invalid params", A2AErrorCode.InvalidParams); + var getResult = await _taskManager.GetPushNotificationAsync(getPushParams, ct) + ?? throw new A2AException("Push notification config not found", A2AErrorCode.TaskNotFound); + return JsonRpcResponse.CreateJsonRpcResponse(rpcRequest.Id, getResult); + + default: + throw new A2AException($"Method not found: {rpcRequest.Method}", A2AErrorCode.MethodNotFound); + } + } + } +}