Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,59 +105,113 @@ public static IEndpointConventionBuilder MapAGUI(
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());

return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
return endpoints.MapPost(pattern, ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken)
=> HandleRunAsync(input, hostAgent, context, cancellationToken));
}

/// <summary>
/// Maps an AG-UI agent endpoint that resolves the agent <strong>per request</strong> via a factory
/// delegate, rather than capturing a single agent instance at startup.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentName">The logical agent name passed to the factory and used as the
/// <see cref="AgentSessionStore"/> resolution key.</param>
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <param name="createAgentDelegate">A factory invoked once per request with the request's
/// <see cref="IServiceProvider"/> and the <paramref name="agentName"/>, returning the
/// <see cref="AIAgent"/> that handles that request.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
/// <remarks>
/// <para>
/// Use this overload when the agent must be built with request-scoped state — per-request
/// authentication, scoped tool/MCP sessions, or conversation-scoped configuration — rather than a
/// process-wide singleton resolved once at startup (as the other <c>MapAGUI</c> overloads do). The
/// factory receives the request's <see cref="IServiceProvider"/> (<see cref="HttpContext.RequestServices"/>),
/// so scoped services resolve correctly within the request.
/// </para>
/// <para>
/// The keyed <see cref="AgentSessionStore"/> lookup and the <c>ThreadId</c> trust model are identical
/// to <see cref="MapAGUI(IEndpointRouteBuilder, string, AIAgent)"/>; the store is resolved per request
/// from <see cref="HttpContext.RequestServices"/> using <paramref name="agentName"/> as the key.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("route")] string pattern,
Func<IServiceProvider, string, AIAgent> createAgentDelegate)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
ArgumentNullException.ThrowIfNull(createAgentDelegate);

return endpoints.MapPost(pattern, ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
if (input is null)
{
return Results.BadRequest();
}
var aiAgent = createAgentDelegate(context.RequestServices, agentName)
?? throw new InvalidOperationException($"The agent factory for '{agentName}' returned null.");
Comment thread
darthmolen marked this conversation as resolved.

var jsonOptions = context.RequestServices.GetRequiredService<IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions>>();
var jsonSerializerOptions = jsonOptions.Value.SerializerOptions;
var agentSessionStore = context.RequestServices.GetKeyedService<AgentSessionStore>(agentName);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
return HandleRunAsync(input, hostAgent, context, cancellationToken);
Comment thread
darthmolen marked this conversation as resolved.
});
}

var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var clientTools = input.Tools?.AsAITools().ToList();
private static async Task<IResult> HandleRunAsync(
RunAgentInput? input,
AIHostAgent hostAgent,
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;

// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
var messages = input.Messages.AsChatMessages(jsonSerializerOptions);
var clientTools = input.Tools?.AsAITools().ToList();

// Create run options with AG-UI context in AdditionalProperties
var runOptions = new ChatClientAgentRunOptions
{
ChatOptions = new ChatOptions
{
ChatOptions = new ChatOptions
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
Tools = clientTools,
AdditionalProperties = new AdditionalPropertiesDictionary
{
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
["ag_ui_state"] = input.State,
["ag_ui_context"] = input.Context?.Select(c => new KeyValuePair<string, string>(c.Description, c.Value)).ToArray(),
["ag_ui_forwarded_properties"] = input.ForwardedProperties,
["ag_ui_thread_id"] = input.ThreadId,
["ag_ui_run_id"] = input.RunId
}
};

var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId;
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);

// Run the agent and convert to AG-UI events
var events = hostAgent.RunStreamingAsync(
messages,
session: session,
options: runOptions,
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
.FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
.AsAGUIEventStreamAsync(
threadId,
input.RunId,
jsonSerializerOptions,
cancellationToken);

// Wrap the event stream to save the session after streaming completes
var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);

var sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
});
}
};

var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId;
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);

// Run the agent and convert to AG-UI events
var events = hostAgent.RunStreamingAsync(
messages,
session: session,
options: runOptions,
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
.FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
.AsAGUIEventStreamAsync(
threadId,
input.RunId,
jsonSerializerOptions,
cancellationToken);

// Wrap the event stream to save the session after streaming completes
var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);

var sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
}

