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