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
This commit is contained in:
Javier Calvarro Nelson
2025-12-01 13:07:20 +01:00
Unverified
parent 5c70e143a0
commit 5160d381b1
4 changed files with 665 additions and 19 deletions
@@ -1,11 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
@@ -14,40 +17,106 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for mapping AG-UI agents to ASP.NET Core endpoints.
/// </summary>
public static class AGUIEndpointRouteBuilderExtensions
public static class MicrosoftAgentAIHostingAGUIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps an AG-UI agent endpoint.
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="IHostedAgentBuilder"/>.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <param name="aiAgent">The agent instance.</param>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the AG-UI endpoints for.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder)
=> MapAGUI(endpoints, agentBuilder, path: null);
/// <summary>
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="IHostedAgentBuilder"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the AG-UI endpoints for.</param>
/// <param name="path">Custom route path for the AG-UI endpoint.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, [StringSyntax("Route")] string? path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
AIAgent agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapAGUI(endpoints, agent, path);
}
/// <summary>
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given agent name.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agentName">The name of the agent to map the AG-UI endpoints for.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, string agentName)
=> MapAGUI(endpoints, agentName, path: null);
/// <summary>
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given agent name.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agentName">The name of the agent to map the AG-UI endpoints for.</param>
/// <param name="path">Custom route path for the AG-UI endpoint.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, string agentName, [StringSyntax("Route")] string? path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
AIAgent agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return MapAGUI(endpoints, agent, path);
}
/// <summary>
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the AG-UI endpoints for.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(this IEndpointRouteBuilder endpoints, AIAgent agent)
=> MapAGUI(endpoints, agent, path: null);
/// <summary>
/// Maps AG-UI endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the AG-UI endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the AG-UI endpoints for.</param>
/// <param name="path">Custom route path for the AG-UI endpoint.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
[StringSyntax("route")] string pattern,
AIAgent aiAgent)
AIAgent agent,
[StringSyntax("Route")] string? path)
{
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name));
ValidateAgentName(agent.Name);
path ??= $"/{agent.Name}/agui";
return endpoints.MapPost(path, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
if (input is null)
{
return Results.BadRequest();
}
var jsonOptions = context.RequestServices.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions> jsonOptions = context.RequestServices.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
System.Text.Json.JsonSerializerOptions jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var clientTools = input.Tools?.AsAITools().ToList();
IEnumerable<ChatMessage> messages = input.Messages.AsChatMessages(jsonSerializerOptions);
List<AITool>? clientTools = input.Tools?.AsAITools().ToList();
// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
ChatClientAgentRunOptions runOptions = new()
{
ChatOptions = new ChatOptions
{
@@ -64,7 +133,7 @@ public static class AGUIEndpointRouteBuilderExtensions
};
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
IAsyncEnumerable<BaseEvent> events = agent.RunStreamingAsync(
messages,
options: runOptions,
cancellationToken: cancellationToken)
@@ -76,8 +145,17 @@ public static class AGUIEndpointRouteBuilderExtensions
jsonSerializerOptions,
cancellationToken);
var sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
ILogger<AGUIServerSentEventsResult> sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
return new AGUIServerSentEventsResult(events, sseLogger);
});
}
private static void ValidateAgentName([NotNull] string agentName)
{
string 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));
}
}
}
@@ -20,7 +20,7 @@ using Moq;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIEndpointRouteBuilderExtensions"/> class.
/// Unit tests for the <see cref="MicrosoftAgentAIHostingAGUIEndpointRouteBuilderExtensions"/> class.
/// </summary>
public sealed class AGUIEndpointRouteBuilderExtensionsTests
{
@@ -38,7 +38,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
AIAgent agent = new TestAgent();
// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(Pattern, agent);
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agent, Pattern);
// Assert
Assert.NotNull(result);
@@ -423,6 +423,8 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
{
public override string Id => "multi-response-agent";
public override string Name => "multi-response-agent";
public override string? Description => "Agent that produces multiple text chunks";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
@@ -512,6 +514,8 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
{
public override string Id => "test-agent";
public override string Name => "test-agent";
public override string? Description => "Test agent";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
@@ -0,0 +1,479 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the MapAGUI extension methods.
/// </summary>
public sealed class MapAGUIEndpointRouteBuilderExtensionsTests
{
/// <summary>
/// Verifies that MapAGUI throws ArgumentNullException for null endpoints when using IHostedAgentBuilder.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
IEndpointRouteBuilder endpoints = null!;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapAGUI(agentBuilder));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapAGUI throws ArgumentNullException for null agentBuilder.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapAGUI(agentBuilder));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapAGUI with IHostedAgentBuilder correctly maps the agent.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI(agentBuilder);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapAGUI with IHostedAgentBuilder and custom path works correctly.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_CustomPath_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("my-agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI(agentBuilder, path: "/agents/my-agent/agui");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that multiple agents can be mapped using IHostedAgentBuilder.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI(agent1Builder);
app.MapAGUI(agent2Builder);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that IHostedAgentBuilder overload validates agent name characters.
/// </summary>
[Theory]
[InlineData("agent with spaces")]
[InlineData("agent<script>")]
[InlineData("agent?query")]
[InlineData("agent#fragment")]
public void MapAGUI_WithAgentBuilder_InvalidAgentNameCharacters_ThrowsArgumentException(string invalidName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent(invalidName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapAGUI(agentBuilder));
Assert.Contains("invalid for URL routes", exception.Message);
}
/// <summary>
/// Verifies that IHostedAgentBuilder overload accepts valid agent names.
/// </summary>
[Theory]
[InlineData("agent-name")]
[InlineData("agent_name")]
[InlineData("agent.name")]
[InlineData("agent123")]
[InlineData("my-agent_v1.0")]
public void MapAGUI_WithAgentBuilder_ValidAgentNameCharacters_DoesNotThrow(string validName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agentBuilder = builder.AddAIAgent(validName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI(agentBuilder);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that IHostedAgentBuilder overload with custom paths can be specified.
/// </summary>
[Fact]
public void MapAGUI_WithAgentBuilder_MultipleAgentsWithCustomPaths_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI(agent1Builder, path: "/api/v1/agent1/agui");
app.MapAGUI(agent2Builder, path: "/api/v1/agent2/agui");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapAGUI throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[Fact]
public void MapAGUI_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapAGUI("agent"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapAGUI throws ArgumentException for null or whitespace agent name.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void MapAGUI_WithAgentName_NullOrWhitespaceAgentName_ThrowsArgumentException(string? agentName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() =>
app.MapAGUI(agentName!));
}
/// <summary>
/// Verifies that MapAGUI with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapAGUI_WithAgentName_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI("agent");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapAGUI with string agent name and custom path works correctly.
/// </summary>
[Fact]
public void MapAGUI_WithAgentName_CustomPath_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("my-agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
app.MapAGUI("my-agent", path: "/agents/my-agent/agui");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that string agent name overload validates agent name characters.
/// </summary>
[Theory]
[InlineData("agent with spaces")]
[InlineData("agent<script>")]
[InlineData("agent?query")]
[InlineData("agent#fragment")]
public void MapAGUI_WithAgentName_InvalidAgentNameCharacters_ThrowsArgumentException(string invalidName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(invalidName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapAGUI(invalidName));
Assert.Contains("invalid for URL routes", exception.Message);
}
/// <summary>
/// Verifies that MapAGUI throws ArgumentNullException for null endpoints when using AIAgent.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
IEndpointRouteBuilder endpoints = null!;
AIAgent agent = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapAGUI(agent));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapAGUI throws ArgumentNullException for null agent.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_NullAgent_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
// Act & Assert
AIAgent agent = null!;
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
app.MapAGUI(agent));
Assert.Equal("agent", exception.ParamName);
}
/// <summary>
/// Verifies that MapAGUI with AIAgent correctly maps the agent.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
app.MapAGUI(agent);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapAGUI with AIAgent and custom path works correctly.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_CustomPath_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
app.MapAGUI(agent, path: "/custom/agui");
Assert.NotNull(app);
}
/// <summary>
/// Verifies that MapAGUI validates agent name characters for URL safety.
/// </summary>
[Theory]
[InlineData("agent with spaces")]
[InlineData("agent<script>")]
[InlineData("agent\nwith\nnewlines")]
[InlineData("agent\twith\ttabs")]
[InlineData("agent?query")]
[InlineData("agent#fragment")]
public void MapAGUI_WithAIAgent_InvalidAgentNameCharacters_ThrowsArgumentException(string invalidName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(invalidName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>(invalidName);
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
app.MapAGUI(agent));
Assert.Contains("invalid for URL routes", exception.Message);
}
/// <summary>
/// Verifies that MapAGUI accepts valid agent names with special characters.
/// </summary>
[Theory]
[InlineData("agent-name")]
[InlineData("agent_name")]
[InlineData("agent.name")]
[InlineData("agent123")]
[InlineData("123agent")]
[InlineData("AGENT")]
[InlineData("my-agent_v1.0")]
public void MapAGUI_WithAIAgent_ValidAgentNameCharacters_DoesNotThrow(string validName)
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(validName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>(validName);
// Act & Assert - Should not throw
app.MapAGUI(agent);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_MultipleAgents_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent1 = app.Services.GetRequiredKeyedService<AIAgent>("agent1");
AIAgent agent2 = app.Services.GetRequiredKeyedService<AIAgent>("agent2");
// Act & Assert - Should not throw
app.MapAGUI(agent1);
app.MapAGUI(agent2);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that long agent names are accepted.
/// </summary>
[Fact]
public void MapAGUI_WithAIAgent_LongAgentName_Succeeds()
{
// Arrange
string longName = new('a', 100);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent(longName, "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>(longName);
// Act & Assert - Should not throw
app.MapAGUI(agent);
Assert.NotNull(app);
}
/// <summary>
/// Verifies that custom paths can be specified for AGUI endpoints.
/// </summary>
[Fact]
public void MapAGUI_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new SimpleMockChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddAGUI();
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
// Act & Assert - Should not throw
app.MapAGUI(agent, path: "/custom/agui/path");
Assert.NotNull(app);
}
}
@@ -1,7 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
@@ -19,3 +24,83 @@ internal static class TestHelpers
}
}
}
/// <summary>
/// Simple mock implementation of IChatClient for basic testing purposes.
/// </summary>
internal sealed class SimpleMockChatClient : IChatClient
{
private readonly string _responseText;
public SimpleMockChatClient(string responseText = "Test response")
{
this._responseText = responseText;
}
public ChatClientMetadata Metadata { get; } = new("Test", new Uri("https://test.example.com"), "test-model");
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
// Count input messages to simulate context size
int messageCount = messages.Count();
ChatMessage message = new(ChatRole.Assistant, this._responseText);
ChatResponse response = new([message])
{
ModelId = "test-model",
FinishReason = ChatFinishReason.Stop,
Usage = new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}
};
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
// Count input messages to simulate context size
int messageCount = messages.Count();
// Split response into words to simulate streaming
string[] words = this._responseText.Split(' ');
for (int i = 0; i < words.Length; i++)
{
string content = i < words.Length - 1 ? words[i] + " " : words[i];
ChatResponseUpdate update = new()
{
Contents = [new TextContent(content)],
Role = ChatRole.Assistant
};
// Add usage to the last update
if (i == words.Length - 1)
{
update.Contents.Add(new UsageContent(new UsageDetails
{
InputTokenCount = 10 + (messageCount * 5),
OutputTokenCount = 5,
TotalTokenCount = 15 + (messageCount * 5)
}));
}
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose()
{
}
}