diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 85fd00fb8b..2fb836aa56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -105,59 +105,113 @@ public static IEndpointConventionBuilder MapAGUI( var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(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)); + } + + /// + /// Maps an AG-UI agent endpoint that resolves the agent per request via a factory + /// delegate, rather than capturing a single agent instance at startup. + /// + /// The endpoint route builder. + /// The logical agent name passed to the factory and used as the + /// resolution key. + /// The URL pattern for the endpoint. + /// A factory invoked once per request with the request's + /// and the , returning the + /// that handles that request. + /// An for the mapped endpoint. + /// + /// + /// 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 MapAGUI overloads do). The + /// factory receives the request's (), + /// so scoped services resolve correctly within the request. + /// + /// + /// The keyed lookup and the ThreadId trust model are identical + /// to ; the store is resolved per request + /// from using as the key. + /// + /// + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("route")] string pattern, + Func 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."); - var jsonOptions = context.RequestServices.GetRequiredService>(); - var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; + var agentSessionStore = context.RequestServices.GetKeyedService(agentName); + var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore()); + return HandleRunAsync(input, hostAgent, context, cancellationToken); + }); + } - var messages = input.Messages.AsChatMessages(jsonSerializerOptions); - var clientTools = input.Tools?.AsAITools().ToList(); + private static async Task HandleRunAsync( + RunAgentInput? input, + AIHostAgent hostAgent, + HttpContext context, + CancellationToken cancellationToken) + { + if (input is null) + { + return Results.BadRequest(); + } + + var jsonOptions = context.RequestServices.GetRequiredService>(); + 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(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(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>(); - 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>(); + return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); } private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs new file mode 100644 index 0000000000..b8857f2f1b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/MapAGUIFactoryDelegateTests.cs @@ -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; + +/// +/// Integration tests for the per-request factory-delegate overload of +/// MapAGUI(endpoints, agentName, pattern, Func<IServiceProvider, string, AIAgent>). +/// Unlike the startup-capture overloads, the factory is invoked once per request from the request's +/// . +/// +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 firstTurn = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "First")], session, new AgentRunOptions(), CancellationToken.None)) + { + firstTurn.Add(update); + } + + List 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 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()) + .WithMessage("*factory for 'factory-agent' returned null*"); + } + + private async Task SetupTestServerWithFactoryAsync(Func 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() 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(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 248629b392..27dcd60e5a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -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 endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + + 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); + } + + [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 endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + + 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() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUI_WithNullFactoryDelegate_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI("test-agent", "/api/agent", (Func)null!)); + } + [Fact] public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync() {