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();
+ }
+}