Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs
T
Javier Calvarro NelsonandGitHub e859edc2a4 .NET: AG-UI support for .NET: Support for tool calling (#1896)
* Initial implementation

* tmp

* Replace function calling with a FunctionInvokingChatClient

* Cleanups

* Remove custom thread

* Fixing function calling server and client

* Cleanup

* Cleanup serialization

* Run dotnet format

* Pass logger factory

* Populate message properties

* Remove files

* Cleanups

* cleanup

* Cleanups

* More cleanup

* Simplify things

* Cleanup

* Clean up json serialization

* Additional tests

* Add service collection extensions for serialization

* Combine options in AGUIChatClient

* Additional tests

* Include tool calling in the sample, fix mixed server and client tool calls

* Fix tests

* More cleanups

* Fix tests

* Cleanups

* Dojo project and fixes

* Fix build

* Remove dojo

* Cleanup

* Address feedback

* address feedback

* Additional feedback

* Fix build

* Fix build

* Make packages packable
2025-11-07 17:23:21 +00:00

280 lines
10 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AGUIEndpointRouteBuilderExtensions"/> class.
/// </summary>
public sealed class AGUIEndpointRouteBuilderExtensionsTests
{
[Fact]
public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
const string Pattern = "/api/agent";
AIAgent agent = new TestAgent();
// Act
IEndpointConventionBuilder? result = AGUIEndpointRouteBuilderExtensions.MapAGUI(endpointsMock.Object, Pattern, agent);
// Assert
Assert.NotNull(result);
}
[Fact]
public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync()
{
// Arrange
DefaultHttpContext context = new();
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("invalid json"));
context.RequestAborted = CancellationToken.None;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, ctx, props) => new TestAgent());
// Act
await handler(context);
// Assert
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Fact]
public async Task MapAGUIAgent_InvokesAgentFactory_WithCorrectMessagesAndContextAsync()
{
// Arrange
List<ChatMessage>? capturedMessages = null;
IEnumerable<KeyValuePair<string, string>>? capturedContext = null;
AIAgent factory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
capturedMessages = messages.ToList();
capturedContext = context;
return new TestAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }],
Context = [new AGUIContextItem { Description = "key1", Value = "value1" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
httpContext.Response.Body = new MemoryStream();
RequestDelegate handler = this.CreateRequestDelegate(factory);
// Act
await handler(httpContext);
// Assert
Assert.NotNull(capturedMessages);
Assert.Single(capturedMessages);
Assert.Equal("Test", capturedMessages[0].Text);
Assert.NotNull(capturedContext);
Assert.Contains(capturedContext, kvp => kvp.Key == "key1" && kvp.Value == "value1");
}
[Fact]
public async Task MapAGUIAgent_ReturnsSSEResponseStream_WithCorrectContentTypeAsync()
{
// Arrange
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
httpContext.Response.Body = new MemoryStream();
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act
await handler(httpContext);
// Assert
Assert.Equal("text/event-stream", httpContext.Response.ContentType);
}
[Fact]
public async Task MapAGUIAgent_PassesCancellationToken_ToAgentExecutionAsync()
{
// Arrange
using CancellationTokenSource cts = new();
cts.Cancel();
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages = [new AGUIUserMessage { Id = "m1", Content = "Test" }]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
httpContext.Response.Body = new MemoryStream();
httpContext.RequestAborted = cts.Token;
RequestDelegate handler = this.CreateRequestDelegate((messages, tools, context, props) => new TestAgent());
// Act & Assert
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => handler(httpContext));
}
[Fact]
public async Task MapAGUIAgent_ConvertsInputMessages_ToChatMessagesBeforeFactoryAsync()
{
// Arrange
List<ChatMessage>? capturedMessages = null;
AIAgent factory(IEnumerable<ChatMessage> messages, IEnumerable<AITool> tools, IEnumerable<KeyValuePair<string, string>> context, JsonElement props)
{
capturedMessages = messages.ToList();
return new TestAgent();
}
DefaultHttpContext httpContext = new();
RunAgentInput input = new()
{
ThreadId = "thread1",
RunId = "run1",
Messages =
[
new AGUIUserMessage { Id = "m1", Content = "First" },
new AGUIAssistantMessage { Id = "m2", Content = "Second" }
]
};
string json = JsonSerializer.Serialize(input, AGUIJsonSerializerContext.Default.RunAgentInput);
httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json));
httpContext.Response.Body = new MemoryStream();
RequestDelegate handler = this.CreateRequestDelegate(factory);
// Act
await handler(httpContext);
// Assert
Assert.NotNull(capturedMessages);
Assert.Equal(2, capturedMessages.Count);
Assert.Equal(ChatRole.User, capturedMessages[0].Role);
Assert.Equal("First", capturedMessages[0].Text);
Assert.Equal(ChatRole.Assistant, capturedMessages[1].Role);
Assert.Equal("Second", capturedMessages[1].Text);
}
private RequestDelegate CreateRequestDelegate(
Func<IEnumerable<ChatMessage>, IEnumerable<AITool>, IEnumerable<KeyValuePair<string, string>>, JsonElement, AIAgent> factory)
{
return async context =>
{
CancellationToken cancellationToken = context.RequestAborted;
RunAgentInput? input;
try
{
input = await JsonSerializer.DeserializeAsync(
context.Request.Body,
AGUIJsonSerializerContext.Default.RunAgentInput,
cancellationToken).ConfigureAwait(false);
}
catch (JsonException)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
if (input is null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
IEnumerable<ChatMessage> messages = input.Messages.AsChatMessages(AGUIJsonSerializerContext.Default.Options);
IEnumerable<KeyValuePair<string, string>> contextValues = input.Context.Select(c => new KeyValuePair<string, string>(c.Description, c.Value));
JsonElement forwardedProps = input.ForwardedProperties;
AIAgent agent = factory(messages, [], contextValues, forwardedProps);
IAsyncEnumerable<BaseEvent> events = agent.RunStreamingAsync(
messages,
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
.AsAGUIEventStreamAsync(
input.ThreadId,
input.RunId,
AGUIJsonSerializerContext.Default.Options,
cancellationToken);
ILogger<AGUIServerSentEventsResult> logger = NullLogger<AGUIServerSentEventsResult>.Instance;
await new AGUIServerSentEventsResult(events, logger).ExecuteAsync(context).ConfigureAwait(false);
};
}
private sealed class TestInMemoryAgentThread : InMemoryAgentThread
{
public TestInMemoryAgentThread()
: base()
{
}
public TestInMemoryAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThreadState, jsonSerializerOptions, null)
{
}
}
private sealed class TestAgent : AIAgent
{
public override string Id => "test-agent";
public override string? Description => "Test agent";
public override AgentThread GetNewThread() => new TestInMemoryAgentThread();
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield return new AgentRunResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
}
}
}