diff --git a/aevatar.slnx b/aevatar.slnx index ac7725a5..26b52b2d 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -37,6 +37,9 @@ + + + @@ -123,6 +126,7 @@ + diff --git a/global.json b/global.json index ed07ad8f..512142d2 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.103", + "version": "10.0.100", "rollForward": "latestFeature" } } diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs index 7589f16d..8df44264 100644 --- a/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs +++ b/src/Aevatar.AI.Abstractions/LLMProviders/LLMRequest.cs @@ -1,36 +1,39 @@ // ───────────────────────────────────────────────────────────── -// LLMRequest / ChatMessage / ToolCall — LLM 请求与消息模型 -// 封装 Chat API 所需的消息列表、工具、参数等 +// LLMRequest / ChatMessage / ToolCall — LLM request and message models +// Encapsulates the message list, tools, parameters, and other data required by the Chat API // ───────────────────────────────────────────────────────────── using Aevatar.AI.Abstractions.ToolProviders; namespace Aevatar.AI.Abstractions.LLMProviders; -/// LLM 请求 DTO。包含消息、工具、模型参数。 +/// LLM request DTO. Includes messages, tools, and model parameters. public sealed class LLMRequest { - /// 对话消息列表,按顺序排列(system / user / assistant / tool)。 + /// Conversation message list in order (system / user / assistant / tool). public required List Messages { get; init; } - /// 稳定请求标识,用于 replay/dedup/outbox 等跨边界关联。 + /// Stable request identifier used for cross-boundary correlation in replay/dedup/outbox scenarios. public string? RequestId { get; init; } - /// 透传给 provider/middleware 的附加 metadata。 + /// Additional metadata passed through to the provider/middleware. public IReadOnlyDictionary? Metadata { get; init; } - /// 可选工具列表,供 LLM 选择调用。 + /// Optional list of tools available for the LLM to invoke. public IReadOnlyList? Tools { get; init; } - /// 可选模型名称,覆盖 Provider 默认模型。 + /// Optional model name that overrides the provider default model. public string? Model { get; init; } - /// 可选温度参数,控制生成随机性。 + /// Optional temperature parameter that controls generation randomness. public double? Temperature { get; init; } - /// 可选最大生成 Token 数。 + /// Optional maximum number of output tokens. public int? MaxTokens { get; init; } + /// Optional response format constraint (Text / JsonObject / JsonSchema). + public LLMResponseFormat? ResponseFormat { get; init; } + public IReadOnlySet GetRequestedInputModalities() { var modalities = new HashSet(); @@ -55,36 +58,36 @@ public IReadOnlySet GetRequestedInputModalities() } } -/// 单条 Chat 消息。支持 system / user / assistant / tool 四种角色。 +/// A single Chat message. Supports the system / user / assistant / tool roles. public sealed class ChatMessage { - /// 消息角色:system / user / assistant / tool。 + /// Message role: system / user / assistant / tool. public required string Role { get; init; } - /// 文本内容,tool 角色时表示工具执行结果。 + /// Text content; for the tool role, this represents the tool execution result. public string? Content { get; init; } - /// 多模态内容分片(文本/图片)。存在时优先由 Provider 按分片构造消息。 + /// Multimodal content parts (text/image). When present, the provider should construct the message from the parts first. public IReadOnlyList? ContentParts { get; init; } - /// tool 角色时,对应 tool_call 的 Id。 + /// For the tool role, the corresponding tool_call Id. public string? ToolCallId { get; init; } - /// assistant 角色时,LLM 返回的 tool_call 列表。 + /// For the assistant role, the tool_call list returned by the LLM. public IReadOnlyList? ToolCalls { get; init; } - /// 创建 system 角色消息。 + /// Creates a system-role message. public static ChatMessage System(string content) => new() { Role = "system", Content = content }; - /// 创建 user 角色消息。 + /// Creates a user-role message. public static ChatMessage User(string content) => new() { Role = "user", Content = content }; - /// 创建 assistant 角色消息。 + /// Creates an assistant-role message. public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content }; - /// 创建 tool 角色消息,携带工具执行结果。 - /// 对应 tool_call 的 Id。 - /// 工具执行结果 JSON 字符串。 + /// Creates a tool-role message carrying the tool execution result. + /// The corresponding tool_call Id. + /// Tool execution result as a JSON string. public static ChatMessage Tool(string callId, string result) => new() { Role = "tool", ToolCallId = callId, Content = result }; public static ChatMessage User(IReadOnlyList parts, string? text = null) => new() @@ -104,32 +107,32 @@ public enum ContentPartKind Video = 4, } -/// 多模态内容分片。 +/// Multimodal content part. public sealed class ContentPart { - /// 分片类型:text / image / audio / video。 + /// Part kind: text / image / audio / video. public required ContentPartKind Kind { get; init; } - /// 文本分片内容。 + /// Text part content. public string? Text { get; init; } - /// 媒体分片的内联 base64 数据(不带 data-uri 头)。 + /// Inline base64 data for a media part (without the data-uri prefix). public string? DataBase64 { get; init; } - /// 媒体 MIME 类型(例如 image/png、audio/wav、video/mp4)。 + /// Media MIME type (for example image/png, audio/wav, video/mp4). public string? MediaType { get; init; } - /// 媒体远程地址或 data-uri。 + /// Remote media URI or data-uri. public string? Uri { get; init; } - /// 可选显示名或文件名。 + /// Optional display name or file name. public string? Name { get; init; } - /// 创建文本分片。 + /// Creates a text part. public static ContentPart TextPart(string text) => new() { Kind = ContentPartKind.Text, Text = text }; - /// 创建图片分片。 + /// Creates an image part. public static ContentPart ImagePart(string dataBase64, string mediaType = "image/png", string? name = null) => new() { Kind = ContentPartKind.Image, DataBase64 = dataBase64, MediaType = mediaType, Name = name }; @@ -149,15 +152,15 @@ public static ContentPart VideoUriPart(string uri, string mediaType = "video/mp4 new() { Kind = ContentPartKind.Video, Uri = uri, MediaType = mediaType, Name = name }; } -/// 单次工具调用。包含 Id、名称、参数 JSON。 +/// A single tool call. Includes Id, name, and parameter JSON. public sealed class ToolCall { - /// 工具调用唯一标识。 + /// Unique tool call identifier. public required string Id { get; init; } - /// 工具名称。 + /// Tool name. public required string Name { get; init; } - /// 工具参数 JSON 字符串。 + /// Tool parameter JSON string. public required string ArgumentsJson { get; init; } } diff --git a/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponseFormat.cs b/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponseFormat.cs new file mode 100644 index 00000000..513c6172 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/LLMProviders/LLMResponseFormat.cs @@ -0,0 +1,90 @@ +// ───────────────────────────────────────────────────────────── +// LLMResponseFormat — structured output constraints +// +// Three modes: Text (default free text), JsonObject (JSON mode), +// and JsonSchema (strict JSON with schema constraints). +// ───────────────────────────────────────────────────────────── + +using System.Text.Json; +using System.Text.RegularExpressions; +using Aevatar.AI.Abstractions.ToolProviders; + +namespace Aevatar.AI.Abstractions.LLMProviders; + +/// LLM response format constraints. +public class LLMResponseFormat +{ + /// Free text (default). + public static LLMResponseFormat Text { get; } = new() { Kind = LLMResponseFormatKind.Text }; + + /// JSON mode without a schema. + public static LLMResponseFormat JsonObject { get; } = new() { Kind = LLMResponseFormatKind.JsonObject }; + + /// Strict JSON constrained by a JSON Schema. + public static LLMResponseFormat ForJsonSchema( + JsonElement schema, + string? schemaName = null, + string? schemaDescription = null) => + new LLMResponseFormatJsonSchema(schema, schemaName, schemaDescription); + + /// Automatically generates JSON Schema constraints from a C# type. + public static LLMResponseFormat ForJsonSchema( + string? schemaName = null, + string? schemaDescription = null) => + new LLMResponseFormatJsonSchema( + AgentToolSchemaGenerator.GenerateSchema(), + schemaName ?? SanitizeTypeName(typeof(T)), + schemaDescription); + + /// The format kind. + public LLMResponseFormatKind Kind { get; protected init; } = LLMResponseFormatKind.Text; + + /// Sanitizes a CLR type name into a provider-safe schema name. + internal static string SanitizeTypeName(Type type) + { + var name = type.Name; + // Remove generic suffixes such as `1, `2, etc. + var idx = name.IndexOf('`'); + if (idx >= 0) name = name[..idx]; + // Replace non-alphanumeric characters + return Regex.Replace(name, @"[^a-zA-Z0-9_]", "_"); + } +} + +/// Response format kind enumeration. +public enum LLMResponseFormatKind +{ + /// Free text (default). + Text = 0, + + /// JSON mode without schema constraints. + JsonObject = 1, + + /// Strict JSON with a JSON Schema. + JsonSchema = 2, +} + +/// Strict JSON format constraints with a JSON Schema. +public sealed class LLMResponseFormatJsonSchema : LLMResponseFormat +{ + public LLMResponseFormatJsonSchema( + JsonElement schema, + string? schemaName = null, + string? schemaDescription = null) + { + Kind = LLMResponseFormatKind.JsonSchema; + // Clone to decouple from caller's JsonDocument lifetime + Schema = schema.Clone(); + SchemaName = schemaName; + SchemaDescription = schemaDescription; + } + + /// The JSON Schema. + public JsonElement Schema { get; } + + /// The schema name (required by some providers). + public string? SchemaName { get; } + + /// The schema description. + public string? SchemaDescription { get; } +} diff --git a/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolBase.cs b/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolBase.cs new file mode 100644 index 00000000..dd20e9b0 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolBase.cs @@ -0,0 +1,73 @@ +// ───────────────────────────────────────────────────────────── +// AgentToolBase — type-safe base class for IAgentTool +// +// Automatically derives ParametersSchema from TParams; subclasses only need to implement +// Name / Description / ExecuteAsync(TParams, CancellationToken)。 +// ───────────────────────────────────────────────────────────── + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aevatar.AI.Abstractions.ToolProviders; + +/// +/// Type-safe base class for . +/// is automatically generated from . +/// +/// Tool parameter type used for automatic JSON Schema generation and deserialization. +public abstract class AgentToolBase : IAgentTool where TParams : class +{ + private static readonly string CachedSchema = AgentToolSchemaGenerator.GenerateSchemaString(); + + private static readonly JsonSerializerOptions DeserializeOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// + public abstract string Name { get; } + + /// + public abstract string Description { get; } + + /// The JSON Schema automatically generated from . + public string ParametersSchema => CachedSchema; + + /// + public virtual ToolApprovalMode ApprovalMode => ToolApprovalMode.NeverRequire; + + /// + public virtual bool IsReadOnly => false; + + /// + public virtual bool IsDestructive => false; + + /// + public virtual bool? RequiresApproval(string argumentsJson) => null; + + /// Type-safe execution method. + protected abstract Task ExecuteAsync(TParams parameters, CancellationToken ct); + + /// + public Task ExecuteAsync(string argumentsJson, CancellationToken ct = default) + { + TParams? parameters; + try + { + parameters = string.IsNullOrWhiteSpace(argumentsJson) + ? null + : JsonSerializer.Deserialize(argumentsJson, DeserializeOptions); + } + catch (JsonException ex) + { + return Task.FromResult(JsonSerializer.Serialize(new { error = $"Invalid parameters: {ex.Message}" })); + } + + if (parameters is null) + return Task.FromResult("""{"error":"Parameters are required"}"""); + + return ExecuteAsync(parameters, ct); + } +} diff --git a/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolSchemaGenerator.cs b/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolSchemaGenerator.cs new file mode 100644 index 00000000..b73f4a71 --- /dev/null +++ b/src/Aevatar.AI.Abstractions/ToolProviders/AgentToolSchemaGenerator.cs @@ -0,0 +1,78 @@ +// ───────────────────────────────────────────────────────────── +// AgentToolSchemaGenerator — automatically generate JSON Schema from C# types +// +// Eliminates the maintenance burden of handwritten ParametersSchema. +// Uses System.Text.Json.Schema.JsonSchemaExporter (.NET 9+). +// ───────────────────────────────────────────────────────────── + +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Aevatar.AI.Abstractions.ToolProviders; + +/// +/// Automatically generates JSON Schema from C# types for +/// and schema generation for . +/// +public static class AgentToolSchemaGenerator +{ + private static readonly JsonSerializerOptions SchemaSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + private static readonly JsonSchemaExporterOptions SchemaExporterOptions = new() + { + TreatNullObliviousAsNonNullable = true, + }; + + private static readonly ConcurrentDictionary StringCache = new(); + private static readonly ConcurrentDictionary ElementCache = new(); + + /// Generates a JSON Schema string from the type parameter. + public static string GenerateSchemaString() => + GenerateSchemaString(typeof(TParams)); + + /// Generates a JSON Schema string from a Type (results are cached by type). + public static string GenerateSchemaString(Type paramsType) => + StringCache.GetOrAdd(paramsType, static type => + { + var node = GenerateSchemaNode(type); + return node.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + }); + + /// Generates a JSON Schema JsonElement from the type parameter. + public static JsonElement GenerateSchema() => + GenerateSchema(typeof(TParams)); + + /// Generates a JSON Schema JsonElement from a Type (results are cached by type). + public static JsonElement GenerateSchema(Type paramsType) => + ElementCache.GetOrAdd(paramsType, static type => + { + var node = GenerateSchemaNode(type); + return JsonSerializer.Deserialize(node.ToJsonString()); + }); + + private static JsonNode GenerateSchemaNode(Type paramsType) + { + var node = JsonSchemaExporter.GetJsonSchemaAsNode( + SchemaSerializerOptions, + paramsType, + SchemaExporterOptions); + + // Ensure top-level is { "type": "object", ... } + if (node is JsonObject obj && !obj.ContainsKey("type")) + { + obj["type"] = "object"; + } + + return node; + } +} diff --git a/src/Aevatar.AI.Core/Chat/ChatRuntime.cs b/src/Aevatar.AI.Core/Chat/ChatRuntime.cs index 8047a805..a1e7acf3 100644 --- a/src/Aevatar.AI.Core/Chat/ChatRuntime.cs +++ b/src/Aevatar.AI.Core/Chat/ChatRuntime.cs @@ -293,6 +293,7 @@ await channel.Writer.WriteAsync( Model = baseRequest.Model, Temperature = baseRequest.Temperature, MaxTokens = baseRequest.MaxTokens, + ResponseFormat = baseRequest.ResponseFormat, }; var roundResult = await StreamLlmRoundAsync( provider, @@ -470,6 +471,7 @@ await channel.Writer.WriteAsync( Model = baseRequest.Model, Temperature = baseRequest.Temperature, MaxTokens = baseRequest.MaxTokens, + ResponseFormat = baseRequest.ResponseFormat, }; var finalRound = await StreamLlmRoundAsync( provider, @@ -520,6 +522,7 @@ await channel.Writer.WriteAsync( Model = finalRequest.Model, Temperature = finalRequest.Temperature, MaxTokens = finalRequest.MaxTokens, + ResponseFormat = finalRequest.ResponseFormat, }; var summaryRound = await StreamLlmRoundAsync( provider, summaryRequest, channel.Writer, runToken, @@ -748,6 +751,7 @@ private static LLMRequest ApplyRequestIdentity( Model = baseRequest.Model, Temperature = baseRequest.Temperature, MaxTokens = baseRequest.MaxTokens, + ResponseFormat = baseRequest.ResponseFormat, }; } diff --git a/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs b/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs index 81bf5536..e30d105e 100644 --- a/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs +++ b/src/Aevatar.AI.Core/Tools/ToolCallLoop.cs @@ -91,6 +91,7 @@ public ToolCallLoop( Model = baseRequest.Model, Temperature = baseRequest.Temperature, MaxTokens = baseRequest.MaxTokens, + ResponseFormat = baseRequest.ResponseFormat, }; var (response, terminated) = await InvokeLlmAsync(provider, request, ct); @@ -209,6 +210,7 @@ public ToolCallLoop( Model = baseRequest.Model, Temperature = baseRequest.Temperature, MaxTokens = baseRequest.MaxTokens, + ResponseFormat = baseRequest.ResponseFormat, }; var (finalResponse, _) = await InvokeLlmAsync(provider, finalRequest, ct); var finalContent = finalResponse?.Content; @@ -234,6 +236,7 @@ public ToolCallLoop( Model = finalRequest.Model, Temperature = finalRequest.Temperature, MaxTokens = finalRequest.MaxTokens, + ResponseFormat = finalRequest.ResponseFormat, }; var (summaryResponse, _) = await InvokeLlmAsync(provider, summaryRequest, ct); var summaryContent = summaryResponse?.Content; diff --git a/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs b/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs index 50a406f7..dec32460 100644 --- a/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs +++ b/src/Aevatar.AI.LLMProviders.MEAI/MEAILLMProvider.cs @@ -1,9 +1,9 @@ // ───────────────────────────────────────────────────────────── -// MEAILLMProvider — 基于 Microsoft.Extensions.AI 的 LLM 提供者 +// MEAILLMProvider — LLM provider based on Microsoft.Extensions.AI // -// 将 MEAI 的 IChatClient 桥接到 Aevatar 的 ILLMProvider。 -// 支持 OpenAI / Azure OpenAI / 任何兼容 OpenAI API 的提供者 -// (DeepSeek、Moonshot、通义千问等通过 baseUrl 配置)。 +// Bridges MEAI's IChatClient to Aevatar's ILLMProvider. +// Supports OpenAI / Azure OpenAI / any provider compatible with the OpenAI API +// (DeepSeek, Moonshot, Tongyi Qianwen, etc. configured via baseUrl). // ───────────────────────────────────────────────────────────── using System.Runtime.CompilerServices; @@ -17,8 +17,8 @@ namespace Aevatar.AI.LLMProviders.MEAI; /// -/// 基于 MEAI IChatClient 的 ILLMProvider 实现。 -/// 支持 OpenAI、Azure OpenAI、以及任何兼容 OpenAI API 的提供者。 +/// ILLMProvider implementation based on the MEAI IChatClient. +/// Supports OpenAI, Azure OpenAI, and any provider compatible with the OpenAI API. /// public sealed class MEAILLMProvider : ILLMProvider { @@ -46,17 +46,17 @@ public sealed class MEAILLMProvider : ILLMProvider private readonly IChatClient _client; private readonly ILogger _logger; - /// 提供者名称。 + /// Provider name. public string Name { get; } public LLMProviderCapabilities Capabilities => ProviderCapabilities; /// - /// 创建 MEAI LLM Provider。 + /// Creates the MEAI LLM Provider. /// - /// 提供者名称(如 "openai", "deepseek")。 - /// MEAI 的 IChatClient 实例。 - /// 日志记录器。 + /// Provider name (for example "openai", "deepseek"). + /// MEAI IChatClient instance. + /// Logger. public MEAILLMProvider(string name, IChatClient client, ILogger? logger = null) { Name = name; @@ -66,13 +66,13 @@ public MEAILLMProvider(string name, IChatClient client, ILogger? logger = null) // ─── ILLMProvider.ChatAsync ─── - /// 单轮 LLM 调用。将 Aevatar 的 LLMRequest 转为 MEAI 的 ChatMessage 列表。 + /// Single-turn LLM call. Converts Aevatar's LLMRequest into a list of MEAI ChatMessage instances. public async Task ChatAsync(LLMRequest request, CancellationToken ct = default) { var messages = ConvertMessages(request.Messages); var options = BuildOptions(request); - _logger.LogDebug("MEAI ChatAsync: {MessageCount} 条消息, model={Model}", + _logger.LogDebug("MEAI ChatAsync: {MessageCount} messages, model={Model}", messages.Count, options?.ModelId); var response = await _client.GetResponseAsync(messages, options, ct); @@ -82,7 +82,7 @@ public async Task ChatAsync(LLMRequest request, CancellationToken c // ─── ILLMProvider.ChatStreamAsync ─── - /// 流式 LLM 调用。返回异步枚举的流式 chunk。 + /// Streaming LLM call. Returns an async-enumerable stream of chunks. public async IAsyncEnumerable ChatStreamAsync( LLMRequest request, [EnumeratorCancellation] CancellationToken ct = default) @@ -90,7 +90,7 @@ public async IAsyncEnumerable ChatStreamAsync( var messages = ConvertMessages(request.Messages); var options = BuildOptions(request); - _logger.LogDebug("MEAI ChatStreamAsync: {MessageCount} 条消息", messages.Count); + _logger.LogDebug("MEAI ChatStreamAsync: {MessageCount} messages", messages.Count); var emittedStreamChunk = false; string? lastFinishReason = null; @@ -204,11 +204,11 @@ public async IAsyncEnumerable ChatStreamAsync( yield break; } - // 最后一个 chunk 标记结束 + // The final chunk marks the end yield return new LLMStreamChunk { IsLast = true, FinishReason = lastFinishReason }; } - // ─── 转换:Aevatar → MEAI ─── + // ─── Conversion: Aevatar -> MEAI ─── private static List ConvertMessages( IEnumerable messages) @@ -235,14 +235,14 @@ public async IAsyncEnumerable ChatStreamAsync( meaiMsg.Contents.Add(new TextContent(msg.Content)); } - // 处理 Tool Call 结果 + // Handle tool call results if (msg.Role == "tool" && msg.ToolCallId != null) { meaiMsg.Contents.Clear(); meaiMsg.Contents.Add(new FunctionResultContent(msg.ToolCallId, BuildToolResultPayload(msg))); } - // 处理 Assistant 的 Tool Calls + // Handle assistant tool calls if (msg.ToolCalls is { Count: > 0 }) { meaiMsg.Contents.Clear(); @@ -253,7 +253,7 @@ public async IAsyncEnumerable ChatStreamAsync( foreach (var tc in msg.ToolCalls) { - // 解析 JSON 参数为字典 + // Parse JSON arguments into a dictionary Dictionary? args = null; if (!string.IsNullOrEmpty(tc.ArgumentsJson)) { @@ -261,7 +261,7 @@ public async IAsyncEnumerable ChatStreamAsync( { args = System.Text.Json.JsonSerializer.Deserialize>(tc.ArgumentsJson); } - catch { /* 解析失败则不传参数 */ } + catch { /* If parsing fails, do not pass arguments */ } } meaiMsg.Contents.Add(new FunctionCallContent(tc.Id, tc.Name, args)); @@ -385,7 +385,25 @@ private static object BuildToolResultPayload(Aevatar.AI.Abstractions.LLMProvider hasOptions = true; } - // 注册 Tools — 使用工具自身的 ParametersSchema,让 LLM 看到真实参数结构 + // Map ResponseFormat + if (request.ResponseFormat is not null) + { + options.ResponseFormat = request.ResponseFormat.Kind switch + { + LLMResponseFormatKind.Text => ChatResponseFormat.Text, + LLMResponseFormatKind.JsonObject => ChatResponseFormat.Json, + LLMResponseFormatKind.JsonSchema when request.ResponseFormat is LLMResponseFormatJsonSchema jsonSchema => + ChatResponseFormat.ForJsonSchema( + jsonSchema.Schema, + jsonSchema.SchemaName, + jsonSchema.SchemaDescription), + _ => null, + }; + if (options.ResponseFormat != null) + hasOptions = true; + } + + // Register tools — use each tool's own ParametersSchema so the LLM sees the real parameter structure if (request.Tools is { Count: > 0 }) { options.Tools = []; @@ -415,17 +433,17 @@ private static AdditionalPropertiesDictionary BuildAdditionalProperties(LLMReque return new AdditionalPropertiesDictionary(properties); } - // ─── 转换:MEAI → Aevatar ─── + // ─── Conversion: MEAI -> Aevatar ─── private static LLMResponse ConvertResponse(Microsoft.Extensions.AI.ChatResponse response) { - // ChatResponse.Messages 包含所有回复消息 + // ChatResponse.Messages contains all reply messages var lastMessage = response.Messages.LastOrDefault(); var content = lastMessage?.Text; List? toolCalls = null; List? contentParts = null; - // 检查是否有 Tool Calls + // Check whether there are tool calls if (lastMessage != null) { foreach (var part in lastMessage.Contents) diff --git a/src/Aevatar.AI.LLMProviders.NyxId/NyxIdLLMProvider.cs b/src/Aevatar.AI.LLMProviders.NyxId/NyxIdLLMProvider.cs index 9e27483c..61dd52be 100644 --- a/src/Aevatar.AI.LLMProviders.NyxId/NyxIdLLMProvider.cs +++ b/src/Aevatar.AI.LLMProviders.NyxId/NyxIdLLMProvider.cs @@ -188,6 +188,7 @@ private LLMRequest NormalizeRequest(LLMRequest request) Model = ResolveModel(request), Temperature = request.Temperature, MaxTokens = request.MaxTokens, + ResponseFormat = request.ResponseFormat, }; } diff --git a/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs b/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs index 717106d5..20a42b75 100644 --- a/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs +++ b/src/Aevatar.AI.LLMProviders.Tornado/TornadoLLMProvider.cs @@ -131,6 +131,7 @@ private LlmTornado.Chat.ChatRequest MapRequest(LLMRequest request) Model = request.Model, Temperature = request.Temperature, MaxTokens = request.MaxTokens, + ResponseFormat = request.ResponseFormat, }; } diff --git a/src/Aevatar.Interop.A2A.Abstractions/Aevatar.Interop.A2A.Abstractions.csproj b/src/Aevatar.Interop.A2A.Abstractions/Aevatar.Interop.A2A.Abstractions.csproj new file mode 100644 index 00000000..295dd382 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/Aevatar.Interop.A2A.Abstractions.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + Aevatar.Interop.A2A.Abstractions + Aevatar.Interop.A2A.Abstractions + + + + + diff --git a/src/Aevatar.Interop.A2A.Abstractions/IA2AAdapterService.cs b/src/Aevatar.Interop.A2A.Abstractions/IA2AAdapterService.cs new file mode 100644 index 00000000..1e7cf7bc --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/IA2AAdapterService.cs @@ -0,0 +1,41 @@ +using Aevatar.Interop.A2A.Abstractions.Models; + +namespace Aevatar.Interop.A2A.Abstractions; + +/// A2A protocol adapter service. Converts A2A JSON-RPC requests into internal actor interactions. +public interface IA2AAdapterService +{ + /// Handles the tasks/send request. + Task SendTaskAsync(TaskSendParams sendParams, CancellationToken ct = default); + + /// Handles the tasks/get request. + Task GetTaskAsync(TaskQueryParams queryParams, CancellationToken ct = default); + + /// Handles the tasks/cancel request. + Task CancelTaskAsync(TaskIdParams idParams, CancellationToken ct = default); + + /// Gets the Agent Card. + AgentCard GetAgentCard(string baseUrl); +} + +/// tasks/send parameters. +public sealed class TaskSendParams +{ + public required string Id { get; init; } + public string? SessionId { get; init; } + public required Message Message { get; init; } + public Dictionary? Metadata { get; init; } +} + +/// tasks/get parameters. +public sealed class TaskQueryParams +{ + public required string Id { get; init; } + public int? HistoryLength { get; init; } +} + +/// tasks/cancel parameters. +public sealed class TaskIdParams +{ + public required string Id { get; init; } +} diff --git a/src/Aevatar.Interop.A2A.Abstractions/IA2ATaskStore.cs b/src/Aevatar.Interop.A2A.Abstractions/IA2ATaskStore.cs new file mode 100644 index 00000000..7a7873d6 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/IA2ATaskStore.cs @@ -0,0 +1,25 @@ +using System.Threading.Channels; +using Aevatar.Interop.A2A.Abstractions.Models; + +namespace Aevatar.Interop.A2A.Abstractions; + +/// A2A Task state store. Tracks the mapping between A2A tasks and internal actor commands. +public interface IA2ATaskStore +{ + Task CreateTaskAsync(string taskId, string? sessionId, Message message, CancellationToken ct = default); + Task GetTaskAsync(string taskId, CancellationToken ct = default); + Task UpdateTaskStateAsync(string taskId, TaskState state, Message? message = null, CancellationToken ct = default); + Task AddArtifactAsync(string taskId, Artifact artifact, CancellationToken ct = default); + Task DeleteTaskAsync(string taskId, CancellationToken ct = default); + + /// Subscribes to state change notifications for the specified task. Returns a ChannelReader for SSE streaming consumption. + ChannelReader SubscribeAsync(string taskId); +} + +/// Task state change notification. +public sealed class TaskStateUpdate +{ + public required A2ATaskStatus Status { get; init; } + public Artifact? Artifact { get; init; } + public bool IsFinal { get; init; } +} diff --git a/src/Aevatar.Interop.A2A.Abstractions/Models/A2ATask.cs b/src/Aevatar.Interop.A2A.Abstractions/Models/A2ATask.cs new file mode 100644 index 00000000..bd4f41a7 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/Models/A2ATask.cs @@ -0,0 +1,221 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aevatar.Interop.A2A.Abstractions.Models; + +/// A2A Task — represents a task for a cross-agent interaction. +public sealed class A2ATask +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("sessionId")] + public string? SessionId { get; set; } + + [JsonPropertyName("status")] + public required A2ATaskStatus Status { get; set; } + + [JsonPropertyName("history")] + public List? History { get; set; } + + [JsonPropertyName("artifacts")] + public List? Artifacts { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } +} + +/// A2A Task status. +public sealed class A2ATaskStatus +{ + [JsonPropertyName("state")] + public required TaskState State { get; set; } + + [JsonPropertyName("message")] + public Message? Message { get; set; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TaskState +{ + [JsonStringEnumMemberName("submitted")] + Submitted, + + [JsonStringEnumMemberName("working")] + Working, + + [JsonStringEnumMemberName("input-required")] + InputRequired, + + [JsonStringEnumMemberName("completed")] + Completed, + + [JsonStringEnumMemberName("canceled")] + Canceled, + + [JsonStringEnumMemberName("failed")] + Failed, + + [JsonStringEnumMemberName("unknown")] + Unknown, +} + +/// A2A Message — a single conversation message. +public sealed class Message +{ + [JsonPropertyName("role")] + public required string Role { get; init; } + + [JsonPropertyName("parts")] + public required IReadOnlyList Parts { get; init; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; init; } +} + +/// A2A Artifact — an output artifact generated by an agent. +public sealed class Artifact +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("parts")] + public required IReadOnlyList Parts { get; init; } + + [JsonPropertyName("index")] + public int Index { get; init; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; init; } +} + +/// A2A Part — a content fragment in a message/artifact. Distinguished by the "type" field in the A2A protocol. +[JsonConverter(typeof(PartJsonConverter))] +public abstract class Part +{ + [JsonPropertyName("type")] + public abstract string Type { get; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; init; } +} + +public sealed class TextPart : Part +{ + public override string Type => "text"; + + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +public sealed class FilePart : Part +{ + public override string Type => "file"; + + [JsonPropertyName("file")] + public required FileContent File { get; init; } +} + +public sealed class DataPart : Part +{ + public override string Type => "data"; + + [JsonPropertyName("data")] + public required Dictionary Data { get; init; } +} + +public sealed class FileContent +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + [JsonPropertyName("bytes")] + public string? Bytes { get; init; } + + [JsonPropertyName("uri")] + public string? Uri { get; init; } +} + +/// Custom JSON converter for A2A Part that routes to the concrete subtype based on the "type" field. +internal sealed class PartJsonConverter : JsonConverter +{ + public override Part? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + var type = root.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; + + return type switch + { + "text" => new TextPart + { + Text = root.GetProperty("text").GetString() ?? "", + Metadata = DeserializeMetadata(root), + }, + "file" => new FilePart + { + File = JsonSerializer.Deserialize(root.GetProperty("file").GetRawText(), options)!, + Metadata = DeserializeMetadata(root), + }, + "data" => new DataPart + { + Data = JsonSerializer.Deserialize>( + root.GetProperty("data").GetRawText(), options) ?? [], + Metadata = DeserializeMetadata(root), + }, + // For an unknown type, try to infer it from the content + _ when root.TryGetProperty("text", out _) => new TextPart + { + Text = root.GetProperty("text").GetString() ?? "", + Metadata = DeserializeMetadata(root), + }, + _ => throw new JsonException($"Unknown part type: '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, Part value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + + switch (value) + { + case TextPart textPart: + writer.WriteString("text", textPart.Text); + break; + case FilePart filePart: + writer.WritePropertyName("file"); + JsonSerializer.Serialize(writer, filePart.File, options); + break; + case DataPart dataPart: + writer.WritePropertyName("data"); + JsonSerializer.Serialize(writer, dataPart.Data, options); + break; + } + + if (value.Metadata is { Count: > 0 }) + { + writer.WritePropertyName("metadata"); + JsonSerializer.Serialize(writer, value.Metadata, options); + } + + writer.WriteEndObject(); + } + + private static Dictionary? DeserializeMetadata(JsonElement root) + { + if (!root.TryGetProperty("metadata", out var metaProp) || metaProp.ValueKind == JsonValueKind.Null) + return null; + return JsonSerializer.Deserialize>(metaProp.GetRawText()); + } +} diff --git a/src/Aevatar.Interop.A2A.Abstractions/Models/AgentCard.cs b/src/Aevatar.Interop.A2A.Abstractions/Models/AgentCard.cs new file mode 100644 index 00000000..7522a983 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/Models/AgentCard.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace Aevatar.Interop.A2A.Abstractions.Models; + +/// A2A Agent Card — describes an agent's capabilities for service discovery. +public sealed class AgentCard +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("url")] + public required string Url { get; init; } + + [JsonPropertyName("version")] + public string Version { get; init; } = "1.0.0"; + + [JsonPropertyName("capabilities")] + public AgentCapabilities Capabilities { get; init; } = new(); + + [JsonPropertyName("skills")] + public IReadOnlyList Skills { get; init; } = []; + + [JsonPropertyName("defaultInputModes")] + public IReadOnlyList DefaultInputModes { get; init; } = ["text"]; + + [JsonPropertyName("defaultOutputModes")] + public IReadOnlyList DefaultOutputModes { get; init; } = ["text"]; +} + +public sealed class AgentCapabilities +{ + [JsonPropertyName("streaming")] + public bool Streaming { get; init; } + + [JsonPropertyName("pushNotifications")] + public bool PushNotifications { get; init; } + + [JsonPropertyName("stateTransitionHistory")] + public bool StateTransitionHistory { get; init; } +} + +public sealed class AgentSkill +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("tags")] + public IReadOnlyList Tags { get; init; } = []; + + [JsonPropertyName("examples")] + public IReadOnlyList Examples { get; init; } = []; +} diff --git a/src/Aevatar.Interop.A2A.Abstractions/Models/JsonRpc.cs b/src/Aevatar.Interop.A2A.Abstractions/Models/JsonRpc.cs new file mode 100644 index 00000000..6faf70be --- /dev/null +++ b/src/Aevatar.Interop.A2A.Abstractions/Models/JsonRpc.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aevatar.Interop.A2A.Abstractions.Models; + +/// A2A JSON-RPC 2.0 request. +public sealed class JsonRpcRequest +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + [JsonPropertyName("id")] + public JsonElement? Id { get; init; } + + [JsonPropertyName("method")] + public required string Method { get; init; } + + [JsonPropertyName("params")] + public JsonElement? Params { get; init; } +} + +/// A2A JSON-RPC 2.0 response. +public sealed class JsonRpcResponse +{ + [JsonPropertyName("jsonrpc")] + public string JsonRpc { get; init; } = "2.0"; + + [JsonPropertyName("id")] + public JsonElement? Id { get; init; } + + [JsonPropertyName("result")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Result { get; init; } + + [JsonPropertyName("error")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonRpcError? Error { get; init; } + + public static JsonRpcResponse Success(JsonElement? id, object result) => new() + { + Id = id, + Result = result, + }; + + public static JsonRpcResponse Fail(JsonElement? id, int code, string message, object? data = null) => new() + { + Id = id, + Error = new JsonRpcError { Code = code, Message = message, Data = data }, + }; +} + +public sealed class JsonRpcError +{ + [JsonPropertyName("code")] + public required int Code { get; init; } + + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Data { get; init; } +} + +/// A2A JSON-RPC standard error codes. +public static class A2AErrorCodes +{ + public const int ParseError = -32700; + public const int InvalidRequest = -32600; + public const int MethodNotFound = -32601; + public const int InvalidParams = -32602; + public const int InternalError = -32603; + public const int TaskNotFound = -32001; + public const int TaskNotCancelable = -32002; +} diff --git a/src/Aevatar.Interop.A2A.Application/A2AAdapterService.cs b/src/Aevatar.Interop.A2A.Application/A2AAdapterService.cs new file mode 100644 index 00000000..5cf29803 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Application/A2AAdapterService.cs @@ -0,0 +1,183 @@ +// ───────────────────────────────────────────────────────────── +// A2AAdapterService — bidirectional conversion between the A2A protocol and internal EventEnvelope +// +// Maps A2A tasks/send to IActorDispatchPort.DispatchAsync, +// wraps internal ChatRequestEvent as an EventEnvelope and dispatches it to the target GAgent. +// ───────────────────────────────────────────────────────────── + +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.MultiAgent; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Interop.A2A.Application; + +public sealed class A2AAdapterService : IA2AAdapterService +{ + private readonly IActorDispatchPort _dispatchPort; + private readonly IA2ATaskStore _taskStore; + private readonly ILogger _logger; + + public A2AAdapterService( + IActorDispatchPort dispatchPort, + IA2ATaskStore taskStore, + ILogger? logger = null) + { + _dispatchPort = dispatchPort; + _taskStore = taskStore; + _logger = logger ?? NullLogger.Instance; + } + + public async Task SendTaskAsync(TaskSendParams sendParams, CancellationToken ct = default) + { + // 1. Extract the text prompt from the message + var prompt = ExtractTextFromMessage(sendParams.Message); + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Message must contain at least one text part."); + + // 2. Resolve the target actor ID (from metadata or session) + var targetActorId = ResolveTargetActorId(sendParams); + if (string.IsNullOrWhiteSpace(targetActorId)) + throw new ArgumentException("Target agent ID must be specified in metadata['agentId'] or sessionId."); + + // 3. Create the task record + var task = await _taskStore.CreateTaskAsync(sendParams.Id, sendParams.SessionId, sendParams.Message, ct); + + // 4. Build the EventEnvelope and dispatch it + var chatRequest = BuildChatRequestEvent(prompt, sendParams); + var envelope = BuildEnvelope(chatRequest, sendParams.Id, targetActorId); + + try + { + await _dispatchPort.DispatchAsync(targetActorId, envelope, ct); + task = await _taskStore.UpdateTaskStateAsync(sendParams.Id, TaskState.Working, ct: ct); + _logger.LogDebug("A2A task {TaskId} dispatched to actor {ActorId}", sendParams.Id, targetActorId); + } + catch (Exception ex) + { + _logger.LogError(ex, "A2A task {TaskId} dispatch failed", sendParams.Id); + var errorMessage = new Message + { + Role = "agent", + Parts = [new TextPart { Text = $"Dispatch failed: {ex.Message}" }], + }; + task = await _taskStore.UpdateTaskStateAsync(sendParams.Id, TaskState.Failed, errorMessage, ct); + } + + return task; + } + + public async Task GetTaskAsync(TaskQueryParams queryParams, CancellationToken ct = default) + { + var task = await _taskStore.GetTaskAsync(queryParams.Id, ct); + if (task == null) return null; + + // Trim by historyLength + if (queryParams.HistoryLength.HasValue && task.History != null) + { + var len = queryParams.HistoryLength.Value; + if (len >= 0 && len < task.History.Count) + { + task.History = task.History.GetRange(task.History.Count - len, len); + } + } + + return task; + } + + public async Task CancelTaskAsync(TaskIdParams idParams, CancellationToken ct = default) + { + var task = await _taskStore.GetTaskAsync(idParams.Id, ct); + if (task == null) + throw new KeyNotFoundException($"Task '{idParams.Id}' not found."); + + if (task.Status.State is TaskState.Completed or TaskState.Failed or TaskState.Canceled) + throw new InvalidOperationException($"Task '{idParams.Id}' is in terminal state '{task.Status.State}' and cannot be canceled."); + + return await _taskStore.UpdateTaskStateAsync(idParams.Id, TaskState.Canceled, ct: ct); + } + + public AgentCard GetAgentCard(string baseUrl) + { + return new AgentCard + { + Name = "Aevatar GAgent", + Description = "Aevatar GAgent accessible via A2A protocol.", + Url = baseUrl.TrimEnd('/') + "/a2a", + Version = "1.0.0", + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + StateTransitionHistory = true, + }, + Skills = + [ + new AgentSkill + { + Id = "chat", + Name = "Chat", + Description = "General-purpose conversational agent.", + Tags = ["chat", "conversation"], + }, + ], + }; + } + + // ─── Private Helpers ─── + + private static string ExtractTextFromMessage(Message message) + { + var textParts = message.Parts.OfType().Select(p => p.Text); + return string.Join("\n", textParts); + } + + private static string? ResolveTargetActorId(TaskSendParams sendParams) + { + if (sendParams.Metadata?.TryGetValue("agentId", out var agentId) == true + && !string.IsNullOrWhiteSpace(agentId)) + return agentId; + + if (!string.IsNullOrWhiteSpace(sendParams.SessionId)) + return sendParams.SessionId; + + return null; + } + + private static IMessage BuildChatRequestEvent(string prompt, TaskSendParams sendParams) + { + // Safely create ChatRequestEvent via reflection (avoiding a direct dependency on AI.Abstractions) + // The actual proto type is Aevatar.AI.Abstractions.ChatRequestEvent + // But this layer dispatches generically through Foundation Abstractions Any.Pack + // + // Because the Application layer does not directly depend on AI.Abstractions (to keep layering clean), + // we build a generic message from agent_messages.proto. + // Callers can use AgentMessage or build ChatRequestEvent directly. + var agentMessage = new AgentMessage + { + Content = prompt, + FromAgentId = "a2a-adapter", + }; + + return agentMessage; + } + + private static EventEnvelope BuildEnvelope(IMessage payload, string correlationId, string targetActorId) + { + return new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(payload), + Route = EnvelopeRouteSemantics.CreateDirect("a2a-adapter", targetActorId), + Propagation = new EnvelopePropagation + { + CorrelationId = correlationId, + }, + }; + } +} diff --git a/src/Aevatar.Interop.A2A.Application/Aevatar.Interop.A2A.Application.csproj b/src/Aevatar.Interop.A2A.Application/Aevatar.Interop.A2A.Application.csproj new file mode 100644 index 00000000..911ddb09 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Application/Aevatar.Interop.A2A.Application.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + Aevatar.Interop.A2A.Application + Aevatar.Interop.A2A.Application + + + + + + + + + + diff --git a/src/Aevatar.Interop.A2A.Application/InMemoryA2ATaskStore.cs b/src/Aevatar.Interop.A2A.Application/InMemoryA2ATaskStore.cs new file mode 100644 index 00000000..f8ca8472 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Application/InMemoryA2ATaskStore.cs @@ -0,0 +1,151 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; + +namespace Aevatar.Interop.A2A.Application; + +/// In-memory implementation of the A2A Task store. For development/testing use only. +public sealed class InMemoryA2ATaskStore : IA2ATaskStore +{ + private readonly ConcurrentDictionary _tasks = new(); + private readonly ConcurrentDictionary>> _subscribers = new(); + + public Task CreateTaskAsync(string taskId, string? sessionId, Message message, CancellationToken ct = default) + { + var task = new A2ATask + { + Id = taskId, + SessionId = sessionId, + Status = new A2ATaskStatus + { + State = TaskState.Submitted, + Timestamp = DateTime.UtcNow.ToString("O"), + }, + History = [message], + }; + + if (!_tasks.TryAdd(taskId, task)) + throw new InvalidOperationException($"Task '{taskId}' already exists."); + + return Task.FromResult(task); + } + + public Task GetTaskAsync(string taskId, CancellationToken ct = default) + { + _tasks.TryGetValue(taskId, out var task); + return Task.FromResult(task); + } + + public Task UpdateTaskStateAsync(string taskId, TaskState state, Message? message = null, CancellationToken ct = default) + { + if (!_tasks.TryGetValue(taskId, out var task)) + throw new KeyNotFoundException($"Task '{taskId}' not found."); + + task.Status = new A2ATaskStatus + { + State = state, + Message = message, + Timestamp = DateTime.UtcNow.ToString("O"), + }; + + if (message != null) + { + task.History ??= []; + task.History.Add(message); + } + + var isFinal = state is TaskState.Completed or TaskState.Failed or TaskState.Canceled; + NotifySubscribers(taskId, new TaskStateUpdate + { + Status = task.Status, + IsFinal = isFinal, + }); + + return Task.FromResult(task); + } + + public Task AddArtifactAsync(string taskId, Artifact artifact, CancellationToken ct = default) + { + if (!_tasks.TryGetValue(taskId, out var task)) + throw new KeyNotFoundException($"Task '{taskId}' not found."); + + task.Artifacts ??= []; + task.Artifacts.Add(artifact); + + NotifySubscribers(taskId, new TaskStateUpdate + { + Status = task.Status, + Artifact = artifact, + }); + + return Task.FromResult(task); + } + + public Task DeleteTaskAsync(string taskId, CancellationToken ct = default) + { + return Task.FromResult(_tasks.TryRemove(taskId, out _)); + } + + public ChannelReader SubscribeAsync(string taskId) + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(64) + { + FullMode = BoundedChannelFullMode.DropOldest, + }); + + // Check current state under subscriber lock to avoid race with UpdateTaskStateAsync. + // If task is already terminal, send the final status and complete immediately. + var subscribers = _subscribers.GetOrAdd(taskId, _ => []); + lock (subscribers) + { + if (_tasks.TryGetValue(taskId, out var task) && + task.Status.State is TaskState.Completed or TaskState.Failed or TaskState.Canceled) + { + channel.Writer.TryWrite(new TaskStateUpdate + { + Status = task.Status, + IsFinal = true, + }); + channel.Writer.TryComplete(); + return channel.Reader; + } + + subscribers.Add(channel); + } + + return channel.Reader; + } + + private void NotifySubscribers(string taskId, TaskStateUpdate update) + { + if (!_subscribers.TryGetValue(taskId, out var subscribers)) + return; + + lock (subscribers) + { + for (var i = subscribers.Count - 1; i >= 0; i--) + { + var wrote = subscribers[i].Writer.TryWrite(update); + if (!wrote) + { + // Channel full or completed — drop this subscriber + subscribers[i].Writer.TryComplete(); + subscribers.RemoveAt(i); + continue; + } + + if (update.IsFinal) + { + // Successfully wrote the final update — now complete the channel + subscribers[i].Writer.TryComplete(); + } + } + + if (update.IsFinal) + { + subscribers.Clear(); + } + } + } +} diff --git a/src/Aevatar.Interop.A2A.Hosting/A2AEndpoints.cs b/src/Aevatar.Interop.A2A.Hosting/A2AEndpoints.cs new file mode 100644 index 00000000..13423094 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Hosting/A2AEndpoints.cs @@ -0,0 +1,212 @@ +// ───────────────────────────────────────────────────────────── +// A2AEndpoints — HTTP endpoints for the A2A protocol +// +// /.well-known/agent.json — Agent Card discovery +// /a2a — JSON-RPC 2.0 dispatch (tasks/send, tasks/get, tasks/cancel) +// /a2a/subscribe/{taskId} — SSE streaming delivery (tasks/sendSubscribe) +// ───────────────────────────────────────────────────────────── + +using System.Text; +using System.Text.Json; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Aevatar.Interop.A2A.Hosting; + +public static class A2AEndpoints +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + public static IEndpointRouteBuilder MapA2AEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/.well-known/agent.json", HandleAgentCardAsync) + .WithTags("A2A") + .Produces(); + + app.MapPost("/a2a", HandleJsonRpcAsync) + .WithTags("A2A") + .Accepts("application/json") + .Produces(); + + app.MapGet("/a2a/subscribe/{taskId}", HandleSubscribeAsync) + .WithTags("A2A"); + + return app; + } + + private static IResult HandleAgentCardAsync(HttpContext context, IA2AAdapterService adapter) + { + var request = context.Request; + var baseUrl = $"{request.Scheme}://{request.Host}"; + var card = adapter.GetAgentCard(baseUrl); + return Results.Json(card, JsonOptions); + } + + private static async Task HandleJsonRpcAsync( + HttpContext context, + IA2AAdapterService adapter) + { + JsonRpcRequest? rpcRequest; + try + { + rpcRequest = await JsonSerializer.DeserializeAsync( + context.Request.Body, JsonOptions, context.RequestAborted); + } + catch (JsonException) + { + return Results.Json( + JsonRpcResponse.Fail(null, A2AErrorCodes.ParseError, "Parse error"), + JsonOptions); + } + + if (rpcRequest == null || string.IsNullOrWhiteSpace(rpcRequest.Method)) + { + return Results.Json( + JsonRpcResponse.Fail(null, A2AErrorCodes.InvalidRequest, "Invalid request"), + JsonOptions); + } + + try + { + var result = rpcRequest.Method switch + { + "tasks/send" => await HandleTasksSendAsync(rpcRequest, adapter, context.RequestAborted), + "tasks/get" => await HandleTasksGetAsync(rpcRequest, adapter, context.RequestAborted), + "tasks/cancel" => await HandleTasksCancelAsync(rpcRequest, adapter, context.RequestAborted), + _ => JsonRpcResponse.Fail(rpcRequest.Id, A2AErrorCodes.MethodNotFound, + $"Method '{rpcRequest.Method}' not found"), + }; + return Results.Json(result, JsonOptions); + } + catch (KeyNotFoundException ex) + { + return Results.Json( + JsonRpcResponse.Fail(rpcRequest.Id, A2AErrorCodes.TaskNotFound, ex.Message), + JsonOptions); + } + catch (InvalidOperationException ex) + { + return Results.Json( + JsonRpcResponse.Fail(rpcRequest.Id, A2AErrorCodes.TaskNotCancelable, ex.Message), + JsonOptions); + } + catch (ArgumentException ex) + { + return Results.Json( + JsonRpcResponse.Fail(rpcRequest.Id, A2AErrorCodes.InvalidParams, ex.Message), + JsonOptions); + } + catch (Exception ex) + { + return Results.Json( + JsonRpcResponse.Fail(rpcRequest.Id, A2AErrorCodes.InternalError, ex.Message), + JsonOptions); + } + } + + private static async Task HandleTasksSendAsync( + JsonRpcRequest rpc, IA2AAdapterService adapter, CancellationToken ct) + { + var sendParams = DeserializeParams(rpc); + var task = await adapter.SendTaskAsync(sendParams, ct); + return JsonRpcResponse.Success(rpc.Id, task); + } + + private static async Task HandleTasksGetAsync( + JsonRpcRequest rpc, IA2AAdapterService adapter, CancellationToken ct) + { + var queryParams = DeserializeParams(rpc); + var task = await adapter.GetTaskAsync(queryParams, ct); + if (task == null) + return JsonRpcResponse.Fail(rpc.Id, A2AErrorCodes.TaskNotFound, $"Task '{queryParams.Id}' not found"); + return JsonRpcResponse.Success(rpc.Id, task); + } + + private static async Task HandleTasksCancelAsync( + JsonRpcRequest rpc, IA2AAdapterService adapter, CancellationToken ct) + { + var idParams = DeserializeParams(rpc); + var task = await adapter.CancelTaskAsync(idParams, ct); + return JsonRpcResponse.Success(rpc.Id, task); + } + + private static async Task HandleSubscribeAsync( + HttpContext context, + string taskId, + IA2AAdapterService adapter, + IA2ATaskStore taskStore) + { + var ct = context.RequestAborted; + + // Verify task exists + var queryParams = new TaskQueryParams { Id = taskId }; + var task = await adapter.GetTaskAsync(queryParams, ct); + if (task == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Set SSE headers + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.Headers.ContentType = "text/event-stream; charset=utf-8"; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + await context.Response.StartAsync(ct); + + var reader = taskStore.SubscribeAsync(taskId); + + // Subscribe before sending the first event so a transition that happens + // during stream startup is still observed by the reader. + await WriteSseEventAsync(context.Response, "status", task.Status, ct); + + // If task is already in terminal state, close the stream + if (task.Status.State is TaskState.Completed or TaskState.Failed or TaskState.Canceled) + { + await WriteSseEventAsync(context.Response, "close", new { reason = "terminal_state" }, ct); + return; + } + + try + { + await foreach (var update in reader.ReadAllAsync(ct)) + { + await WriteSseEventAsync(context.Response, "status", update.Status, ct); + + if (update.Artifact != null) + await WriteSseEventAsync(context.Response, "artifact", update.Artifact, ct); + + if (update.IsFinal) + { + await WriteSseEventAsync(context.Response, "close", new { reason = "terminal_state" }, ct); + break; + } + } + } + catch (OperationCanceledException) { /* client disconnected */ } + } + + private static async Task WriteSseEventAsync(HttpResponse response, string eventType, object data, CancellationToken ct) + { + var json = JsonSerializer.Serialize(data, JsonOptions); + var bytes = Encoding.UTF8.GetBytes($"event: {eventType}\ndata: {json}\n\n"); + await response.Body.WriteAsync(bytes, ct); + await response.Body.FlushAsync(ct); + } + + private static T DeserializeParams(JsonRpcRequest rpc) + { + if (!rpc.Params.HasValue || rpc.Params.Value.ValueKind == JsonValueKind.Null) + throw new ArgumentException("Missing required params."); + + return JsonSerializer.Deserialize(rpc.Params.Value.GetRawText(), JsonOptions) + ?? throw new ArgumentException($"Failed to deserialize params as {typeof(T).Name}."); + } +} diff --git a/src/Aevatar.Interop.A2A.Hosting/A2AServiceCollectionExtensions.cs b/src/Aevatar.Interop.A2A.Hosting/A2AServiceCollectionExtensions.cs new file mode 100644 index 00000000..abffb36a --- /dev/null +++ b/src/Aevatar.Interop.A2A.Hosting/A2AServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Application; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.Interop.A2A.Hosting; + +public static class A2AServiceCollectionExtensions +{ + /// + /// Registers the services required by the A2A protocol adapter layer. + /// Prerequisite: the host must have already registered IActorDispatchPort (provided by the Foundation Runtime). + /// + public static IServiceCollection AddA2AAdapter(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddScoped(); + return services; + } +} diff --git a/src/Aevatar.Interop.A2A.Hosting/Aevatar.Interop.A2A.Hosting.csproj b/src/Aevatar.Interop.A2A.Hosting/Aevatar.Interop.A2A.Hosting.csproj new file mode 100644 index 00000000..3e36c4d8 --- /dev/null +++ b/src/Aevatar.Interop.A2A.Hosting/Aevatar.Interop.A2A.Hosting.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + Aevatar.Interop.A2A.Hosting + Aevatar.Interop.A2A.Hosting + + + + + + + + + + diff --git a/test/Aevatar.AI.Tests/AgentToolBaseTests.cs b/test/Aevatar.AI.Tests/AgentToolBaseTests.cs new file mode 100644 index 00000000..b2142a67 --- /dev/null +++ b/test/Aevatar.AI.Tests/AgentToolBaseTests.cs @@ -0,0 +1,171 @@ +using System.ComponentModel; +using System.Text.Json; +using Aevatar.AI.Abstractions.ToolProviders; +using FluentAssertions; + +namespace Aevatar.AI.Tests; + +public class AgentToolBaseTests +{ + // ─── Test parameter type ─── + + private class SearchParams + { + [Description("The search query")] + public string Query { get; set; } = ""; + + public int MaxResults { get; set; } = 10; + } + + // ─── Test tool implementation ─── + + private class TestSearchTool : AgentToolBase + { + public override string Name => "test_search"; + public override string Description => "A test search tool"; + public override bool IsReadOnly => true; + + public SearchParams? LastParams { get; private set; } + + protected override Task ExecuteAsync(SearchParams parameters, CancellationToken ct) + { + LastParams = parameters; + return Task.FromResult($$"""{"results":[],"query":"{{parameters.Query}}","max":{{parameters.MaxResults}}}"""); + } + } + + private class FailingTool : AgentToolBase + { + public override string Name => "failing_tool"; + public override string Description => "A tool that throws"; + + protected override Task ExecuteAsync(SearchParams parameters, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + // ─── ParametersSchema tests ─── + + [Fact] + public void ParametersSchema_AutoGeneratedFromType() + { + var tool = new TestSearchTool(); + + tool.ParametersSchema.Should().NotBeNullOrWhiteSpace(); + + var doc = JsonDocument.Parse(tool.ParametersSchema); + var root = doc.RootElement; + + root.GetProperty("type").GetString().Should().Be("object"); + var props = root.GetProperty("properties"); + props.TryGetProperty("query", out _).Should().BeTrue("snake_case naming applied"); + props.TryGetProperty("max_results", out _).Should().BeTrue("snake_case naming applied"); + } + + [Fact] + public void ParametersSchema_IsCachedAcrossInstances() + { + var tool1 = new TestSearchTool(); + var tool2 = new TestSearchTool(); + + // Same string reference (static cached) + ReferenceEquals(tool1.ParametersSchema, tool2.ParametersSchema).Should().BeTrue(); + } + + // ─── ExecuteAsync deserialization tests ─── + + [Fact] + public async Task ExecuteAsync_ValidJson_DeserializesAndExecutes() + { + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync("""{"query":"hello","max_results":5}"""); + + result.Should().Contain("hello"); + tool.LastParams.Should().NotBeNull(); + tool.LastParams!.Query.Should().Be("hello"); + tool.LastParams.MaxResults.Should().Be(5); + } + + [Fact] + public async Task ExecuteAsync_CaseInsensitive_Deserializes() + { + // PropertyNameCaseInsensitive handles case variations of the snake_case names + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync("""{"QUERY":"test","MAX_RESULTS":3}"""); + + tool.LastParams.Should().NotBeNull(); + tool.LastParams!.Query.Should().Be("test"); + tool.LastParams.MaxResults.Should().Be(3); + } + + [Fact] + public async Task ExecuteAsync_InvalidJson_ReturnsError() + { + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync("not valid json"); + + result.Should().Contain("error").And.Contain("Invalid parameters"); + } + + [Fact] + public async Task ExecuteAsync_EmptyString_ReturnsError() + { + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync(""); + + result.Should().Contain("error").And.Contain("Parameters are required"); + } + + [Fact] + public async Task ExecuteAsync_NullString_ReturnsError() + { + var tool = new TestSearchTool(); + var result = await ((IAgentTool)tool).ExecuteAsync(null!); + + result.Should().Contain("error").And.Contain("Parameters are required"); + } + + // ─── IAgentTool contract tests ─── + + [Fact] + public void Implements_IAgentTool_Contract() + { + IAgentTool tool = new TestSearchTool(); + + tool.Name.Should().Be("test_search"); + tool.Description.Should().Be("A test search tool"); + tool.ParametersSchema.Should().NotBeNullOrWhiteSpace(); + tool.IsReadOnly.Should().BeTrue(); + tool.IsDestructive.Should().BeFalse(); + tool.ApprovalMode.Should().Be(ToolApprovalMode.NeverRequire); + tool.RequiresApproval("{}").Should().BeNull(); + } + + [Fact] + public async Task ExecuteAsync_WhitespaceOnly_ReturnsError() + { + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync(" "); + + result.Should().Contain("error").And.Contain("Parameters are required"); + } + + [Fact] + public async Task ExecuteAsync_WithExtraProperties_IgnoresAndExecutes() + { + var tool = new TestSearchTool(); + var result = await tool.ExecuteAsync("""{"query":"test","max_results":2,"unknown_field":"ignored"}"""); + + tool.LastParams.Should().NotBeNull(); + tool.LastParams!.Query.Should().Be("test"); + tool.LastParams.MaxResults.Should().Be(2); + } + + [Fact] + public async Task ExecuteAsync_ToolThrows_PropagatesException() + { + var tool = new FailingTool(); + var act = () => tool.ExecuteAsync("""{"query":"x"}"""); + + await act.Should().ThrowAsync().WithMessage("boom"); + } +} diff --git a/test/Aevatar.AI.Tests/AgentToolSchemaGeneratorTests.cs b/test/Aevatar.AI.Tests/AgentToolSchemaGeneratorTests.cs new file mode 100644 index 00000000..f4b90e11 --- /dev/null +++ b/test/Aevatar.AI.Tests/AgentToolSchemaGeneratorTests.cs @@ -0,0 +1,192 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Text.Json; +using Aevatar.AI.Abstractions.ToolProviders; +using FluentAssertions; + +namespace Aevatar.AI.Tests; + +public class AgentToolSchemaGeneratorTests +{ + // ─── Test parameter types ─── + + private class SimpleParams + { + public string Query { get; set; } = ""; + public int MaxResults { get; set; } + } + + private class OptionalParams + { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public int? Limit { get; set; } + } + + private class NestedParams + { + public string Id { get; set; } = ""; + public InnerConfig Config { get; set; } = new(); + + public class InnerConfig + { + public bool Enabled { get; set; } + public double Threshold { get; set; } + } + } + + private class EmptyParams { } + + private class ArrayParams + { + public string[] Tags { get; set; } = []; + public List Scores { get; set; } = []; + } + + private class DescribedParams + { + [Description("The search query to execute")] + public string Query { get; set; } = ""; + + [Description("Maximum number of results to return")] + public int MaxResults { get; set; } + } + + // ─── GenerateSchemaString tests ─── + + [Fact] + public void GenerateSchemaString_SimpleType_ReturnsValidJsonSchema() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + + schema.Should().NotBeNullOrWhiteSpace(); + var doc = JsonDocument.Parse(schema); + var root = doc.RootElement; + + root.GetProperty("type").GetString().Should().Be("object"); + root.GetProperty("properties").EnumerateObject().Should().HaveCount(2); + } + + [Fact] + public void GenerateSchemaString_UsesSnakeCaseNaming() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + var props = doc.RootElement.GetProperty("properties"); + + props.TryGetProperty("query", out _).Should().BeTrue(); + props.TryGetProperty("max_results", out _).Should().BeTrue(); + // Original PascalCase should not appear + props.TryGetProperty("Query", out _).Should().BeFalse(); + props.TryGetProperty("MaxResults", out _).Should().BeFalse(); + } + + [Fact] + public void GenerateSchemaString_EmptyType_ReturnsObjectSchema() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + + doc.RootElement.GetProperty("type").GetString().Should().Be("object"); + } + + [Fact] + public void GenerateSchemaString_ArrayProperties_IncludesArrayType() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + var props = doc.RootElement.GetProperty("properties"); + + props.GetProperty("tags").GetProperty("type").GetString().Should().Be("array"); + props.GetProperty("scores").GetProperty("type").GetString().Should().Be("array"); + } + + [Fact] + public void GenerateSchemaString_NestedType_IncludesNestedProperties() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + var props = doc.RootElement.GetProperty("properties"); + + props.TryGetProperty("id", out _).Should().BeTrue(); + props.TryGetProperty("config", out _).Should().BeTrue(); + } + + [Fact] + public void GenerateSchemaString_DescribedType_GeneratesValidSchema() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + var props = doc.RootElement.GetProperty("properties"); + + // Properties exist with correct snake_case naming + props.TryGetProperty("query", out var queryProp).Should().BeTrue(); + queryProp.GetProperty("type").GetString().Should().Be("string"); + props.TryGetProperty("max_results", out var maxProp).Should().BeTrue(); + maxProp.GetProperty("type").GetString().Should().Be("integer"); + } + + // ─── GenerateSchema (JsonElement) tests ─── + + [Fact] + public void GenerateSchema_ReturnsUsableJsonElement() + { + var schema = AgentToolSchemaGenerator.GenerateSchema(); + + schema.ValueKind.Should().Be(JsonValueKind.Object); + schema.GetProperty("type").GetString().Should().Be("object"); + schema.GetProperty("properties").EnumerateObject().Should().HaveCount(2); + } + + [Fact] + public void GenerateSchema_TypeOverload_MatchesGenericOverload() + { + var generic = AgentToolSchemaGenerator.GenerateSchemaString(); + var typed = AgentToolSchemaGenerator.GenerateSchemaString(typeof(SimpleParams)); + + generic.Should().Be(typed); + } + + [Fact] + public void GenerateSchema_ElementAndString_AreConsistent() + { + var schemaString = AgentToolSchemaGenerator.GenerateSchemaString(); + var schemaElement = AgentToolSchemaGenerator.GenerateSchema(); + + var fromString = JsonDocument.Parse(schemaString).RootElement; + fromString.GetProperty("type").GetString() + .Should().Be(schemaElement.GetProperty("type").GetString()); + fromString.GetProperty("properties").EnumerateObject().Count() + .Should().Be(schemaElement.GetProperty("properties").EnumerateObject().Count()); + } + + [Fact] + public void GenerateSchemaString_ConcurrentAccess_ProducesSameResult() + { + var results = new ConcurrentBag(); + + Parallel.For(0, 10, _ => + { + results.Add(AgentToolSchemaGenerator.GenerateSchemaString()); + }); + + results.Distinct().Should().HaveCount(1, "all concurrent calls should return the same cached schema"); + } + + private class NullableParams + { + public string? Name { get; set; } + public int? Value { get; set; } + } + + [Fact] + public void GenerateSchemaString_NullableProperties_GeneratesValidSchema() + { + var schema = AgentToolSchemaGenerator.GenerateSchemaString(); + var doc = JsonDocument.Parse(schema); + + doc.RootElement.GetProperty("type").GetString().Should().Be("object"); + doc.RootElement.GetProperty("properties").TryGetProperty("name", out _).Should().BeTrue(); + doc.RootElement.GetProperty("properties").TryGetProperty("value", out _).Should().BeTrue(); + } +} diff --git a/test/Aevatar.AI.Tests/LLMResponseFormatTests.cs b/test/Aevatar.AI.Tests/LLMResponseFormatTests.cs new file mode 100644 index 00000000..d9406227 --- /dev/null +++ b/test/Aevatar.AI.Tests/LLMResponseFormatTests.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using Aevatar.AI.Abstractions.LLMProviders; +using FluentAssertions; + +namespace Aevatar.AI.Tests; + +public class LLMResponseFormatTests +{ + // ─── Static instances ─── + + [Fact] + public void Text_IsSingleton() + { + var a = LLMResponseFormat.Text; + var b = LLMResponseFormat.Text; + + a.Should().BeSameAs(b); + a.Kind.Should().Be(LLMResponseFormatKind.Text); + } + + [Fact] + public void JsonObject_IsSingleton() + { + var a = LLMResponseFormat.JsonObject; + var b = LLMResponseFormat.JsonObject; + + a.Should().BeSameAs(b); + a.Kind.Should().Be(LLMResponseFormatKind.JsonObject); + } + + // ─── ForJsonSchema(JsonElement) ─── + + [Fact] + public void ForJsonSchema_WithJsonElement_CreatesJsonSchemaFormat() + { + var schemaJson = """{"type":"object","properties":{"score":{"type":"number"}}}"""; + var schema = JsonDocument.Parse(schemaJson).RootElement.Clone(); + + var format = LLMResponseFormat.ForJsonSchema(schema, "EvalResult", "Evaluation result"); + + format.Kind.Should().Be(LLMResponseFormatKind.JsonSchema); + format.Should().BeOfType(); + + var jsonSchema = (LLMResponseFormatJsonSchema)format; + jsonSchema.Schema.GetProperty("type").GetString().Should().Be("object"); + jsonSchema.SchemaName.Should().Be("EvalResult"); + jsonSchema.SchemaDescription.Should().Be("Evaluation result"); + } + + [Fact] + public void ForJsonSchema_WithoutOptionalParams_DefaultsToNull() + { + var schema = JsonDocument.Parse("{}").RootElement.Clone(); + var format = (LLMResponseFormatJsonSchema)LLMResponseFormat.ForJsonSchema(schema); + + format.SchemaName.Should().BeNull(); + format.SchemaDescription.Should().BeNull(); + } + + // ─── ForJsonSchema() ─── + + private class EvalResponse + { + public double Score { get; set; } + public string Reason { get; set; } = ""; + } + + [Fact] + public void ForJsonSchema_Generic_AutoGeneratesSchema() + { + var format = LLMResponseFormat.ForJsonSchema(); + + format.Kind.Should().Be(LLMResponseFormatKind.JsonSchema); + var jsonSchema = (LLMResponseFormatJsonSchema)format; + + jsonSchema.SchemaName.Should().Be("EvalResponse"); + jsonSchema.Schema.GetProperty("type").GetString().Should().Be("object"); + jsonSchema.Schema.GetProperty("properties").EnumerateObject().Should().HaveCountGreaterThanOrEqualTo(2); + } + + [Fact] + public void ForJsonSchema_Generic_WithCustomName() + { + var format = LLMResponseFormat.ForJsonSchema("custom_name", "custom description"); + + var jsonSchema = (LLMResponseFormatJsonSchema)format; + jsonSchema.SchemaName.Should().Be("custom_name"); + jsonSchema.SchemaDescription.Should().Be("custom description"); + } + + // ─── LLMRequest.ResponseFormat propagation ─── + + [Fact] + public void LLMRequest_ResponseFormat_IsOptional() + { + var request = new LLMRequest + { + Messages = [ChatMessage.User("hi")], + }; + + request.ResponseFormat.Should().BeNull(); + } + + [Fact] + public void LLMRequest_ResponseFormat_CanBeSet() + { + var request = new LLMRequest + { + Messages = [ChatMessage.User("hi")], + ResponseFormat = LLMResponseFormat.ForJsonSchema(), + }; + + request.ResponseFormat.Should().NotBeNull(); + request.ResponseFormat!.Kind.Should().Be(LLMResponseFormatKind.JsonSchema); + } + + // ─── SanitizeTypeName tests ─── + + private class SimpleType { } + + [Fact] + public void ForJsonSchema_Generic_SanitizesGenericSuffix() + { + // List has CLR name "List`1" — the backtick must be removed + var format = LLMResponseFormat.ForJsonSchema>(); + var jsonSchema = (LLMResponseFormatJsonSchema)format; + + jsonSchema.SchemaName.Should().NotContain("`"); + jsonSchema.SchemaName.Should().Be("List"); + } + + [Fact] + public void ForJsonSchema_SimpleType_UsesTypeName() + { + var format = LLMResponseFormat.ForJsonSchema(); + var jsonSchema = (LLMResponseFormatJsonSchema)format; + + jsonSchema.SchemaName.Should().Be("SimpleType"); + } + + [Fact] + public void ForJsonSchema_SchemaIsClonedAndIndependent() + { + var schemaJson = """{"type":"object","properties":{"x":{"type":"number"}}}"""; + using var doc = JsonDocument.Parse(schemaJson); + var original = doc.RootElement; + + var format = LLMResponseFormat.ForJsonSchema(original, "Test"); + var jsonSchema = (LLMResponseFormatJsonSchema)format; + + // The schema should survive after the source document is disposed + jsonSchema.Schema.GetProperty("type").GetString().Should().Be("object"); + } +} diff --git a/test/Aevatar.Interop.A2A.Tests/A2AAdapterServiceTests.cs b/test/Aevatar.Interop.A2A.Tests/A2AAdapterServiceTests.cs new file mode 100644 index 00000000..134da929 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/A2AAdapterServiceTests.cs @@ -0,0 +1,309 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.MultiAgent; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using Aevatar.Interop.A2A.Application; +using FluentAssertions; + +namespace Aevatar.Interop.A2A.Tests; + +public class A2AAdapterServiceTests +{ + private readonly StubDispatchPort _dispatchPort = new(); + private readonly InMemoryA2ATaskStore _taskStore = new(); + private readonly A2AAdapterService _adapter; + + public A2AAdapterServiceTests() + { + _adapter = new A2AAdapterService(_dispatchPort, _taskStore); + } + + private static Message MakeUserMessage(string text) => new() + { + Role = "user", + Parts = [new TextPart { Text = text }], + }; + + // ─── SendTask tests ─── + + [Fact] + public async Task SendTask_WithAgentId_DispatchesAndSetsWorking() + { + var sendParams = new TaskSendParams + { + Id = "task-1", + Message = MakeUserMessage("Hello agent"), + Metadata = new() { ["agentId"] = "actor-123" }, + }; + + var task = await _adapter.SendTaskAsync(sendParams); + + task.Id.Should().Be("task-1"); + task.Status.State.Should().Be(TaskState.Working); + _dispatchPort.DispatchedCount.Should().Be(1); + _dispatchPort.LastTargetActorId.Should().Be("actor-123"); + } + + [Fact] + public async Task SendTask_WithSessionId_UsesAsActorId() + { + var sendParams = new TaskSendParams + { + Id = "task-2", + SessionId = "session-actor-456", + Message = MakeUserMessage("Hi"), + }; + + var task = await _adapter.SendTaskAsync(sendParams); + + task.Status.State.Should().Be(TaskState.Working); + _dispatchPort.LastTargetActorId.Should().Be("session-actor-456"); + } + + [Fact] + public async Task SendTask_NoTargetId_Throws() + { + var sendParams = new TaskSendParams + { + Id = "task-3", + Message = MakeUserMessage("Hi"), + }; + + var act = () => _adapter.SendTaskAsync(sendParams); + await act.Should().ThrowAsync().WithMessage("*agentId*"); + } + + [Fact] + public async Task SendTask_EmptyMessage_Throws() + { + var sendParams = new TaskSendParams + { + Id = "task-4", + Message = new Message { Role = "user", Parts = [] }, + Metadata = new() { ["agentId"] = "actor-1" }, + }; + + var act = () => _adapter.SendTaskAsync(sendParams); + await act.Should().ThrowAsync().WithMessage("*text part*"); + } + + [Fact] + public async Task SendTask_DispatchFails_SetsFailedState() + { + _dispatchPort.ShouldThrow = true; + var sendParams = new TaskSendParams + { + Id = "task-5", + Message = MakeUserMessage("Hi"), + Metadata = new() { ["agentId"] = "actor-1" }, + }; + + var task = await _adapter.SendTaskAsync(sendParams); + + task.Status.State.Should().Be(TaskState.Failed); + } + + // ─── GetTask tests ─── + + [Fact] + public async Task GetTask_Existing_ReturnsTask() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t1", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var task = await _adapter.GetTaskAsync(new TaskQueryParams { Id = "t1" }); + task.Should().NotBeNull(); + task!.Id.Should().Be("t1"); + } + + [Fact] + public async Task GetTask_NonExistent_ReturnsNull() + { + var task = await _adapter.GetTaskAsync(new TaskQueryParams { Id = "missing" }); + task.Should().BeNull(); + } + + [Fact] + public async Task GetTask_WithHistoryLength_TruncatesHistory() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t1", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var task = await _adapter.GetTaskAsync(new TaskQueryParams { Id = "t1", HistoryLength = 0 }); + task!.History.Should().BeEmpty(); + } + + // ─── CancelTask tests ─── + + [Fact] + public async Task CancelTask_WorkingTask_SetsCanceled() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t1", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var task = await _adapter.CancelTaskAsync(new TaskIdParams { Id = "t1" }); + task.Status.State.Should().Be(TaskState.Canceled); + } + + [Fact] + public async Task CancelTask_NonExistent_Throws() + { + var act = () => _adapter.CancelTaskAsync(new TaskIdParams { Id = "missing" }); + await act.Should().ThrowAsync(); + } + + // ─── AgentCard tests ─── + + [Fact] + public void GetAgentCard_ReturnsValidCard() + { + var card = _adapter.GetAgentCard("https://example.com"); + + card.Name.Should().NotBeNullOrWhiteSpace(); + card.Url.Should().Be("https://example.com/a2a"); + card.Capabilities.Streaming.Should().BeTrue(); + card.Capabilities.StateTransitionHistory.Should().BeTrue(); + card.Skills.Should().NotBeEmpty(); + } + + [Fact] + public async Task SendTask_MultipleTextParts_JoinsWithNewline() + { + var sendParams = new TaskSendParams + { + Id = "task-multi", + Message = new Message + { + Role = "user", + Parts = [new TextPart { Text = "Hello" }, new TextPart { Text = "World" }], + }, + Metadata = new() { ["agentId"] = "actor-1" }, + }; + + var task = await _adapter.SendTaskAsync(sendParams); + + task.Status.State.Should().Be(TaskState.Working); + _dispatchPort.LastPayloadContent.Should().Be("Hello\nWorld"); + } + + [Fact] + public async Task GetTask_WithNegativeHistoryLength_ReturnsAllHistory() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t-neg", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var task = await _adapter.GetTaskAsync(new TaskQueryParams { Id = "t-neg", HistoryLength = -1 }); + task!.History.Should().NotBeEmpty("negative historyLength should not trim"); + } + + [Fact] + public async Task GetTask_WithHistoryLengthExceedingCount_ReturnsAllHistory() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t-large", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var task = await _adapter.GetTaskAsync(new TaskQueryParams { Id = "t-large", HistoryLength = 100 }); + task!.History.Should().HaveCount(1, "historyLength > count returns all"); + } + + [Fact] + public async Task CancelTask_CompletedTask_Throws() + { + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t-done", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + await _taskStore.UpdateTaskStateAsync("t-done", TaskState.Completed); + + var act = () => _adapter.CancelTaskAsync(new TaskIdParams { Id = "t-done" }); + await act.Should().ThrowAsync().WithMessage("*terminal*"); + } + + [Fact] + public async Task CancelTask_FailedTask_Throws() + { + _dispatchPort.ShouldThrow = true; + await _adapter.SendTaskAsync(new TaskSendParams + { + Id = "t-fail", + Message = MakeUserMessage("Hello"), + Metadata = new() { ["agentId"] = "a1" }, + }); + + var act = () => _adapter.CancelTaskAsync(new TaskIdParams { Id = "t-fail" }); + await act.Should().ThrowAsync().WithMessage("*terminal*"); + } + + [Fact] + public async Task SendTask_DispatchFails_PreservesExceptionMessage() + { + _dispatchPort.ShouldThrow = true; + var sendParams = new TaskSendParams + { + Id = "task-err", + Message = MakeUserMessage("Hi"), + Metadata = new() { ["agentId"] = "actor-1" }, + }; + + var task = await _adapter.SendTaskAsync(sendParams); + + task.Status.State.Should().Be(TaskState.Failed); + task.Status.Message.Should().NotBeNull(); + var text = ((TextPart)task.Status.Message!.Parts[0]).Text; + text.Should().Contain("Dispatch failed"); + } + + [Fact] + public void GetAgentCard_TrailingSlash_NormalizesUrl() + { + var card = _adapter.GetAgentCard("https://example.com/"); + card.Url.Should().Be("https://example.com/a2a"); + } + + // ─── Stub ─── + + private sealed class StubDispatchPort : IActorDispatchPort + { + public int DispatchedCount { get; private set; } + public string? LastTargetActorId { get; private set; } + public string? LastPayloadContent { get; private set; } + public bool ShouldThrow { get; set; } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + if (ShouldThrow) throw new InvalidOperationException("Dispatch failed"); + DispatchedCount++; + LastTargetActorId = actorId; + + if (envelope.Payload != null) + { + var agentMessage = envelope.Payload.Unpack(); + LastPayloadContent = agentMessage.Content; + } + + return Task.CompletedTask; + } + } +} diff --git a/test/Aevatar.Interop.A2A.Tests/A2AEndpointsTests.cs b/test/Aevatar.Interop.A2A.Tests/A2AEndpointsTests.cs new file mode 100644 index 00000000..6836c0a3 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/A2AEndpointsTests.cs @@ -0,0 +1,376 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.MultiAgent; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using Aevatar.Interop.A2A.Application; +using Aevatar.Interop.A2A.Hosting; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Interop.A2A.Tests; + +public class A2AEndpointsTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + private readonly TestServer _server; + private readonly HttpClient _client; + private readonly StubDispatchPort _dispatchPort = new(); + private readonly InMemoryA2ATaskStore _taskStore = new(); + + public A2AEndpointsTests() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(_dispatchPort); + builder.Services.AddSingleton(_taskStore); + builder.Services.AddScoped(); + + var app = builder.Build(); + app.MapA2AEndpoints(); + app.StartAsync().GetAwaiter().GetResult(); + + _server = app.GetTestServer(); + _client = _server.CreateClient(); + } + + public void Dispose() + { + _client.Dispose(); + _server.Dispose(); + } + + private async Task PostJsonRpcAsync(object request) + { + var json = JsonSerializer.Serialize(request, JsonOptions); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await _client.PostAsync("/a2a", content); + } + + private static async Task ReadSseEventAsync(StreamReader reader) + { + var lines = new List(); + while (true) + { + var line = await reader.ReadLineAsync(); + line.Should().NotBeNull("the SSE stream should emit a complete event"); + if (string.IsNullOrEmpty(line)) + { + break; + } + + lines.Add(line); + } + + return string.Join(Environment.NewLine, lines) + Environment.NewLine + Environment.NewLine; + } + + // ─── Agent Card ─── + + [Fact] + public async Task AgentCard_ReturnsValidJson() + { + var response = await _client.GetAsync("/.well-known/agent.json"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var card = JsonSerializer.Deserialize(body, JsonOptions); + card.Should().NotBeNull(); + card!.Name.Should().NotBeNullOrWhiteSpace(); + card.Url.Should().Contain("/a2a"); + card.Skills.Should().NotBeEmpty(); + } + + // ─── tasks/send ─── + + [Fact] + public async Task TasksSend_ValidRequest_ReturnsWorkingTask() + { + var rpc = new + { + jsonrpc = "2.0", + id = 1, + method = "tasks/send", + @params = new + { + id = "t-1", + message = new { role = "user", parts = new[] { new { type = "text", text = "hello" } } }, + metadata = new Dictionary { ["agentId"] = "actor-1" }, + }, + }; + + var response = await PostJsonRpcAsync(rpc); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"result\""); + body.Should().Contain("working"); + body.Should().NotContain("\"error\""); + } + + [Fact] + public async Task TasksSend_MissingAgentId_ReturnsInvalidParams() + { + var rpc = new + { + jsonrpc = "2.0", + id = 2, + method = "tasks/send", + @params = new + { + id = "t-2", + message = new { role = "user", parts = new[] { new { type = "text", text = "hello" } } }, + }, + }; + + var response = await PostJsonRpcAsync(rpc); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("\"error\""); + body.Should().Contain("-32602"); + } + + // ─── tasks/get ─── + + [Fact] + public async Task TasksGet_ExistingTask_ReturnsTask() + { + await _taskStore.CreateTaskAsync("t-get", null, + new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }); + + var rpc = new { jsonrpc = "2.0", id = 3, method = "tasks/get", @params = new { id = "t-get" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"result\""); + body.Should().Contain("t-get"); + } + + [Fact] + public async Task TasksGet_NonExistent_ReturnsTaskNotFound() + { + var rpc = new { jsonrpc = "2.0", id = 4, method = "tasks/get", @params = new { id = "missing" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32001"); + } + + // ─── tasks/cancel ─── + + [Fact] + public async Task TasksCancel_WorkingTask_ReturnsCanceled() + { + await _taskStore.CreateTaskAsync("t-cancel", null, + new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }); + await _taskStore.UpdateTaskStateAsync("t-cancel", TaskState.Working); + + var rpc = new { jsonrpc = "2.0", id = 5, method = "tasks/cancel", @params = new { id = "t-cancel" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"result\""); + body.Should().Contain("canceled"); + } + + [Fact] + public async Task TasksCancel_CompletedTask_ReturnsNotCancelable() + { + await _taskStore.CreateTaskAsync("t-done", null, + new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }); + await _taskStore.UpdateTaskStateAsync("t-done", TaskState.Completed); + + var rpc = new { jsonrpc = "2.0", id = 6, method = "tasks/cancel", @params = new { id = "t-done" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32002"); + } + + [Fact] + public async Task TasksCancel_NonExistent_ReturnsTaskNotFound() + { + var rpc = new { jsonrpc = "2.0", id = 7, method = "tasks/cancel", @params = new { id = "nope" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32001"); + } + + // ─── Error handling ─── + + [Fact] + public async Task UnknownMethod_ReturnsMethodNotFound() + { + var rpc = new { jsonrpc = "2.0", id = 8, method = "tasks/unknown", @params = new { id = "x" } }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32601"); + } + + [Fact] + public async Task MalformedJson_ReturnsParseError() + { + var content = new StringContent("{not valid json}", Encoding.UTF8, "application/json"); + var response = await _client.PostAsync("/a2a", content); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32700"); + } + + [Fact] + public async Task EmptyMethod_ReturnsInvalidRequest() + { + var rpc = new { jsonrpc = "2.0", id = 9, method = "" }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32600"); + } + + [Fact] + public async Task MissingParams_ReturnsInvalidParams() + { + var rpc = new { jsonrpc = "2.0", id = 10, method = "tasks/get" }; + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"error\""); + body.Should().Contain("-32602"); + } + + [Fact] + public async Task TasksSend_DispatchFails_ReturnsResultWithFailedState() + { + _dispatchPort.ShouldThrow = true; + + var rpc = new + { + jsonrpc = "2.0", + id = 11, + method = "tasks/send", + @params = new + { + id = "t-err", + message = new { role = "user", parts = new[] { new { type = "text", text = "hello" } } }, + metadata = new Dictionary { ["agentId"] = "actor-1" }, + }, + }; + + var response = await PostJsonRpcAsync(rpc); + var body = await response.Content.ReadAsStringAsync(); + + body.Should().Contain("\"result\""); + body.Should().Contain("failed"); + } + + // ─── SSE subscribe ─── + + [Fact] + public async Task Subscribe_NonExistentTask_Returns404() + { + var response = await _client.GetAsync("/a2a/subscribe/nonexistent"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Subscribe_CompletedTask_ReturnsStatusAndCloses() + { + await _taskStore.CreateTaskAsync("t-sse", null, + new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }); + await _taskStore.UpdateTaskStateAsync("t-sse", TaskState.Completed); + + var response = await _client.GetAsync("/a2a/subscribe/t-sse"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream"); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("event: status"); + body.Should().Contain("event: close"); + body.Should().Contain("terminal_state"); + } + + [Fact] + public async Task Subscribe_WorkingTask_StreamsUpdates() + { + await _taskStore.CreateTaskAsync("t-stream", null, + new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }); + await _taskStore.UpdateTaskStateAsync("t-stream", TaskState.Working); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/a2a/subscribe/t-stream"); + var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + await using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + + var initialEvent = await ReadSseEventAsync(reader); + initialEvent.Should().Contain("event: status"); + initialEvent.Should().Contain("working"); + + await _taskStore.UpdateTaskStateAsync("t-stream", TaskState.Completed); + + var body = initialEvent + await reader.ReadToEndAsync(); + + body.Should().Contain("event: status"); + body.Should().Contain("event: close"); + } + + // ─── DI extension ─── + + [Fact] + public void AddA2AAdapter_RegistersServices() + { + var services = new ServiceCollection(); + services.AddSingleton(new StubDispatchPort()); + services.AddLogging(); + services.AddA2AAdapter(); + + var provider = services.BuildServiceProvider(); + + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddA2AAdapter_DoesNotOverrideExistingRegistrations() + { + var customStore = new InMemoryA2ATaskStore(); + var services = new ServiceCollection(); + services.AddSingleton(new StubDispatchPort()); + services.AddSingleton(customStore); + services.AddLogging(); + services.AddA2AAdapter(); + + var provider = services.BuildServiceProvider(); + provider.GetService().Should().BeSameAs(customStore); + } + + // ─── Stub ─── + + private sealed class StubDispatchPort : IActorDispatchPort + { + public bool ShouldThrow { get; set; } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + if (ShouldThrow) throw new InvalidOperationException("Dispatch failed"); + return Task.CompletedTask; + } + } +} diff --git a/test/Aevatar.Interop.A2A.Tests/Aevatar.Interop.A2A.Tests.csproj b/test/Aevatar.Interop.A2A.Tests/Aevatar.Interop.A2A.Tests.csproj new file mode 100644 index 00000000..05737723 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/Aevatar.Interop.A2A.Tests.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + false + true + Aevatar.Interop.A2A.Tests + Aevatar.Interop.A2A.Tests + + + + + + + + + + + + + + + diff --git a/test/Aevatar.Interop.A2A.Tests/GlobalUsings.cs b/test/Aevatar.Interop.A2A.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Aevatar.Interop.A2A.Tests/InMemoryA2ATaskStoreTests.cs b/test/Aevatar.Interop.A2A.Tests/InMemoryA2ATaskStoreTests.cs new file mode 100644 index 00000000..761055f6 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/InMemoryA2ATaskStoreTests.cs @@ -0,0 +1,243 @@ +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using Aevatar.Interop.A2A.Application; +using FluentAssertions; + +namespace Aevatar.Interop.A2A.Tests; + +public class InMemoryA2ATaskStoreTests +{ + private readonly InMemoryA2ATaskStore _store = new(); + + private static Message MakeMessage(string text) => new() + { + Role = "user", + Parts = [new TextPart { Text = text }], + }; + + [Fact] + public async Task CreateTask_SetsSubmittedState() + { + var task = await _store.CreateTaskAsync("t1", "s1", MakeMessage("hello")); + + task.Id.Should().Be("t1"); + task.SessionId.Should().Be("s1"); + task.Status.State.Should().Be(TaskState.Submitted); + task.History.Should().HaveCount(1); + } + + [Fact] + public async Task CreateTask_DuplicateId_Throws() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("a")); + + var act = () => _store.CreateTaskAsync("t1", null, MakeMessage("b")); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetTask_Existing_ReturnsTask() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var task = await _store.GetTaskAsync("t1"); + task.Should().NotBeNull(); + task!.Id.Should().Be("t1"); + } + + [Fact] + public async Task GetTask_NonExistent_ReturnsNull() + { + var task = await _store.GetTaskAsync("missing"); + task.Should().BeNull(); + } + + [Fact] + public async Task UpdateTaskState_ChangesState() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + + var updated = await _store.UpdateTaskStateAsync("t1", TaskState.Working); + updated.Status.State.Should().Be(TaskState.Working); + + var agentMsg = MakeMessage("Done!"); + var completed = await _store.UpdateTaskStateAsync("t1", TaskState.Completed, new Message + { + Role = "agent", + Parts = [new TextPart { Text = "Done!" }], + }); + completed.Status.State.Should().Be(TaskState.Completed); + completed.History.Should().HaveCount(2); + } + + [Fact] + public async Task UpdateTaskState_NonExistent_Throws() + { + var act = () => _store.UpdateTaskStateAsync("missing", TaskState.Working); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddArtifact_AppendsToList() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + + var artifact = new Artifact + { + Parts = [new TextPart { Text = "result" }], + Index = 0, + }; + var task = await _store.AddArtifactAsync("t1", artifact); + task.Artifacts.Should().HaveCount(1); + task.Artifacts![0].Parts.Should().HaveCount(1); + } + + [Fact] + public async Task DeleteTask_RemovesTask() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var deleted = await _store.DeleteTaskAsync("t1"); + deleted.Should().BeTrue(); + + var task = await _store.GetTaskAsync("t1"); + task.Should().BeNull(); + } + + [Fact] + public async Task DeleteTask_NonExistent_ReturnsFalse() + { + var deleted = await _store.DeleteTaskAsync("missing"); + deleted.Should().BeFalse(); + } + + // ─── Subscription tests ─── + + [Fact] + public async Task Subscribe_ReceivesUpdates() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var reader = _store.SubscribeAsync("t1"); + + await _store.UpdateTaskStateAsync("t1", TaskState.Working); + + var canRead = reader.TryRead(out var update); + canRead.Should().BeTrue(); + update!.Status.State.Should().Be(TaskState.Working); + update.IsFinal.Should().BeFalse(); + } + + [Fact] + public async Task Subscribe_FinalUpdate_CompletesChannel() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var reader = _store.SubscribeAsync("t1"); + + await _store.UpdateTaskStateAsync("t1", TaskState.Completed); + + var updates = new List(); + await foreach (var u in reader.ReadAllAsync()) + updates.Add(u); + + updates.Should().HaveCount(1); + updates[0].Status.State.Should().Be(TaskState.Completed); + updates[0].IsFinal.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_AfterTerminalState_ImmediatelyCompletes() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + await _store.UpdateTaskStateAsync("t1", TaskState.Completed); + + // Subscribe AFTER task is already completed + var reader = _store.SubscribeAsync("t1"); + + var updates = new List(); + await foreach (var u in reader.ReadAllAsync()) + updates.Add(u); + + updates.Should().HaveCount(1); + updates[0].Status.State.Should().Be(TaskState.Completed); + updates[0].IsFinal.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_MultipleSubscribers_AllReceiveUpdates() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var reader1 = _store.SubscribeAsync("t1"); + var reader2 = _store.SubscribeAsync("t1"); + + await _store.UpdateTaskStateAsync("t1", TaskState.Working); + + reader1.TryRead(out var u1).Should().BeTrue(); + reader2.TryRead(out var u2).Should().BeTrue(); + u1!.Status.State.Should().Be(TaskState.Working); + u2!.Status.State.Should().Be(TaskState.Working); + } + + [Fact] + public async Task Subscribe_ArtifactUpdate_IncludesArtifact() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var reader = _store.SubscribeAsync("t1"); + + var artifact = new Artifact + { + Parts = [new TextPart { Text = "result" }], + Index = 0, + }; + await _store.AddArtifactAsync("t1", artifact); + + reader.TryRead(out var update).Should().BeTrue(); + update!.Artifact.Should().NotBeNull(); + update.Artifact!.Index.Should().Be(0); + } + + [Fact] + public async Task UpdateTaskState_WithMessage_AppendsToHistory() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + var agentMsg = new Message { Role = "agent", Parts = [new TextPart { Text = "Reply 1" }] }; + await _store.UpdateTaskStateAsync("t1", TaskState.Working, agentMsg); + + var agentMsg2 = new Message { Role = "agent", Parts = [new TextPart { Text = "Reply 2" }] }; + await _store.UpdateTaskStateAsync("t1", TaskState.Completed, agentMsg2); + + var task = await _store.GetTaskAsync("t1"); + task!.History.Should().HaveCount(3, "initial + 2 agent messages"); + } + + [Fact] + public async Task UpdateTaskState_WithoutMessage_DoesNotModifyHistory() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + await _store.UpdateTaskStateAsync("t1", TaskState.Working); + + var task = await _store.GetTaskAsync("t1"); + task!.History.Should().HaveCount(1, "only the initial message"); + } + + [Fact] + public async Task AddArtifact_MultipleTimes_AccumulatesArtifacts() + { + await _store.CreateTaskAsync("t1", null, MakeMessage("hello")); + await _store.AddArtifactAsync("t1", new Artifact { Parts = [new TextPart { Text = "a" }], Index = 0 }); + await _store.AddArtifactAsync("t1", new Artifact { Parts = [new TextPart { Text = "b" }], Index = 1 }); + + var task = await _store.GetTaskAsync("t1"); + task!.Artifacts.Should().HaveCount(2); + task.Artifacts![0].Index.Should().Be(0); + task.Artifacts[1].Index.Should().Be(1); + } + + [Fact] + public async Task AddArtifact_NonExistent_Throws() + { + var act = () => _store.AddArtifactAsync("missing", new Artifact + { + Parts = [new TextPart { Text = "a" }], + Index = 0, + }); + await act.Should().ThrowAsync(); + } +} diff --git a/test/Aevatar.Interop.A2A.Tests/JsonRpcModelTests.cs b/test/Aevatar.Interop.A2A.Tests/JsonRpcModelTests.cs new file mode 100644 index 00000000..70809827 --- /dev/null +++ b/test/Aevatar.Interop.A2A.Tests/JsonRpcModelTests.cs @@ -0,0 +1,383 @@ +using System.Text.Json; +using Aevatar.Interop.A2A.Abstractions; +using Aevatar.Interop.A2A.Abstractions.Models; +using FluentAssertions; + +namespace Aevatar.Interop.A2A.Tests; + +public class JsonRpcModelTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [Fact] + public void JsonRpcRequest_Roundtrips() + { + var json = """ + { + "jsonrpc": "2.0", + "id": 1, + "method": "tasks/send", + "params": { "id": "t1", "message": { "role": "user", "parts": [{"text": "hi"}] } } + } + """; + + var request = JsonSerializer.Deserialize(json, JsonOptions); + request.Should().NotBeNull(); + request!.Method.Should().Be("tasks/send"); + request.Id.Should().NotBeNull(); + request.Params.Should().NotBeNull(); + } + + [Fact] + public void JsonRpcResponse_Success_SerializesCorrectly() + { + var response = JsonRpcResponse.Success( + JsonSerializer.Deserialize("1"), + new { status = "ok" }); + + var json = JsonSerializer.Serialize(response, JsonOptions); + json.Should().Contain("result"); + json.Should().NotContain("error"); + } + + [Fact] + public void JsonRpcResponse_Error_SerializesCorrectly() + { + var response = JsonRpcResponse.Fail(null, A2AErrorCodes.MethodNotFound, "Not found"); + + var json = JsonSerializer.Serialize(response, JsonOptions); + json.Should().Contain("error"); + json.Should().Contain("-32601"); + json.Should().NotContain("result"); + } + + [Fact] + public void TaskState_SerializesAsString() + { + var status = new A2ATaskStatus { State = TaskState.Working }; + var json = JsonSerializer.Serialize(status, JsonOptions); + json.Should().Contain("working"); + } + + [Fact] + public void AgentCard_Serializes() + { + var card = new AgentCard + { + Name = "Test", + Url = "http://localhost/a2a", + }; + + var json = JsonSerializer.Serialize(card, JsonOptions); + json.Should().Contain("\"name\""); + json.Should().Contain("\"url\""); + } + + // ─── Part serialization (A2A spec: {"type":"text","text":"hello"}) ─── + + [Fact] + public void TextPart_Serializes_WithTypeDiscriminator() + { + Part part = new TextPart { Text = "hello" }; + var json = JsonSerializer.Serialize(part, JsonOptions); + + json.Should().Contain("\"type\":\"text\""); + json.Should().Contain("\"text\":\"hello\""); + } + + [Fact] + public void TextPart_Deserializes_FromA2AFormat() + { + var json = """{"type":"text","text":"hello world"}"""; + var part = JsonSerializer.Deserialize(json, JsonOptions); + + part.Should().BeOfType(); + ((TextPart)part!).Text.Should().Be("hello world"); + } + + [Fact] + public void FilePart_Roundtrips() + { + Part part = new FilePart { File = new FileContent { Name = "test.txt", MimeType = "text/plain", Uri = "https://example.com/file" } }; + var json = JsonSerializer.Serialize(part, JsonOptions); + json.Should().Contain("\"type\":\"file\""); + + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized.Should().BeOfType(); + ((FilePart)deserialized!).File.Name.Should().Be("test.txt"); + } + + [Fact] + public void Message_WithParts_Roundtrips() + { + var message = new Message + { + Role = "user", + Parts = [new TextPart { Text = "Hello" }, new TextPart { Text = "World" }], + }; + + var json = JsonSerializer.Serialize(message, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Parts.Should().HaveCount(2); + deserialized.Parts[0].Should().BeOfType(); + ((TextPart)deserialized.Parts[0]).Text.Should().Be("Hello"); + } + + [Fact] + public void Part_Deserializes_WithoutTypeField_FallsBackToText() + { + // A2A clients might omit "type" for text parts + var json = """{"text":"implicit text"}"""; + var part = JsonSerializer.Deserialize(json, JsonOptions); + + part.Should().BeOfType(); + ((TextPart)part!).Text.Should().Be("implicit text"); + } + + [Fact] + public void TaskSendParams_Deserializes_FromJsonRpc() + { + // Simulates what the JSON-RPC endpoint receives + var json = """ + { + "id": "task-1", + "sessionId": "session-1", + "message": { + "role": "user", + "parts": [{"type": "text", "text": "hello agent"}] + }, + "metadata": {"agentId": "actor-123"} + } + """; + + var sendParams = JsonSerializer.Deserialize(json, JsonOptions); + sendParams.Should().NotBeNull(); + sendParams!.Id.Should().Be("task-1"); + sendParams.Message.Parts.Should().HaveCount(1); + sendParams.Message.Parts[0].Should().BeOfType(); + ((TextPart)sendParams.Message.Parts[0]).Text.Should().Be("hello agent"); + sendParams.Metadata!["agentId"].Should().Be("actor-123"); + } + + // ─── Additional coverage ─── + + [Fact] + public void JsonRpcResponse_WithNullId_Serializes() + { + var response = JsonRpcResponse.Success(null, new { ok = true }); + var json = JsonSerializer.Serialize(response, JsonOptions); + + json.Should().Contain("\"result\""); + // id should be null in the output + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized!.Id.Should().BeNull(); + } + + [Fact] + public void JsonRpcResponse_Fail_WithData_IncludesData() + { + var response = JsonRpcResponse.Fail(null, A2AErrorCodes.InternalError, "error", new { detail = "stack" }); + var json = JsonSerializer.Serialize(response, JsonOptions); + + json.Should().Contain("\"data\""); + json.Should().Contain("stack"); + } + + [Fact] + public void A2AErrorCodes_MatchJsonRpcSpec() + { + A2AErrorCodes.ParseError.Should().Be(-32700); + A2AErrorCodes.InvalidRequest.Should().Be(-32600); + A2AErrorCodes.MethodNotFound.Should().Be(-32601); + A2AErrorCodes.InvalidParams.Should().Be(-32602); + A2AErrorCodes.InternalError.Should().Be(-32603); + } + + [Fact] + public void A2ATask_JsonRoundtrip_PreservesAllProperties() + { + var task = new A2ATask + { + Id = "t-1", + SessionId = "s-1", + Status = new A2ATaskStatus + { + State = TaskState.Working, + Timestamp = "2026-04-07T00:00:00Z", + }, + History = [new Message { Role = "user", Parts = [new TextPart { Text = "hi" }] }], + Artifacts = [new Artifact { Parts = [new TextPart { Text = "result" }], Index = 0 }], + Metadata = new() { ["key"] = "value" }, + }; + + var json = JsonSerializer.Serialize(task, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Id.Should().Be("t-1"); + deserialized.SessionId.Should().Be("s-1"); + deserialized.Status.State.Should().Be(TaskState.Working); + deserialized.History.Should().HaveCount(1); + deserialized.Artifacts.Should().HaveCount(1); + deserialized.Metadata!["key"].Should().Be("value"); + } + + [Fact] + public void A2ATask_WithNullCollections_SerializesCorrectly() + { + var task = new A2ATask + { + Id = "t-2", + Status = new A2ATaskStatus { State = TaskState.Submitted }, + }; + + var json = JsonSerializer.Serialize(task, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized!.History.Should().BeNull(); + deserialized.Artifacts.Should().BeNull(); + deserialized.Metadata.Should().BeNull(); + } + + [Fact] + public void DataPart_Roundtrips() + { + Part part = new DataPart + { + Data = new Dictionary { ["score"] = 42 }, + }; + + var json = JsonSerializer.Serialize(part, JsonOptions); + json.Should().Contain("\"type\":\"data\""); + + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized.Should().BeOfType(); + } + + [Fact] + public void AgentCard_JsonRoundtrip_PreservesAllProperties() + { + var card = new AgentCard + { + Name = "Agent", + Description = "Desc", + Url = "http://localhost/a2a", + Version = "2.0.0", + Capabilities = new AgentCapabilities + { + Streaming = true, + PushNotifications = false, + StateTransitionHistory = true, + }, + Skills = [new AgentSkill + { + Id = "chat", + Name = "Chat", + Description = "General chat", + Tags = ["chat", "ai"], + Examples = ["Hello"], + }], + DefaultInputModes = ["text", "image"], + DefaultOutputModes = ["text"], + }; + + var json = JsonSerializer.Serialize(card, JsonOptions); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + deserialized!.Name.Should().Be("Agent"); + deserialized.Version.Should().Be("2.0.0"); + deserialized.Capabilities.Streaming.Should().BeTrue(); + deserialized.Skills.Should().HaveCount(1); + deserialized.Skills[0].Tags.Should().Contain("ai"); + deserialized.Skills[0].Examples.Should().Contain("Hello"); + deserialized.DefaultInputModes.Should().Contain("image"); + } + + [Fact] + public void TaskState_AllValues_SerializeAsStrings() + { + foreach (var state in Enum.GetValues()) + { + var status = new A2ATaskStatus { State = state }; + var json = JsonSerializer.Serialize(status, JsonOptions); + + // Should not contain numeric enum values + json.Should().NotMatchRegex("\"state\":\\d"); + } + } + + [Fact] + public void TextPart_WithMetadata_Roundtrips() + { + Part part = new TextPart + { + Text = "hello", + Metadata = new() { ["source"] = "user" }, + }; + + var json = JsonSerializer.Serialize(part, JsonOptions); + json.Should().Contain("\"metadata\""); + + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized.Should().BeOfType(); + deserialized!.Metadata.Should().ContainKey("source"); + } + + [Fact] + public void FilePart_WithMetadata_Roundtrips() + { + Part part = new FilePart + { + File = new FileContent { Name = "doc.pdf", MimeType = "application/pdf" }, + Metadata = new() { ["size"] = "1024" }, + }; + + var json = JsonSerializer.Serialize(part, JsonOptions); + json.Should().Contain("\"metadata\""); + json.Should().Contain("\"size\""); + + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized.Should().BeOfType(); + deserialized!.Metadata.Should().ContainKey("size"); + } + + [Fact] + public void DataPart_WithMetadata_Roundtrips() + { + Part part = new DataPart + { + Data = new Dictionary { ["key"] = "val" }, + Metadata = new() { ["origin"] = "system" }, + }; + + var json = JsonSerializer.Serialize(part, JsonOptions); + json.Should().Contain("\"metadata\""); + + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + deserialized.Should().BeOfType(); + deserialized!.Metadata.Should().ContainKey("origin"); + } + + [Fact] + public void UnknownPartType_ThrowsJsonException() + { + var json = """{"type":"video","url":"http://example.com/v.mp4"}"""; + var act = () => JsonSerializer.Deserialize(json, JsonOptions); + act.Should().Throw().WithMessage("*Unknown part type*"); + } + + [Fact] + public void Part_WithNullMetadata_DeserializesCleanly() + { + var json = """{"type":"text","text":"hi","metadata":null}"""; + var part = JsonSerializer.Deserialize(json, JsonOptions); + + part.Should().BeOfType(); + part!.Metadata.Should().BeNull(); + } +}