mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
+95
-17
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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();
|
||||
|
||||
+479
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user