From 29f2d11076972976d2a5fdd2e1b234d11ee40e1e Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Thu, 9 Apr 2026 17:23:37 +0000
Subject: [PATCH 1/3] Update AGUI service to support session storage
---
.../AGUIClientServer/AGUIServer/Program.cs | 17 +-
.../AGUIEndpointRouteBuilderExtensions.cs | 79 +++++-
...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 +
...ng.AGUI.AspNetCore.IntegrationTests.csproj | 1 +
.../SessionPersistenceTests.cs | 226 ++++++++++++++++++
...AGUIEndpointRouteBuilderExtensionsTests.cs | 191 +++++++++++++++
6 files changed, 508 insertions(+), 7 deletions(-)
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
index d2c17a5541..cef584ae46 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
@@ -4,6 +4,7 @@
using AGUIServer;
using Azure.AI.OpenAI;
using Azure.Identity;
+using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
@@ -13,11 +14,11 @@
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
builder.Services.AddAGUI();
-WebApplication app = builder.Build();
-
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
+const string AgentName = "AGUIAssistant";
+
// Create the AI agent with tools
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -27,7 +28,7 @@
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
- name: "AGUIAssistant",
+ name: AgentName,
tools: [
AIFunctionFactory.Create(
() => DateTimeOffset.UtcNow,
@@ -48,7 +49,15 @@
AGUIServerSerializerContext.Default.Options)
]);
+// Register the agent with the host and configure it to use an in-memory session store
+// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
+var pirateAgentBuilder = builder
+ .AddAIAgent(AgentName, (_, _) => agent)
+ .WithInMemorySessionStore();
+
+WebApplication app = builder.Build();
+
// Map the AG-UI agent endpoint
-app.MapAGUI("/", agent);
+app.MapAGUI(AgentName, "/");
await app.RunAsync();
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 e20d1ab448..745402c477 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -1,9 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -21,6 +24,41 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
///
public static class AGUIEndpointRouteBuilderExtensions
{
+ ///
+ /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via .
+ ///
+ /// The endpoint route builder.
+ /// The hosted agent builder that identifies the agent registration.
+ /// The URL pattern for the endpoint.
+ /// An for the mapped endpoint.
+ public static IEndpointConventionBuilder MapAGUI(
+ this IEndpointRouteBuilder endpoints,
+ IHostedAgentBuilder agentBuilder,
+ [StringSyntax("route")] string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+ return endpoints.MapAGUI(agentBuilder.Name, pattern);
+ }
+
+ ///
+ /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection.
+ ///
+ /// The endpoint route builder.
+ /// The name of the keyed agent registration to resolve from dependency injection.
+ /// The URL pattern for the endpoint.
+ /// An for the mapped endpoint.
+ public static IEndpointConventionBuilder MapAGUI(
+ this IEndpointRouteBuilder endpoints,
+ string agentName,
+ [StringSyntax("route")] string pattern)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(agentName);
+
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+ return endpoints.MapAGUI(pattern, agent);
+ }
+
///
/// Maps an AG-UI agent endpoint.
///
@@ -28,11 +66,24 @@ public static class AGUIEndpointRouteBuilderExtensions
/// The URL pattern for the endpoint.
/// The agent instance.
/// An for the mapped endpoint.
+ ///
+ ///
+ /// 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).
+ ///
+ ///
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
[StringSyntax("route")] string pattern,
AIAgent aiAgent)
{
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(aiAgent);
+
+ 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) =>
{
if (input is null)
@@ -63,21 +114,43 @@ public static IEndpointConventionBuilder MapAGUI(
}
};
+ var threadId = input.ThreadId ?? Guid.NewGuid().ToString("N");
+ var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
+
// Run the agent and convert to AG-UI events
- var events = aiAgent.RunStreamingAsync(
+ var events = hostAgent.RunStreamingAsync(
messages,
+ session: session,
options: runOptions,
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
.FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
.AsAGUIEventStreamAsync(
- input.ThreadId,
+ 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(events, sseLogger);
+ return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
});
}
+
+ private static async IAsyncEnumerable SaveSessionAfterStreamingAsync(
+ IAsyncEnumerable events,
+ AIHostAgent hostAgent,
+ string threadId,
+ AgentSession session,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (BaseEvent evt in events.ConfigureAwait(false))
+ {
+ yield return evt;
+ }
+
+ await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false);
+ }
}
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 d6169ad805..1565977149 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
@@ -19,6 +19,7 @@
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
index 490f816cd4..e0b072a44b 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj
@@ -21,6 +21,7 @@
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs
new file mode 100644
index 0000000000..785a3b2e00
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs
@@ -0,0 +1,226 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+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;
+
+public sealed class SessionPersistenceTests : IAsyncDisposable
+{
+ private WebApplication? _app;
+ private HttpClient? _client;
+
+ [Fact]
+ public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync()
+ {
+ // Arrange - use hosting DI pattern with InMemorySessionStore.
+ // FakeSessionAgent tracks turn count in session StateBag so we can verify
+ // that state survives the serialization round-trip through the session store.
+ await this.SetupTestServerWithSessionStoreAsync();
+ var chatClient = new AGUIChatClient(this._client!, "", null);
+ AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
+ ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
+
+ // Act - First turn
+ ChatMessage firstUserMessage = new(ChatRole.User, "First message");
+ List firstTurnUpdates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None))
+ {
+ firstTurnUpdates.Add(update);
+ }
+
+ // Act - Second turn (same thread ID to test session persistence)
+ ChatMessage secondUserMessage = new(ChatRole.User, "Second message");
+ List secondTurnUpdates = [];
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None))
+ {
+ secondTurnUpdates.Add(update);
+ }
+
+ // Assert - Verify turn count proves session state was persisted.
+ // If session persistence were broken, both turns would return "Turn 1"
+ // because a fresh session (with turn count 0) would be created each time.
+ AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse();
+ firstResponse.Messages.Should().HaveCount(1);
+ firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
+ firstResponse.Messages[0].Text.Should().Contain("Turn 1:");
+
+ AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse();
+ secondResponse.Messages.Should().HaveCount(1);
+ secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
+ secondResponse.Messages[0].Text.Should().Contain("Turn 2:");
+ }
+
+ [Fact]
+ public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync()
+ {
+ // Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI
+ await this.SetupTestServerWithSessionStoreAsync();
+ var chatClient = new AGUIChatClient(this._client!, "", null);
+ AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
+ ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
+ ChatMessage userMessage = new(ChatRole.User, "hello");
+
+ List updates = [];
+
+ // Act
+ await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None))
+ {
+ updates.Add(update);
+ }
+
+ // Assert
+ updates.Should().NotBeEmpty();
+ updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant));
+
+ AgentResponse response = updates.ToAgentResponse();
+ response.Messages.Should().HaveCount(1);
+ response.Messages[0].Role.Should().Be(ChatRole.Assistant);
+ response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!");
+ }
+
+ private async Task SetupTestServerWithSessionStoreAsync()
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services.AddAGUI();
+
+ // Register agent using hosting DI pattern with InMemorySessionStore
+ builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
+ .WithInMemorySessionStore();
+
+ this._app = builder.Build();
+
+ // Use the agentName overload of MapAGUI
+ this._app.MapAGUI("session-test-agent", "/agent");
+
+ 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();
+ }
+ }
+}
+
+[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
+internal sealed class FakeSessionAgent : AIAgent
+{
+ private readonly string _name;
+
+ public FakeSessionAgent(string name)
+ {
+ this._name = name;
+ }
+
+ protected override string? IdCore => this._name;
+
+ public override string? Name => this._name;
+
+ public override string? Description => "A fake agent with session support for testing";
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new FakeSessionAgentSession());
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
+ new(serializedState.Deserialize(jsonSerializerOptions)!);
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ {
+ if (session is not FakeSessionAgentSession fakeSession)
+ {
+ throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent.");
+ }
+
+ return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions));
+ }
+
+ protected override async Task RunCoreAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List updates = [];
+ await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToAgentResponse();
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Track turn count in session state to enable persistence verification.
+ // If the session store works correctly, the turn count increments across requests.
+ int turnCount = 1;
+ if (session != null)
+ {
+ var counter = session.StateBag.GetValue("turnCounter");
+ turnCount = (counter?.Count ?? 0) + 1;
+ session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount });
+ }
+
+ string messageId = Guid.NewGuid().ToString("N");
+ string prefix = $"Turn {turnCount}: ";
+
+ foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" })
+ {
+ yield return new AgentResponseUpdate
+ {
+ MessageId = messageId,
+ Role = ChatRole.Assistant,
+ Contents = [new TextContent(chunk)]
+ };
+
+ await Task.Yield();
+ }
+ }
+
+ internal sealed class TurnCounter
+ {
+ public int Count { get; set; }
+ }
+
+ private sealed class FakeSessionAgentSession : AgentSession
+ {
+ public FakeSessionAgentSession()
+ {
+ }
+
+ [JsonConstructor]
+ public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag)
+ {
+ }
+ }
+}
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 84a20e1938..248629b392 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
@@ -14,6 +14,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -31,6 +32,7 @@ public void MapAGUIAgent_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([]);
@@ -45,6 +47,155 @@ public void MapAGUIAgent_MapsEndpoint_AtSpecifiedPattern()
Assert.NotNull(result);
}
+ [Fact]
+ public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI()
+ {
+ // Arrange
+ Mock endpointsMock = new();
+ Mock serviceProviderMock = new();
+ AIAgent agent = new NamedTestAgent();
+
+ serviceProviderMock.As()
+ .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
+ .Returns(agent);
+
+ endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
+ endpointsMock.Setup(e => e.DataSources).Returns([]);
+
+ // Act
+ IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent");
+
+ // Assert
+ Assert.NotNull(result);
+ serviceProviderMock.As()
+ .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
+ }
+
+ [Fact]
+ public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName()
+ {
+ // Arrange
+ Mock endpointsMock = new();
+ Mock serviceProviderMock = new();
+ Mock agentBuilderMock = new();
+ AIAgent agent = new NamedTestAgent();
+
+ agentBuilderMock.Setup(b => b.Name).Returns("test-agent");
+
+ serviceProviderMock.As()
+ .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
+ .Returns(agent);
+
+ endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
+ endpointsMock.Setup(e => e.DataSources).Returns([]);
+
+ // Act
+ IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent");
+
+ // Assert
+ Assert.NotNull(result);
+ serviceProviderMock.As()
+ .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
+ }
+
+ [Fact]
+ public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI()
+ {
+ // 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);
+
+ endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
+ endpointsMock.Setup(e => e.DataSources).Returns([]);
+
+ // Act
+ IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
+
+ // Assert
+ Assert.NotNull(result);
+ serviceProviderMock.As()
+ .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once);
+ }
+
+ [Fact]
+ public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore()
+ {
+ // Arrange
+ Mock endpointsMock = new();
+ Mock serviceProviderMock = new();
+ AIAgent agent = new TestAgent();
+
+ // No session store registered - IKeyedServiceProvider returns null by default
+ serviceProviderMock.As();
+
+ endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
+ endpointsMock.Setup(e => e.DataSources).Returns([]);
+
+ // Act - should not throw (falls back to NoopAgentSessionStore)
+ IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
+
+ // Assert
+ Assert.NotNull(result);
+ }
+
+ [Fact]
+ public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException()
+ {
+ // Arrange
+ AIAgent agent = new TestAgent();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent));
+ }
+
+ [Fact]
+ public void MapAGUI_WithNullAgent_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("/api/agent", (AIAgent)null!));
+ }
+
+ [Fact]
+ public void MapAGUI_WithNullAgentName_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((string)null!, "/api/agent"));
+ }
+
+ [Fact]
+ public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException()
+ {
+ // Arrange
+ Mock endpointsMock = new();
+ Mock serviceProviderMock = new();
+ endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
+
+ // Act & Assert
+ Assert.Throws(() =>
+ endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent"));
+ }
+
[Fact]
public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync()
{
@@ -556,4 +707,44 @@ protected override async IAsyncEnumerable RunCoreStreamingA
yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
}
}
+
+ private sealed class NamedTestAgent : AIAgent
+ {
+ protected override string? IdCore => "test-agent";
+
+ public override string? Name => "test-agent";
+
+ public override string? Description => "Named test agent";
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
+ new(new TestAgentSession());
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
+ new(serializedState.Deserialize(jsonSerializerOptions)!);
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ {
+ if (session is not TestAgentSession testSession)
+ {
+ throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent.");
+ }
+
+ return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions));
+ }
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await Task.CompletedTask;
+ yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
+ }
+ }
}
From 641bb78e3ccb4093a6f1183bf484f7c0cf52cf69 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Thu, 9 Apr 2026 19:37:08 +0100
Subject: [PATCH 2/3] Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.../05-end-to-end/AGUIClientServer/AGUIServer/Program.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
index cef584ae46..a12ca1c5ad 100644
--- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs
@@ -51,7 +51,7 @@
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
-var pirateAgentBuilder = builder
+builder
.AddAIAgent(AgentName, (_, _) => agent)
.WithInMemorySessionStore();
From e69526515ceb35f2837afc0e7ec39f71669e5627 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Mon, 13 Apr 2026 09:16:52 +0000
Subject: [PATCH 3/3] Address PR comments
---
.../AGUIEndpointRouteBuilderExtensions.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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 745402c477..948ecdca42 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs
@@ -36,6 +36,7 @@ public static IEndpointConventionBuilder MapAGUI(
IHostedAgentBuilder agentBuilder,
[StringSyntax("route")] string pattern)
{
+ ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapAGUI(agentBuilder.Name, pattern);
}
@@ -114,7 +115,7 @@ public static IEndpointConventionBuilder MapAGUI(
}
};
- var threadId = input.ThreadId ?? Guid.NewGuid().ToString("N");
+ 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