diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 10db0a47ef4..3c8f9670284 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -38,6 +38,9 @@ + + + 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 e71a6893bac..2dc62f20800 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -40,8 +40,16 @@ public static class AGUIEndpointRouteBuilderExtensions /// The URL pattern for the endpoint. /// An for the mapped endpoint. /// + /// + /// Singleton registrations are resolved once when the endpoint is mapped. Scoped and transient registrations are + /// resolved for each request as described in . + /// In both cases, the session store is resolved for each request and persisted sessions are partitioned by the + /// registration name. + /// + /// /// See for authentication, /// authorization, and caller-scoped session isolation requirements. + /// /// public static IEndpointConventionBuilder MapAGUIServer( this IEndpointRouteBuilder endpoints, @@ -50,7 +58,16 @@ public static IEndpointConventionBuilder MapAGUIServer( { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapAGUIServer(agentBuilder.Name, pattern); + + if (agentBuilder.Lifetime != ServiceLifetime.Singleton) + { + return endpoints.MapAGUIServer(agentBuilder.Name, pattern); + } + + string agentName = agentBuilder.Name; + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return MapAGUIServerCore(endpoints, pattern, context => + CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName)); } /// @@ -61,8 +78,17 @@ public static IEndpointConventionBuilder MapAGUIServer( /// The URL pattern for the endpoint. /// An for the mapped endpoint. /// + /// + /// The keyed , its keyed , and the + /// are resolved from + /// for each request, so the configured DI lifetime of the agent registration is honored. + /// Persisted sessions are partitioned by , which remains stable across + /// scoped or transient agent instances. + /// + /// /// See for authentication, /// authorization, and caller-scoped session isolation requirements. + /// /// public static IEndpointConventionBuilder MapAGUIServer( this IEndpointRouteBuilder endpoints, @@ -72,8 +98,63 @@ public static IEndpointConventionBuilder MapAGUIServer( ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agentName); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapAGUIServer(pattern, agent); + HostedAgentResolution.EnsureAgentRegistered(endpoints.ServiceProvider, agentName); + + return MapAGUIServerCore(endpoints, pattern, context => + { + var agent = context.RequestServices.GetRequiredKeyedService(agentName); + return CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName); + }); + } + + /// + /// Maps an AG-UI agent endpoint that selects the agent to invoke for each request. + /// + /// The endpoint route builder. + /// The URL pattern for the endpoint. + /// + /// A delegate invoked once per request that returns the agent to invoke, or when + /// no agent matches the request. The delegate can use route values, claims, headers, and + /// to select or resolve the agent. + /// + /// An for the mapped endpoint. + /// + /// + /// When returns , the endpoint responds with + /// 404 Not Found without loading session state. + /// + /// + /// The selected agent's is its stable logical identity: it is used to resolve the + /// keyed from and to partition + /// persisted sessions, so conversations continue across requests even when the selector returns a new agent + /// instance each time. The selected agent must therefore have a non-empty name, and agents that must not + /// share persisted sessions must have different names. + /// + /// + /// Selection is not an authorization boundary. Enforce authorization with ASP.NET Core authorization + /// policies, or return from for agents the caller must + /// not reach. See for authentication, + /// authorization, and caller-scoped session isolation requirements. + /// + /// + public static IEndpointConventionBuilder MapAGUIServer( + this IEndpointRouteBuilder endpoints, + [StringSyntax("route")] string pattern, + Func agentSelector) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentSelector); + + return MapAGUIServerCore(endpoints, pattern, context => + { + if (agentSelector(context) is not { } agent) + { + return null; + } + + string agentName = HostedAgentResolution.GetSelectedAgentName(agent); + return CreateHostAgent(context.RequestServices, agent, agentName, sessionStorageIdentity: agentName); + }); } /// @@ -88,6 +169,15 @@ public static IEndpointConventionBuilder MapAGUIServer( /// If an is registered in dependency injection keyed by the agent's name, /// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the /// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted). + /// The supplied agent instance is used for every request, while the session store is resolved from + /// for each request. + /// + /// + /// The agent's is its stable logical identity: it keys the session store and partitions + /// persisted sessions, as it does for the named, hosted, and selector mappings. The same agent exposed through + /// several mappings therefore continues the same conversations for a given thread ID, and so does any agent + /// instance with the same name that uses the same session store. Agents that must not share persisted sessions must + /// have different names. Sessions of an agent without a name are partitioned by its instance identity. /// /// /// Trust model. The AG-UI RunAgentInput.ThreadId arrives @@ -125,18 +215,18 @@ public static IEndpointConventionBuilder MapAGUIServer( ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(aiAgent); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name); - - // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. - var isolationKeyProvider = endpoints.ServiceProvider.GetService(); - if (agentSessionStore?.GetService() is null) - { - agentSessionStore ??= new NoopAgentSessionStore(); - agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); - } - - var hostAgent = new AIHostAgent(aiAgent, agentSessionStore); + // A named agent is identified by its name, like the named, hosted, and selector mappings, so the same agent + // exposed through several mappings continues the same conversations. Unnamed agents keep their instance identity. + string? sessionStorageIdentity = string.IsNullOrWhiteSpace(aiAgent.Name) ? null : aiAgent.Name; + return MapAGUIServerCore(endpoints, pattern, context => + CreateHostAgent(context.RequestServices, aiAgent, aiAgent.Name, sessionStorageIdentity)); + } + private static IEndpointConventionBuilder MapAGUIServerCore( + IEndpointRouteBuilder endpoints, + string pattern, + Func hostAgentFactory) + { IEndpointConventionBuilder endpoint = endpoints.MapPost(pattern, async ( [FromBody] RunAgentInput? input, [FromServices] IOptions jsonOptions, @@ -148,6 +238,12 @@ public static IEndpointConventionBuilder MapAGUIServer( return Results.BadRequest(); } + // Resolve the agent before any session state is loaded so an unmatched selection never touches storage. + if (hostAgentFactory(context) is not { } hostAgent) + { + return Results.NotFound(); + } + var jsonSerializerOptions = jsonOptions.Value.SerializerOptions; var streamOptions = context.GetEndpoint()?.Metadata.GetMetadata() ?? context.RequestServices.GetService>()?.Value; @@ -190,6 +286,25 @@ public static IEndpointConventionBuilder MapAGUIServer( return endpoint; } + private static AIHostAgent CreateHostAgent( + IServiceProvider services, + AIAgent agent, + string? sessionStoreKey, + string? sessionStorageIdentity) + { + var agentSessionStore = services.GetKeyedService(sessionStoreKey); + + // Ensure that we have an IsolationKeyScopedAgentSessionStore registered. + var isolationKeyProvider = services.GetService(); + if (agentSessionStore?.GetService() is null) + { + agentSessionStore ??= new NoopAgentSessionStore(); + agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null }); + } + + return new AIHostAgent(agent, agentSessionStore, sessionStorageIdentity); + } + private static void MarkFeatureUsed() { #pragma warning disable MAAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index ac9b03f75a4..3cb7c69355d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -7,6 +7,7 @@ $(NoWarn);MAAI001 $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated true + true diff --git a/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs new file mode 100644 index 00000000000..42d9638d61a --- /dev/null +++ b/dotnet/src/Shared/HostedAgents/HostedAgentResolution.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides validation shared by protocol hosting integrations that resolve agents for each request. +/// +internal static class HostedAgentResolution +{ + /// + /// Verifies that a keyed registration exists without resolving it. + /// + /// The application service provider. + /// The name of the keyed agent registration. + /// No agent is registered with . + /// + /// Resolving the agent from the application service provider would capture scoped or transient registrations, + /// so the check is skipped when the provider cannot report keyed registrations. + /// + public static void EnsureAgentRegistered(IServiceProvider services, string agentName) + { + if (services.GetService() is { } isKeyedService && + !isKeyedService.IsKeyedService(typeof(AIAgent), agentName)) + { + throw new InvalidOperationException( + $"No {nameof(AIAgent)} is registered with the name '{agentName}'. " + + $"Register it with AddAIAgent(\"{agentName}\", ...) before mapping the endpoint."); + } + } + + /// + /// Gets the stable logical identity of an agent returned by a request-time agent selector. + /// + /// The selected agent. + /// The agent name, which identifies the agent's hosted services and persisted sessions. + /// The agent does not have a name. + public static string GetSelectedAgentName(AIAgent agent) + { + string? agentName = agent.Name; + if (string.IsNullOrWhiteSpace(agentName)) + { + throw new InvalidOperationException( + $"The agent selector returned an {nameof(AIAgent)} without a {nameof(AIAgent.Name)}. " + + "Selected agents must have a stable name, which identifies their hosted services and persisted sessions across requests."); + } + + return agentName!; + } +} 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 5c6367ef652..591c3c4909a 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 @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -74,16 +75,12 @@ public void MapAGUIServer_MapsEndpoint_AtSpecifiedPattern() } [Fact] - public void MapAGUIServer_WithAgentName_ResolvesKeyedAgentFromDI() + public void MapAGUIServer_WithAgentName_DoesNotResolveAgentAtMapTime() { // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); - AIAgent agent = new NamedTestAgent(); - - serviceProviderMock.As() - .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) - .Returns(agent); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -94,23 +91,48 @@ public void MapAGUIServer_WithAgentName_ResolvesKeyedAgentFromDI() // Assert Assert.NotNull(result); serviceProviderMock.As() - .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny()), Times.Never); } - [Fact] - public void MapAGUIServer_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + [Theory] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void MapAGUIServer_WithNonSingletonHostedAgentBuilder_DoesNotResolveAgentAtMapTime(ServiceLifetime lifetime) { // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); Mock agentBuilderMock = new(); - AIAgent agent = new NamedTestAgent(); + serviceProviderMock.As(); agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + agentBuilderMock.Setup(b => b.Lifetime).Returns(lifetime); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer(agentBuilderMock.Object, "/api/agent"); + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUIServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAtMapTime() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock agentBuilderMock = new(); serviceProviderMock.As() .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) - .Returns(agent); + .Returns(new NamedTestAgent()); + + agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + agentBuilderMock.Setup(b => b.Lifetime).Returns(ServiceLifetime.Singleton); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -125,17 +147,13 @@ public void MapAGUIServer_WithHostedAgentBuilder_ResolvesAgentByBuilderName() } [Fact] - public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() + public void MapAGUIServer_WithAgent_DoesNotResolveSessionStoreAtMapTime() { // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); - Mock sessionStoreMock = new(); AIAgent agent = new NamedTestAgent(); - - serviceProviderMock.As() - .Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent")) - .Returns(sessionStoreMock.Object); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -146,8 +164,43 @@ public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() // Assert Assert.NotNull(result); serviceProviderMock.As() - .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); - sessionStoreMock.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); + .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), It.IsAny()), Times.Never); + } + + [Fact] + public void MapAGUIServer_WithAgentSelector_DoesNotInvokeSelectorAtMapTime() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + int selectorInvocations = 0; + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUIServer("/api/{agentId}", _ => + { + selectorInvocations++; + return new NamedTestAgent(); + }); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, selectorInvocations); + } + + [Fact] + public void MapAGUIServer_WithNullAgentSelector_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUIServer("/api/agent", (Func)null!)); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs new file mode 100644 index 00000000000..18f750a05e0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIRequestScopedAgentTests.cs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests; + +/// +/// Verifies that AG-UI endpoints resolve hosted agents and their session services for each request. +/// +public sealed class AGUIRequestScopedAgentTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MapAGUIServer_WithScopedAgent_ResolvesAgentFromRequestScopeAsync() + { + // Arrange + ConcurrentQueue createdAgents = new(); + await this.StartAsync( + services => + { + services.AddScoped(); + services.AddAIAgent( + "assistant", + (sp, name) => + { + TurnCountingAgent agent = new(name, sp.GetRequiredService().Id); + createdAgents.Enqueue(agent); + return agent; + }, + ServiceLifetime.Scoped) + .WithInMemorySessionStore(withIsolation: false); + }, + app => app.MapAGUIServer("assistant", "/ag-ui")); + + // Act + string firstResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + TurnCountingAgent[] agents = [.. createdAgents]; + Assert.Equal(2, agents.Length); + Assert.NotEqual(agents[0].Id, agents[1].Id); + Assert.NotEqual(agents[0].Label, agents[1].Label); + Assert.Contains("assistant turn 1", firstResponse); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithHostedAgentBuilder_ResolvesTransientAgentPerRequestAsync() + { + // Arrange + int createdAgents = 0; + IHostedAgentBuilder? agentBuilder = null; + await this.StartAsync( + services => + { + agentBuilder = services.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }, + ServiceLifetime.Transient) + .WithInMemorySessionStore(withIsolation: false); + }, + app => app.MapAGUIServer(agentBuilder!, "/ag-ui")); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(2, createdAgents); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithSingletonHostedAgentBuilder_ResolvesAgentOnceAtMapTimeAsync() + { + // Arrange + int createdAgents = 0; + IHostedAgentBuilder? agentBuilder = null; + await this.StartAsync( + services => + { + agentBuilder = services.AddAIAgent( + "assistant", + (_, name) => + { + Interlocked.Increment(ref createdAgents); + return new TurnCountingAgent(name); + }) + .WithInMemorySessionStore(withIsolation: false); + }, + app => + { + app.MapAGUIServer(agentBuilder!, "/ag-ui"); + Assert.Equal(1, createdAgents); + }); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(1, createdAgents); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgent_ResolvesScopedSessionStoreFromRequestScopeAsync() + { + // Arrange + InMemoryAgentSessionStore sharedStore = new(); + int createdStores = 0; + TurnCountingAgent agent = new("assistant"); + await this.StartAsync( + services => services.AddKeyedScoped("assistant", (_, _) => + { + Interlocked.Increment(ref createdStores); + return sharedStore; + }), + app => app.MapAGUIServer("/ag-ui", agent)); + + // Act + await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + + // Assert + Assert.Equal(2, createdStores); + Assert.Contains("assistant turn 2", secondResponse); + } + + [Fact] + public async Task MapAGUIServer_WithSameAgentMappedByNameAndInstance_SharesConversationAsync() + { + // Arrange + TurnCountingAgent agent = new("assistant"); + await this.StartAsync( + services => services.AddAIAgent("assistant", (_, _) => agent).WithInMemorySessionStore(withIsolation: false), + app => + { + app.MapAGUIServer(agentName: "assistant", pattern: "/chat"); + app.MapAGUIServer(pattern: "/chat-alias", aiAgent: agent); + }); + + // Act + string namedResponse = await this.PostRunAsync("/chat", "thread-1"); + string instanceResponse = await this.PostRunAsync("/chat-alias", "thread-1"); + string namedAgainResponse = await this.PostRunAsync("/chat", "thread-1"); + + // Assert + Assert.Contains("assistant turn 1", namedResponse); + Assert.Contains("assistant turn 2", instanceResponse); + Assert.Contains("assistant turn 3", namedAgainResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentInstances_PartitionsSessionsByNameAsync() + { + // Arrange + await this.StartAsync( + services => services.AddKeyedSingleton(KeyedService.AnyKey, new InMemoryAgentSessionStore()), + app => + { + app.MapAGUIServer("/first", new TurnCountingAgent("assistant")); + app.MapAGUIServer("/second", new TurnCountingAgent("assistant")); + app.MapAGUIServer("/other", new TurnCountingAgent("other")); + }); + + // Act + string firstResponse = await this.PostRunAsync("/first", "thread-1"); + string sameNameResponse = await this.PostRunAsync("/second", "thread-1"); + string otherNameResponse = await this.PostRunAsync("/other", "thread-1"); + + // Assert + Assert.Contains("assistant turn 1", firstResponse); + Assert.Contains("assistant turn 2", sameNameResponse); + Assert.Contains("other turn 1", otherNameResponse); + } + + [Fact] + public async Task MapAGUIServer_WithUnnamedAgentInstances_PartitionsSessionsByInstanceAsync() + { + // Arrange + TurnCountingAgent agent = new(name: null); + await this.StartAsync( + services => services.AddSingleton(new InMemoryAgentSessionStore()), + app => + { + app.MapAGUIServer("/first", agent); + app.MapAGUIServer("/second", new TurnCountingAgent(name: null)); + }); + + // Act + await this.PostRunAsync("/first", "thread-1"); + string sameInstanceResponse = await this.PostRunAsync("/first", "thread-1"); + string otherInstanceResponse = await this.PostRunAsync("/second", "thread-1"); + + // Assert + Assert.Contains(" turn 2", sameInstanceResponse); + Assert.Contains(" turn 1", otherInstanceResponse); + } + + [Fact] + public async Task MapAGUIServer_WithUnregisteredAgentName_ThrowsAtMapTimeAsync() + { + // Arrange + await using WebApplication app = CreateBuilder().Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => app.MapAGUIServer("missing", "/ag-ui")); + Assert.Contains("missing", exception.Message); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelector_SelectsAgentFromRouteValueAsync() + { + // Arrange + await this.StartAsync( + services => + { + services.AddAIAgent("alpha", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped); + services.AddAIAgent("beta", (_, name) => new TurnCountingAgent(name), ServiceLifetime.Scoped); + }, + app => app.MapAGUIServer("/agents/{agentId}/ag-ui", SelectAgentFromRoute)); + + // Act + string alphaResponse = await this.PostRunAsync("/agents/alpha/ag-ui", "thread-1"); + string betaResponse = await this.PostRunAsync("/agents/beta/ag-ui", "thread-1"); + + // Assert + Assert.Contains("alpha turn 1", alphaResponse); + Assert.Contains("beta turn 1", betaResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelectorReturningNull_ReturnsNotFoundWithoutLoadingSessionAsync() + { + // Arrange + int sessionStoreResolutions = 0; + await this.StartAsync( + services => services.AddKeyedScoped(KeyedService.AnyKey, (_, _) => + { + Interlocked.Increment(ref sessionStoreResolutions); + return new InMemoryAgentSessionStore(); + }), + app => app.MapAGUIServer("/agents/{agentId}/ag-ui", _ => null)); + + // Act + using HttpResponseMessage response = await this.SendRunAsync("/agents/unknown/ag-ui", "thread-1"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(0, sessionStoreResolutions); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelector_PersistsSessionsAcrossNewAgentInstancesAsync() + { + // Arrange + await this.StartAsync( + services => services.AddKeyedSingleton("dynamic", new InMemoryAgentSessionStore()), + app => app.MapAGUIServer("/ag-ui", _ => new TurnCountingAgent("dynamic"))); + + // Act + string firstResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string secondResponse = await this.PostRunAsync("/ag-ui", "thread-1"); + string otherThreadResponse = await this.PostRunAsync("/ag-ui", "thread-2"); + + // Assert + Assert.Contains("dynamic turn 1", firstResponse); + Assert.Contains("dynamic turn 2", secondResponse); + Assert.Contains("dynamic turn 1", otherThreadResponse); + } + + [Fact] + public async Task MapAGUIServer_WithAgentSelectorReturningUnnamedAgent_FailsRequestAsync() + { + // Arrange + await this.StartAsync( + _ => { }, + app => app.MapAGUIServer("/ag-ui", _ => new TurnCountingAgent(name: null))); + + // Act & Assert + InvalidOperationException exception = await Assert.ThrowsAsync(() => this.SendRunAsync("/ag-ui", "thread-1")); + Assert.Contains("stable name", exception.Message); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app is not null) + { + await this._app.DisposeAsync(); + } + } + + private static AIAgent? SelectAgentFromRoute(HttpContext context) => + context.GetRouteValue("agentId") is string agentId + ? context.RequestServices.GetKeyedService(agentId) + : null; + + private static WebApplicationBuilder CreateBuilder() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseDefaultServiceProvider(options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + }); + builder.Services.AddAGUIServer(); + return builder; + } + + private async Task StartAsync(Action configureServices, Action mapEndpoints) + { + WebApplicationBuilder builder = CreateBuilder(); + configureServices(builder.Services); + + this._app = builder.Build(); + mapEndpoints(this._app); + await this._app.StartAsync(); + + this._client = this._app.GetTestClient(); + } + + private async Task PostRunAsync(string path, string threadId) + { + using HttpResponseMessage response = await this.SendRunAsync(path, threadId); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + private async Task SendRunAsync(string path, string threadId) + { + string json = $$$""" + {"threadId":"{{{threadId}}}","runId":"{{{Guid.NewGuid():N}}}","messages":[{"id":"m1","role":"user","content":"hi"}],"tools":[],"context":[],"state":{}} + """; + using StringContent content = new(json, Encoding.UTF8, "application/json"); + return await this._client!.PostAsync(new Uri(path, UriKind.Relative), content); + } + + [SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] + private sealed class RequestDependency + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + } + + private sealed class TurnCountingAgent(string? name, string? label = null) : AIAgent + { + public override string? Name => name; + + public string? Label => label; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TurnCountingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(session.StateBag.Serialize()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new TurnCountingSession(AgentSessionStateBag.Deserialize(serializedState))); + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int turn = 1; + if (session is not null) + { + if (session.StateBag.TryGetValue("turn", out string? previousTurn)) + { + turn = int.Parse(previousTurn!, CultureInfo.InvariantCulture) + 1; + } + + session.StateBag.SetValue("turn", turn.ToString(CultureInfo.InvariantCulture)); + } + + await Task.Yield(); + yield return new AgentResponseUpdate + { + MessageId = "message-1", + Role = ChatRole.Assistant, + Contents = [new TextContent($"{name} turn {turn}")] + }; + } + } + + private sealed class TurnCountingSession : AgentSession + { + public TurnCountingSession() + { + } + + public TurnCountingSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj index 8497c2d761d..d3918e551ad 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests.csproj @@ -5,6 +5,7 @@ +