diff --git a/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs b/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs new file mode 100644 index 0000000..b021e94 --- /dev/null +++ b/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs @@ -0,0 +1,94 @@ +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +using A2A; +using A2A.AspNetCore; +using Microsoft.Extensions.Options; +using OpenClaw.Gateway.Bootstrap; +using OpenClaw.Gateway.Composition; +using OpenClaw.Gateway.Endpoints; +using OpenClaw.MicrosoftAgentFrameworkAdapter; +using OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +namespace OpenClaw.Gateway.A2A; + +/// +/// Maps the A2A protocol endpoints (message/send, message/stream, +/// .well-known/agent-card.json) into the Gateway's HTTP pipeline, +/// protected by the same auth and rate limiting as all other OpenClaw endpoints. +/// +internal static class A2AEndpointExtensions +{ + /// + /// Adds an authorization + rate-limiting middleware for requests targeting + /// the A2A path prefix, then maps the A2A JSON-RPC and agent-card endpoints. + /// + public static void MapOpenClawA2AEndpoints( + this WebApplication app, + GatewayStartupContext startup, + GatewayAppRuntime runtime) + { + var options = app.Services.GetRequiredService>().Value; + if (!options.EnableA2A) + return; + + var pathPrefix = options.A2APathPrefix.TrimEnd('/'); + + // Resolve the A2A request handler (A2AServer) from DI + var requestHandler = app.Services.GetRequiredService(); + + // Build and register the agent card for service discovery + var cardFactory = app.Services.GetRequiredService(); + var agentUrl = $"http://{startup.Config.BindAddress}:{startup.Config.Port}{pathPrefix}"; + var agentCard = cardFactory.Create(agentUrl); + + // Map the A2A protocol endpoint (handles JSON-RPC POST for message/send, message/stream, etc.) + // and the .well-known/agent-card.json discovery endpoint using the official SDK extension + app.MapHttpA2A(requestHandler, agentCard, pathPrefix); + app.MapWellKnownAgentCard(agentCard, pathPrefix); + app.Logger.LogInformation( + "A2A protocol endpoints mapped at {PathPrefix} with agent '{AgentName}'.", + pathPrefix, + options.AgentName); + } + + /// + /// Adds a middleware that enforces token-based authorization and rate limiting + /// on requests targeting the A2A path prefix. + /// + public static void UseOpenClawA2AAuth( + this WebApplication app, + GatewayStartupContext startup, + GatewayAppRuntime runtime) + { + var options = app.Services.GetRequiredService>().Value; + if (!options.EnableA2A) + return; + + var pathPrefix = options.A2APathPrefix.TrimEnd('/'); + + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments(pathPrefix, StringComparison.OrdinalIgnoreCase) + || ctx.Request.Path.StartsWithSegments("/.well-known", StringComparison.OrdinalIgnoreCase)) + { + if (!EndpointHelpers.IsAuthorizedRequest(ctx, startup.Config, startup.IsNonLoopbackBind)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + if (!runtime.Operations.ActorRateLimits.TryConsume( + "ip", + EndpointHelpers.GetRemoteIpKey(ctx), + "a2a_http", + out _)) + { + ctx.Response.StatusCode = StatusCodes.Status429TooManyRequests; + return; + } + } + + await next(ctx); + }); + } +} +#endif diff --git a/src/OpenClaw.Gateway/A2A/A2AServiceExtensions.cs b/src/OpenClaw.Gateway/A2A/A2AServiceExtensions.cs new file mode 100644 index 0000000..2d6c1fb --- /dev/null +++ b/src/OpenClaw.Gateway/A2A/A2AServiceExtensions.cs @@ -0,0 +1,31 @@ +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +using A2A; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenClaw.Gateway.Mcp; +using OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +namespace OpenClaw.Gateway.A2A; + +internal static class A2AServiceExtensions +{ + public static IServiceCollection AddOpenClawA2AServices(this IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var handler = sp.GetRequiredService(); + var store = sp.GetRequiredService(); + var notifier = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new A2AServer(handler, store, notifier, logger); + }); + + return services; + } +} +#endif diff --git a/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs b/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs new file mode 100644 index 0000000..fc15b5d --- /dev/null +++ b/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs @@ -0,0 +1,87 @@ +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +using OpenClaw.Core.Middleware; +using OpenClaw.Core.Models; +using OpenClaw.Gateway.Mcp; +using OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +namespace OpenClaw.Gateway.A2A; + +internal sealed class OpenClawA2AExecutionBridge : IOpenClawA2AExecutionBridge +{ + private readonly GatewayRuntimeHolder _runtimeHolder; + private readonly ILogger _logger; + + public OpenClawA2AExecutionBridge( + GatewayRuntimeHolder runtimeHolder, + ILogger logger) + { + _runtimeHolder = runtimeHolder; + _logger = logger; + } + + public async Task ExecuteStreamingAsync( + OpenClawA2AExecutionRequest request, + Func onEvent, + CancellationToken cancellationToken) + { + var runtime = _runtimeHolder.Runtime; + var session = await runtime.SessionManager.GetOrCreateByIdAsync( + request.SessionId, + request.ChannelId, + request.SenderId, + cancellationToken); + + await using var sessionLock = await runtime.SessionManager.AcquireSessionLockAsync(session.Id, cancellationToken); + + var (handled, commandResponse) = await runtime.CommandProcessor.TryProcessCommandAsync(session, request.UserText, cancellationToken); + if (handled) + { + if (!string.IsNullOrWhiteSpace(commandResponse)) + await onEvent(AgentStreamEvent.TextDelta(commandResponse), cancellationToken); + + await onEvent(AgentStreamEvent.Complete(), cancellationToken); + await runtime.SessionManager.PersistAsync(session, cancellationToken, sessionLockHeld: true); + return; + } + + var mwContext = new MessageContext + { + ChannelId = request.ChannelId, + SenderId = request.SenderId, + Text = request.UserText, + MessageId = request.MessageId, + SessionId = session.Id, + SessionInputTokens = session.TotalInputTokens, + SessionOutputTokens = session.TotalOutputTokens + }; + + if (!await runtime.MiddlewarePipeline.ExecuteAsync(mwContext, cancellationToken)) + { + await onEvent(AgentStreamEvent.TextDelta(mwContext.ShortCircuitResponse ?? "Request blocked."), cancellationToken); + await onEvent(AgentStreamEvent.Complete(), cancellationToken); + await runtime.SessionManager.PersistAsync(session, cancellationToken, sessionLockHeld: true); + return; + } + + try + { + await foreach (var evt in runtime.AgentRuntime.RunStreamingAsync(session, mwContext.Text, cancellationToken)) + await onEvent(evt, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "A2A execution failed for session {SessionId}", session.Id); + await onEvent(AgentStreamEvent.ErrorOccurred("A2A request failed."), cancellationToken); + await onEvent(AgentStreamEvent.Complete(), cancellationToken); + } + finally + { + await runtime.SessionManager.PersistAsync(session, cancellationToken, sessionLockHeld: true); + } + } +} +#endif diff --git a/src/OpenClaw.Gateway/Program.cs b/src/OpenClaw.Gateway/Program.cs index 507e1b1..e742c72 100644 --- a/src/OpenClaw.Gateway/Program.cs +++ b/src/OpenClaw.Gateway/Program.cs @@ -7,6 +7,7 @@ using OpenClaw.Gateway.Profiles; #if OPENCLAW_ENABLE_MAF_EXPERIMENT using OpenClaw.MicrosoftAgentFrameworkAdapter; +using OpenClaw.Gateway.A2A; #endif #if OPENCLAW_ENABLE_OPENSANDBOX using OpenClawNet.Sandbox.OpenSandbox; @@ -34,6 +35,7 @@ builder.Services.ApplyOpenClawRuntimeProfile(startup); #if OPENCLAW_ENABLE_MAF_EXPERIMENT builder.Services.AddMicrosoftAgentFrameworkExperiment(builder.Configuration); +builder.Services.AddOpenClawA2AServices(); #endif #if OPENCLAW_ENABLE_OPENSANDBOX builder.Services.AddOpenSandboxIntegration(builder.Configuration); @@ -44,10 +46,16 @@ app.InitializeMcpRuntime(runtime); app.UseOpenClawMcpAuth(startup, runtime); +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +app.UseOpenClawA2AAuth(startup, runtime); +#endif app.UseOpenClawPipeline(startup, runtime); app.MapOpenApi("/openapi/{documentName}.json"); app.MapOpenClawEndpoints(startup, runtime); app.MapMcp("/mcp"); +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +app.MapOpenClawA2AEndpoints(startup, runtime); +#endif app.Run($"http://{startup.Config.BindAddress}:{startup.Config.Port}"); diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/IOpenClawA2AExecutionBridge.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/IOpenClawA2AExecutionBridge.cs new file mode 100644 index 0000000..8fb68bb --- /dev/null +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/IOpenClawA2AExecutionBridge.cs @@ -0,0 +1,20 @@ +using OpenClaw.Core.Models; + +namespace OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +public sealed record OpenClawA2AExecutionRequest +{ + public required string SessionId { get; init; } + public required string ChannelId { get; init; } + public required string SenderId { get; init; } + public required string UserText { get; init; } + public string? MessageId { get; init; } +} + +public interface IOpenClawA2AExecutionBridge +{ + Task ExecuteStreamingAsync( + OpenClawA2AExecutionRequest request, + Func onEvent, + CancellationToken cancellationToken); +} diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawA2AAgentHandler.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawA2AAgentHandler.cs new file mode 100644 index 0000000..c936e44 --- /dev/null +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawA2AAgentHandler.cs @@ -0,0 +1,133 @@ +using A2A; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenClaw.Core.Models; +using System.Text; + +namespace OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +/// +/// Bridges the A2A contract into the OpenClaw runtime +/// through an injected execution bridge. +/// +public sealed class OpenClawA2AAgentHandler : IAgentHandler +{ + private readonly MafOptions _options; + private readonly IOpenClawA2AExecutionBridge _bridge; + private readonly ILogger _logger; + + public OpenClawA2AAgentHandler( + IOptions options, + IOpenClawA2AExecutionBridge bridge, + ILogger logger) + { + _options = options.Value; + _bridge = bridge; + _logger = logger; + } + + /// + public async Task ExecuteAsync( + RequestContext context, + AgentEventQueue eventQueue, + CancellationToken cancellationToken) + { + _logger.LogDebug( + "A2A ExecuteAsync: taskId={TaskId}, contextId={ContextId}, streaming={Streaming}", + context.TaskId, + context.ContextId, + context.StreamingResponse); + + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.SubmitAsync(cancellationToken); + await updater.StartWorkAsync(null, cancellationToken); + + var request = new OpenClawA2AExecutionRequest + { + SessionId = context.TaskId, + ChannelId = "a2a", + SenderId = string.IsNullOrWhiteSpace(context.ContextId) ? "a2a-client" : context.ContextId, + UserText = ExtractUserText(context), + MessageId = context.Message?.MessageId + }; + + var responseText = new StringBuilder(); + string? errorMessage = null; + + try + { + await _bridge.ExecuteStreamingAsync( + request, + (evt, ct) => + { + switch (evt.Type) + { + case AgentStreamEventType.TextDelta when !string.IsNullOrEmpty(evt.Content): + responseText.Append(evt.Content); + break; + case AgentStreamEventType.Error when !string.IsNullOrWhiteSpace(evt.Content): + errorMessage = evt.Content; + break; + } + + return ValueTask.CompletedTask; + }, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "A2A execution failed for task {TaskId}", context.TaskId); + await updater.FailAsync(CreateAgentMessage("A2A request failed."), cancellationToken); + return; + } + + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + await updater.FailAsync(CreateAgentMessage(errorMessage), cancellationToken); + return; + } + + await updater.CompleteAsync( + responseText.Length > 0 + ? CreateAgentMessage(responseText.ToString()) + : CreateAgentMessage($"[{_options.AgentName}] Request completed."), + cancellationToken); + } + + /// + public async Task CancelAsync( + RequestContext context, + AgentEventQueue eventQueue, + CancellationToken cancellationToken) + { + _logger.LogDebug("A2A CancelAsync: taskId={TaskId}", context.TaskId); + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(cancellationToken); + } + + private static string ExtractUserText(RequestContext context) + { + if (!string.IsNullOrWhiteSpace(context.UserText)) + return context.UserText; + + if (context.Message?.Parts is not null) + { + var text = string.Concat(context.Message.Parts.Select(static part => part.Text)); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + + return string.Empty; + } + + private static Message CreateAgentMessage(string text) + => new() + { + Role = Role.Agent, + Parts = [Part.FromText(text)] + }; +} diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawAgentCardFactory.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawAgentCardFactory.cs new file mode 100644 index 0000000..e57dfe5 --- /dev/null +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/A2A/OpenClawAgentCardFactory.cs @@ -0,0 +1,79 @@ +using A2A; +using Microsoft.Extensions.Options; + +namespace OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; + +/// +/// Builds a default from +/// configuration and any registered skills. The card is served at the +/// .well-known/agent-card.json discovery endpoint. +/// +public sealed class OpenClawAgentCardFactory +{ + private readonly MafOptions _options; + + public OpenClawAgentCardFactory(IOptions options) + { + _options = options.Value; + } + + /// + /// Creates an populated from configuration. + /// + public AgentCard Create(string agentUrl) + { + var card = new AgentCard + { + Name = _options.AgentName, + Description = _options.AgentDescription, + Version = _options.A2AVersion, + SupportedInterfaces = [new AgentInterface { Url = agentUrl }], + Provider = new AgentProvider + { + Organization = "OpenClaw.NET" + }, + Capabilities = new AgentCapabilities + { + Streaming = _options.EnableStreaming, + PushNotifications = false + }, + DefaultInputModes = ["text/plain"], + DefaultOutputModes = ["text/plain"], + Skills = BuildSkills() + }; + + return card; + } + + private List BuildSkills() + { + var skills = new List(); + + foreach (var skillConfig in _options.A2ASkills) + { + skills.Add(new AgentSkill + { + Id = skillConfig.Id, + Name = skillConfig.Name, + Description = skillConfig.Description ?? string.Empty, + Tags = skillConfig.Tags ?? [], + OutputModes = ["text/plain"] + }); + } + + // Always include a default general skill if none configured + if (skills.Count == 0) + { + skills.Add(new AgentSkill + { + Id = "general", + Name = "General Assistant", + Description = $"General-purpose AI assistant powered by {_options.AgentName}.", + Tags = ["general", "assistant"], + OutputModes = ["text/plain"] + }); + } + + return skills; + } +} diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafOptions.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafOptions.cs index f78ddc6..a29e439 100644 --- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafOptions.cs +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafOptions.cs @@ -18,4 +18,29 @@ public bool EnableStreamingFallback get => EnableStreaming; set => EnableStreaming = value; } + + // ── A2A Protocol Configuration ────────────────────────────────── + + /// Whether to expose A2A protocol endpoints when MAF is enabled. + public bool EnableA2A { get; set; } = false; + + /// URL path prefix for A2A endpoints (default: /a2a). + public string A2APathPrefix { get; set; } = "/a2a"; + + /// Version string reported in the A2A AgentCard. + public string A2AVersion { get; set; } = "1.0.0"; + + /// Skill descriptors published in the A2A agent card. + public List A2ASkills { get; set; } = []; +} + +/// +/// Configures a single skill entry in the A2A agent card. +/// +public sealed class A2ASkillConfig +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Description { get; set; } + public List? Tags { get; set; } } diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafServiceCollectionExtensions.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafServiceCollectionExtensions.cs index 47bc253..3645f60 100644 --- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafServiceCollectionExtensions.cs +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafServiceCollectionExtensions.cs @@ -11,11 +11,13 @@ public static IServiceCollection AddMicrosoftAgentFrameworkExperiment( this IServiceCollection services, IConfiguration configuration) { - services.AddSingleton>(_ => Options.Create(CreateOptions(configuration))); + var options = CreateOptions(configuration); + services.AddSingleton>(_ => Options.Create(options)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + return services; } @@ -41,6 +43,41 @@ private static MafOptions CreateOptions(IConfiguration configuration) else if (bool.TryParse(section["EnableStreamingFallback"], out var enableStreamingFallback)) options.EnableStreaming = enableStreamingFallback; + // A2A configuration + if (bool.TryParse(section["EnableA2A"], out var enableA2A)) + options.EnableA2A = enableA2A; + + var a2aPathPrefix = section["A2APathPrefix"]; + if (!string.IsNullOrWhiteSpace(a2aPathPrefix)) + options.A2APathPrefix = a2aPathPrefix; + + var a2aVersion = section["A2AVersion"]; + if (!string.IsNullOrWhiteSpace(a2aVersion)) + options.A2AVersion = a2aVersion; + + var skillsSection = section.GetSection("A2ASkills"); + if (skillsSection.Exists()) + { + var skills = new List(); + foreach (var child in skillsSection.GetChildren()) + { + var skill = new A2ASkillConfig + { + Id = child["Id"] ?? "", + Name = child["Name"] ?? "", + Description = child["Description"] + }; + + var tagsSection = child.GetSection("Tags"); + if (tagsSection.Exists()) + skill.Tags = tagsSection.GetChildren().Select(t => t.Value ?? "").ToList(); + + skills.Add(skill); + } + + options.A2ASkills = skills; + } + return options; } } diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/OpenClaw.MicrosoftAgentFrameworkAdapter.csproj b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/OpenClaw.MicrosoftAgentFrameworkAdapter.csproj index e5c02f3..0e5c7c1 100644 --- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/OpenClaw.MicrosoftAgentFrameworkAdapter.csproj +++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/OpenClaw.MicrosoftAgentFrameworkAdapter.csproj @@ -22,5 +22,8 @@ + + + diff --git a/src/OpenClaw.Tests/A2AIntegrationTests.cs b/src/OpenClaw.Tests/A2AIntegrationTests.cs new file mode 100644 index 0000000..72b09e1 --- /dev/null +++ b/src/OpenClaw.Tests/A2AIntegrationTests.cs @@ -0,0 +1,417 @@ +#if OPENCLAW_ENABLE_MAF_EXPERIMENT +using A2A; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenClaw.Core.Models; +using OpenClaw.Gateway.A2A; +using OpenClaw.MicrosoftAgentFrameworkAdapter; +using OpenClaw.MicrosoftAgentFrameworkAdapter.A2A; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class A2AIntegrationTests +{ + // ── AgentCardFactory Tests ────────────────────────────────────── + + [Fact] + public void AgentCardFactory_Creates_Card_With_DefaultSkill_When_NoSkillsConfigured() + { + var options = CreateOptions(); + var factory = new OpenClawAgentCardFactory(Options.Create(options)); + + var card = factory.Create("http://localhost:5000/a2a"); + + Assert.Equal("TestAgent", card.Name); + Assert.Equal("1.0.0", card.Version); + Assert.NotNull(card.Provider); + Assert.Equal("OpenClaw.NET", card.Provider!.Organization); + Assert.NotNull(card.Capabilities); + Assert.True(card.Capabilities!.Streaming); + Assert.Single(card.Skills!); + Assert.Equal("general", card.Skills![0].Id); + Assert.Contains("text/plain", card.DefaultInputModes!); + Assert.Contains("text/plain", card.DefaultOutputModes!); + } + + [Fact] + public void AgentCardFactory_Creates_Card_With_ConfiguredSkills() + { + var options = CreateOptions(); + options.A2ASkills = + [ + new A2ASkillConfig + { + Id = "route-planner", + Name = "Route Planner", + Description = "Plans flight routes.", + Tags = ["travel", "flights"] + }, + new A2ASkillConfig + { + Id = "translator", + Name = "Translator", + Description = "Translates text between languages.", + Tags = ["nlp"] + } + ]; + + var factory = new OpenClawAgentCardFactory(Options.Create(options)); + var card = factory.Create("http://localhost:5000/a2a"); + + Assert.Equal(2, card.Skills!.Count); + Assert.Equal("route-planner", card.Skills[0].Id); + Assert.Equal("Route Planner", card.Skills[0].Name); + Assert.Contains("travel", card.Skills[0].Tags!); + Assert.Equal("translator", card.Skills[1].Id); + } + + [Fact] + public void AgentCardFactory_Uses_SupportedInterfaces_For_Url() + { + var options = CreateOptions(); + var factory = new OpenClawAgentCardFactory(Options.Create(options)); + + var card = factory.Create("http://10.0.0.1:8080/a2a"); + + Assert.NotNull(card.SupportedInterfaces); + Assert.Single(card.SupportedInterfaces!); + Assert.Equal("http://10.0.0.1:8080/a2a", card.SupportedInterfaces[0].Url); + } + + // ── A2A Agent Handler Tests ───────────────────────────────────── + + [Fact] + public async Task AgentHandler_ExecuteAsync_Sends_WorkingThenCompleted() + { + var handler = CreateHandler(); + var eventQueue = new AgentEventQueue(); + var context = new RequestContext + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Hello A2A")] + }, + TaskId = "task-1", + ContextId = "ctx-1", + StreamingResponse = false + }; + + var events = new List(); + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(); + await foreach (var evt in eventQueue) + events.Add(evt); + + var workingUpdate = events.FirstOrDefault(e => e.StatusUpdate?.Status.State == TaskState.Working); + var completedUpdate = events.LastOrDefault(e => e.StatusUpdate?.Status.State == TaskState.Completed); + + Assert.NotEmpty(events); + Assert.NotNull(completedUpdate); + Assert.NotNull(completedUpdate!.StatusUpdate!.Status.Message); + Assert.Contains("bridge:Hello A2A", completedUpdate.StatusUpdate.Status.Message!.Parts![0].Text); + Assert.NotNull(workingUpdate); + } + + [Fact] + public async Task AgentHandler_CancelAsync_Sends_CanceledStatus() + { + var handler = CreateHandler(); + var eventQueue = new AgentEventQueue(); + var context = new RequestContext + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Cancel this")] + }, + TaskId = "task-cancel", + ContextId = "ctx-cancel", + StreamingResponse = false + }; + + var events = new List(); + await handler.CancelAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(); + await foreach (var evt in eventQueue) + events.Add(evt); + + Assert.Single(events); + Assert.NotNull(events[0].StatusUpdate); + Assert.Equal(TaskState.Canceled, events[0].StatusUpdate!.Status.State); + } + + // ── A2A Service Registration Tests ────────────────────────────── + + [Fact] + public void AddOpenClawA2AServices_Registers_Required_Services() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(_ => Options.Create(CreateOptions())); + services.AddOpenClawA2AServices(); + services.AddSingleton(new FakeExecutionBridge()); + + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void MafServiceCollectionExtensions_Parses_A2A_Options_Without_Registering_Endpoints() + { + var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{MafOptions.SectionName}:EnableA2A"] = "true", + [$"{MafOptions.SectionName}:AgentName"] = "TestA2A" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new OpenClaw.Core.Models.GatewayConfig()); + services.AddMicrosoftAgentFrameworkExperiment(config); + + using var provider = services.BuildServiceProvider(); + var resolvedOptions = provider.GetRequiredService>().Value; + + Assert.True(resolvedOptions.EnableA2A); + Assert.Equal("TestA2A", resolvedOptions.AgentName); + Assert.Null(provider.GetService()); + } + + // ── A2A Server Integration Tests ──────────────────────────────── + + [Fact] + public async Task A2AServer_SendMessage_Returns_CompletedTask() + { + using var provider = BuildA2AServiceProvider(); + var requestHandler = provider.GetRequiredService(); + + var response = await requestHandler.SendMessageAsync( + new SendMessageRequest + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("What is 2+2?")] + } + }, + CancellationToken.None); + + Assert.NotNull(response); + Assert.NotNull(response.Task); + Assert.NotNull(response.Task!.Id); + Assert.Equal(TaskState.Completed, response.Task.Status.State); + Assert.Contains("bridge:What is 2+2?", response.Task.Status.Message!.Parts![0].Text); + } + + [Fact] + public async Task A2AServer_SendStreamingMessage_Yields_Events() + { + using var provider = BuildA2AServiceProvider(); + var requestHandler = provider.GetRequiredService(); + + var events = new List(); + await foreach (var evt in requestHandler.SendStreamingMessageAsync( + new SendMessageRequest + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Stream test")] + } + }, + CancellationToken.None)) + { + events.Add(evt); + } + + Assert.NotEmpty(events); + // Last event should have a completed status + var lastStatusUpdate = events.LastOrDefault(e => e.StatusUpdate?.Status.State == TaskState.Completed); + Assert.NotNull(lastStatusUpdate); + } + + [Fact] + public async Task A2AServer_GetTask_Returns_StoredTask() + { + using var provider = BuildA2AServiceProvider(); + var requestHandler = provider.GetRequiredService(); + + // First create a task via SendMessage + var sendResponse = await requestHandler.SendMessageAsync( + new SendMessageRequest + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Create task")] + } + }, + CancellationToken.None); + + Assert.NotNull(sendResponse.Task); + var taskId = sendResponse.Task!.Id; + + // Now retrieve it + var getResponse = await requestHandler.GetTaskAsync( + new GetTaskRequest { Id = taskId }, + CancellationToken.None); + + Assert.NotNull(getResponse); + Assert.Equal(taskId, getResponse!.Id); + } + + [Fact] + public async Task A2AServer_CancelTask_Returns_CanceledState() + { + using var provider = BuildA2AServiceProvider(); + var requestHandler = provider.GetRequiredService(); + + // Create a task + var sendResponse = await requestHandler.SendMessageAsync( + new SendMessageRequest + { + Message = new Message + { + Role = Role.User, + Parts = [Part.FromText("Cancel me")] + } + }, + CancellationToken.None); + + var taskId = sendResponse.Task!.Id; + + await Assert.ThrowsAsync(() => + requestHandler.CancelTaskAsync( + new CancelTaskRequest { Id = taskId }, + CancellationToken.None)); + } + + // ── MafOptions A2A Configuration Tests ────────────────────────── + + [Fact] + public void MafOptions_A2A_DefaultValues() + { + var options = new MafOptions(); + + Assert.False(options.EnableA2A); + Assert.Equal("/a2a", options.A2APathPrefix); + Assert.Equal("1.0.0", options.A2AVersion); + Assert.Empty(options.A2ASkills); + } + + [Fact] + public void MafOptions_A2A_ParsesConfigSection() + { + var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{MafOptions.SectionName}:EnableA2A"] = "true", + [$"{MafOptions.SectionName}:A2APathPrefix"] = "/agents/a2a", + [$"{MafOptions.SectionName}:A2AVersion"] = "2.0.0-beta", + [$"{MafOptions.SectionName}:A2ASkills:0:Id"] = "search", + [$"{MafOptions.SectionName}:A2ASkills:0:Name"] = "Web Search", + [$"{MafOptions.SectionName}:A2ASkills:0:Description"] = "Searches the web", + [$"{MafOptions.SectionName}:A2ASkills:0:Tags:0"] = "web", + [$"{MafOptions.SectionName}:A2ASkills:0:Tags:1"] = "search" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new OpenClaw.Core.Models.GatewayConfig()); + services.AddMicrosoftAgentFrameworkExperiment(config); + + using var provider = services.BuildServiceProvider(); + var resolvedOptions = provider.GetRequiredService>().Value; + + Assert.True(resolvedOptions.EnableA2A); + Assert.Equal("/agents/a2a", resolvedOptions.A2APathPrefix); + Assert.Equal("2.0.0-beta", resolvedOptions.A2AVersion); + Assert.Single(resolvedOptions.A2ASkills); + Assert.Equal("search", resolvedOptions.A2ASkills[0].Id); + Assert.Equal("Web Search", resolvedOptions.A2ASkills[0].Name); + Assert.NotNull(resolvedOptions.A2ASkills[0].Tags); + Assert.Equal(2, resolvedOptions.A2ASkills[0].Tags!.Count); + } + + // ── InMemoryTaskStore Tests ───────────────────────────────────── + + [Fact] + public async Task InMemoryTaskStore_StoreAndRetrieve() + { + var store = new InMemoryTaskStore(); + var task = new AgentTask + { + Id = "test-task-1", + ContextId = "ctx-1", + Status = new A2A.TaskStatus { State = TaskState.Completed } + }; + + await store.SaveTaskAsync(task.Id, task, CancellationToken.None); + var retrieved = await store.GetTaskAsync("test-task-1", CancellationToken.None); + + Assert.NotNull(retrieved); + Assert.Equal("test-task-1", retrieved!.Id); + Assert.Equal(TaskState.Completed, retrieved.Status.State); + } + + [Fact] + public async Task InMemoryTaskStore_GetNonExistent_ReturnsNull() + { + var store = new InMemoryTaskStore(); + var retrieved = await store.GetTaskAsync("nonexistent", CancellationToken.None); + Assert.Null(retrieved); + } + + // ── Helpers ───────────────────────────────────────────────────── + + private static MafOptions CreateOptions() => new() + { + AgentName = "TestAgent", + AgentDescription = "Test agent for A2A integration tests.", + EnableStreaming = true, + EnableA2A = true, + A2AVersion = "1.0.0" + }; + + private static OpenClawA2AAgentHandler CreateHandler() + => new( + Options.Create(CreateOptions()), + new FakeExecutionBridge(), + NullLogger.Instance); + + private static ServiceProvider BuildA2AServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton>(_ => Options.Create(CreateOptions())); + services.AddOpenClawA2AServices(); + services.AddSingleton(new FakeExecutionBridge()); + return services.BuildServiceProvider(); + } + + private sealed class FakeExecutionBridge : IOpenClawA2AExecutionBridge + { + public async Task ExecuteStreamingAsync( + OpenClawA2AExecutionRequest request, + Func onEvent, + CancellationToken cancellationToken) + { + await onEvent(AgentStreamEvent.TextDelta($"bridge:{request.UserText}"), cancellationToken); + await onEvent(AgentStreamEvent.Complete(), cancellationToken); + } + } +} +#endif