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
@@ -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()
{
}
}