From 18d05e88246d88d730c07bbca58854bb928adb8f Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Mon, 1 Dec 2025 13:07:20 +0100 Subject: [PATCH] Add MapAGUI hosting overloads with IHostedAgentBuilder and agent name support This adds the same hosting patterns from A2A and OpenAI to AGUI: - MapAGUI(IHostedAgentBuilder) and MapAGUI(IHostedAgentBuilder, string? path) - MapAGUI(string agentName) and MapAGUI(string agentName, string? path) - MapAGUI(AIAgent) and MapAGUI(AIAgent, string? path) - ValidateAgentName for URL-safe validation - Updated namespace to Microsoft.AspNetCore.Builder - Renamed class to MicrosoftAgentAIHostingAGUIEndpointRouteBuilderExtensions - Added comprehensive unit tests --- .../AGUIEndpointRouteBuilderExtensions.cs | 59 +++++ ...AGUIEndpointRouteBuilderExtensionsTests.cs | 210 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 348d4a866..04437c418 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -32,6 +32,21 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; /// public static class AGUIEndpointRouteBuilderExtensions { + /// + /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via . + /// + /// The endpoint route builder. + /// The hosted agent builder that identifies the agent registration. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUIServer( + this IEndpointRouteBuilder endpoints, + IHostedAgentBuilder agentBuilder) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapAGUIServer(agentBuilder.Name); + } + /// /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via . /// @@ -49,6 +64,23 @@ public static class AGUIEndpointRouteBuilderExtensions return endpoints.MapAGUIServer(agentBuilder.Name, pattern); } + /// + /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection. + /// + /// The endpoint route builder. + /// The name of the keyed agent registration to resolve from dependency injection. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUIServer( + this IEndpointRouteBuilder endpoints, + string agentName) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapAGUIServer(agent); + } + /// /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection. /// @@ -68,6 +100,24 @@ public static class AGUIEndpointRouteBuilderExtensions return endpoints.MapAGUIServer(pattern, agent); } + /// + /// Maps an AG-UI agent endpoint using a route derived from the agent name. + /// + /// The endpoint route builder. + /// The agent instance. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUIServer( + this IEndpointRouteBuilder endpoints, + AIAgent aiAgent) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(aiAgent); + ArgumentException.ThrowIfNullOrWhiteSpace(aiAgent.Name, nameof(aiAgent.Name)); + ValidateAgentName(aiAgent.Name); + + return endpoints.MapAGUIServer($"/{aiAgent.Name}/agui", aiAgent); + } + /// /// Maps an AG-UI agent endpoint. /// @@ -186,4 +236,13 @@ public static class AGUIEndpointRouteBuilderExtensions await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false); } + + private static void ValidateAgentName([NotNull] string agentName) + { + var escaped = Uri.EscapeDataString(agentName); + if (!string.Equals(escaped, agentName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Agent name '{agentName}' contains characters invalid for URL routes.", nameof(agentName)); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..11ca8d6f1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/MapAGUIEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Unit tests for the agent-name-derived MapAGUIServer overloads. +/// +public sealed class MapAGUIEndpointRouteBuilderExtensionsTests +{ + [Fact] + public void MapAGUIServer_WithAgentBuilder_MapsNameDerivedRoute() + { + // Arrange + using WebApplication app = CreateApp("test-agent"); + Mock agentBuilder = new(); + agentBuilder.SetupGet(builder => builder.Name).Returns("test-agent"); + + // Act + app.MapAGUIServer(agentBuilder.Object); + + // Assert + Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui"); + } + + [Fact] + public void MapAGUIServer_WithAgentName_MapsNameDerivedRoute() + { + // Arrange + using WebApplication app = CreateApp("test-agent"); + + // Act + app.MapAGUIServer("test-agent"); + + // Assert + Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui"); + } + + [Fact] + public void MapAGUIServer_WithAgent_MapsNameDerivedRoute() + { + // Arrange + using WebApplication app = CreateApp(); + AIAgent agent = new TestAgent("test-agent"); + + // Act + app.MapAGUIServer(agent); + + // Assert + Assert.Contains(GetRoutePatterns(app), pattern => pattern == "/test-agent/agui"); + } + + [Fact] + public void MapAGUIServer_WithNullEndpoints_ThrowsArgumentNullException() + { + // Arrange + IEndpointRouteBuilder endpoints = null!; + AIAgent agent = new TestAgent("test-agent"); + + // Act + ArgumentNullException exception = Assert.Throws(() => endpoints.MapAGUIServer(agent)); + + // Assert + Assert.Equal("endpoints", exception.ParamName); + } + + [Fact] + public void MapAGUIServer_WithNullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + using WebApplication app = CreateApp(); + + // Act + ArgumentNullException exception = Assert.Throws(() => app.MapAGUIServer((IHostedAgentBuilder)null!)); + + // Assert + Assert.Equal("agentBuilder", exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MapAGUIServer_WithNullOrWhitespaceAgentName_ThrowsArgumentException(string? agentName) + { + // Arrange + using WebApplication app = CreateApp(); + + // Act + ArgumentException exception = Assert.ThrowsAny(() => app.MapAGUIServer(agentName!)); + + // Assert + Assert.Equal("agentName", exception.ParamName); + } + + [Fact] + public void MapAGUIServer_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + using WebApplication app = CreateApp(); + + // Act + ArgumentNullException exception = Assert.Throws(() => app.MapAGUIServer((AIAgent)null!)); + + // Assert + Assert.Equal("aiAgent", exception.ParamName); + } + + [Theory] + [InlineData("agent with spaces")] + [InlineData("agent