private static async IAsyncEnumerable<BaseEvent> SaveSessionAfterStreamingAsync(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.AGUI;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests;

/// <summary>
/// Integration tests for the per-request factory-delegate overload of
/// <c>MapAGUI(endpoints, agentName, pattern, Func&lt;IServiceProvider, string, AIAgent&gt;)</c>.
/// Unlike the startup-capture overloads, the factory is invoked once per request from the request's
/// <see cref="IServiceProvider"/>.
/// </summary>
public sealed class MapAGUIFactoryDelegateTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;

[Fact]
public async Task MapAGUI_WithFactoryDelegate_InvokesFactoryPerRequest_AndStreamsAsync()
{
// Arrange - map the endpoint with a factory that records how many times it is invoked.
int factoryInvocations = 0;
await this.SetupTestServerWithFactoryAsync((_, name) =>
{
Interlocked.Increment(ref factoryInvocations);
return new FakeSessionAgent(name);
});

var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
AgentSession session = await agent.CreateSessionAsync();

// Act - two turns => two HTTP requests.
List<AgentResponseUpdate> firstTurn = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session, new AgentRunOptions(), CancellationToken.None))
{
firstTurn.Add(update);
}

List<AgentResponseUpdate> secondTurn = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Second")], session, new AgentRunOptions(), CancellationToken.None))
{
secondTurn.Add(update);
}

// Assert - the factory ran once per request (not captured once at startup), and the agent streamed.
factoryInvocations.Should().Be(2, "the factory delegate is invoked per request");
firstTurn.Should().NotBeEmpty();
firstTurn.ToAgentResponse().Messages[0].Text.Should().Contain("Hello from session agent");
}

[Fact]
public async Task MapAGUI_WithFactoryDelegate_WhenFactoryReturnsNull_FailsTheRequestAsync()
{
// Arrange - a factory that returns null should surface a clear failure when a request arrives.
await this.SetupTestServerWithFactoryAsync((_, _) => null!);

const string Json = """
{"threadId":"t1","runId":"r1","messages":[{"id":"m1","role":"user","content":"hi"}],"tools":[],"context":[],"state":{}}
""";
using StringContent content = new(Json, Encoding.UTF8, "application/json");

// Act
Func<Task> act = async () =>
{
using HttpResponseMessage response = await this._client!.PostAsync((Uri?)null, content);
response.EnsureSuccessStatusCode();
};

// Assert - the factory returning null surfaces a clear InvalidOperationException naming the agent.
(await act.Should().ThrowAsync<InvalidOperationException>())
.WithMessage("*factory for 'factory-agent' returned null*");
}

private async Task SetupTestServerWithFactoryAsync(Func<IServiceProvider, string, AIAgent> factory)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddAGUI();

this._app = builder.Build();

// Per-request factory overload — no keyed AIAgent registration required.
this._app.MapAGUI("factory-agent", "/agent", factory);

await this._app.StartAsync();

TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");

this._client = testServer.CreateClient();
this._client.BaseAddress = new Uri("http://localhost/agent");
}

public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,70 @@ public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException()
endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent"));
}

[Fact]
public void MapAGUI_WithFactoryDelegate_MapsEndpoint_AtSpecifiedPattern()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();

endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);

// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(
"test-agent", "/api/agent", static (_, _) => new NamedTestAgent());

// Assert
Assert.NotNull(result);
}
Comment thread
darthmolen marked this conversation as resolved.

[Fact]
public void MapAGUI_WithFactoryDelegate_DefersResolution_DoesNotInvokeFactoryOrResolveAgentAtMapTime()
{
// Arrange — the factory overload resolves the agent per request, so mapping must NOT invoke
// the factory nor resolve a keyed AIAgent from DI (unlike the startup-capture agentName overload).
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();

endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);

int factoryInvocations = 0;

// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(
"test-agent",
"/api/agent",
(_, _) =>
{
factoryInvocations++;
return new NamedTestAgent();
});

// Assert
Assert.NotNull(result);
Assert.Equal(0, factoryInvocations);
serviceProviderMock.As<IKeyedServiceProvider>()
.Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny<object>()), Times.Never);
}

[Fact]
public void MapAGUI_WithNullFactoryDelegate_ThrowsArgumentNullException()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);

// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
endpointsMock.Object.MapAGUI("test-agent", "/api/agent", (Func<IServiceProvider, string, AIAgent>)null!));
}

[Fact]
public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync()
{
Expand Down