diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj index 248ed3b684..0f45c95147 100644 --- a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj @@ -10,6 +10,10 @@ $(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050 + + + + diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs index 1e59e9fdba..3e8d58b7c7 100644 --- a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs @@ -37,9 +37,6 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) private readonly Dictionary? _frontendResources; - // Lazily resolved and cached backend map: prefix → base URL - private Dictionary? _cachedBackends; - public DevUIAggregatorHostedService( DevUIResource resource, ILogger logger) @@ -279,15 +276,10 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable /// /// Resolves backend URLs from the resource's annotations. - /// Results are cached after first successful resolution of at least one backend. + /// This method does not cache results to ensure late-allocated backends are always discovered. /// private Dictionary ResolveBackends() { - if (this._cachedBackends is not null) - { - return this._cachedBackends; - } - var result = new Dictionary(StringComparer.Ordinal); foreach (var annotation in this._resource.Annotations.OfType()) @@ -313,12 +305,6 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable } } - // Only cache if we resolved at least one backend - if (result.Count > 0) - { - this._cachedBackends = result; - } - return result; } @@ -470,13 +456,19 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable { // Try to determine the backend from agent_id query param or request body string? backendUrl = null; + string? actualAgentId = null; var agentId = context.Request.Query["agent_id"].FirstOrDefault(); if (agentId is not null) { - (backendUrl, _) = this.ResolveBackend(agentId); + (backendUrl, actualAgentId) = this.ResolveBackend(agentId); } + // Build query string with rewritten agent_id if we resolved from query param + var queryString = (agentId is not null && actualAgentId is not null) + ? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId) + : context.Request.QueryString.ToString(); + if (backendUrl is null && context.Request.ContentLength > 0) { var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); @@ -504,8 +496,14 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable var rewritten = JsonSerializer.SerializeToUtf8Bytes(json); var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + + // Also rewrite query string agent_id if present + var bodyQueryString = (agentId is not null) + ? RewriteAgentIdInQueryString(context.Request.QueryString, actualId) + : context.Request.QueryString.ToString(); + await ProxyRequestAsync( - context, backendUrl, targetPath + context.Request.QueryString, rewritten).ConfigureAwait(false); + context, backendUrl, targetPath + bodyQueryString, rewritten).ConfigureAwait(false); return; } } @@ -520,7 +518,7 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; await ProxyRequestAsync( - context, backendUrl, targetPathFallback + context.Request.QueryString, bodyBytes).ConfigureAwait(false); + context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false); return; } @@ -534,7 +532,23 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; await ProxyRequestAsync( - context, backendUrl, convPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false); + context, backendUrl, convPath + queryString, bodyBytes: null).ConfigureAwait(false); + } + + /// + /// Rewrites the agent_id query parameter to the un-prefixed value for backend routing. + /// + internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId) + { + if (!queryString.HasValue) + { + return string.Empty; + } + + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value); + query["agent_id"] = actualAgentId; + + return QueryString.Create(query).ToString(); } private static async Task ProxyRequestAsync( diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md index 22e746022e..8dbace2514 100644 --- a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md +++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md @@ -1,4 +1,4 @@ -# Aspire.Hosting.AgentFramework library +# Aspire.Hosting.AgentFramework.DevUI library Provides extension methods and resource definitions for an Aspire AppHost to configure a DevUI resource for testing and debugging AI agents built with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework). @@ -10,10 +10,10 @@ Agent services must expose the OpenAI Responses and Conversations API endpoints. ### Install the package -In your AppHost project, install the Aspire Agent Framework Hosting library with [NuGet](https://www.nuget.org): +In your AppHost project, install the Aspire Agent Framework DevUI Hosting library with [NuGet](https://www.nuget.org): ```dotnetcli -dotnet add package Aspire.Hosting.AgentFramework +dotnet add package Aspire.Hosting.AgentFramework.DevUI ``` ## Usage example diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj index 0c5573beac..8128a10ab9 100644 --- a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj +++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj @@ -1,7 +1,7 @@ - net10.0 + net10.0 enable enable true diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj index ace52399c5..f25ef01e2a 100644 --- a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj +++ b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj @@ -1,7 +1,7 @@ - net10.0 + net10.0 enable enable b2c3d4e5-f6a7-8901-bcde-f12345678901 diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj index e596b972fe..9fb2da6d53 100644 --- a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj +++ b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj @@ -1,7 +1,7 @@ - net10.0 + net10.0 enable enable a1b2c3d4-e5f6-7890-abcd-ef1234567890 diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj index 9c1036160c..b2ea33c1ab 100644 --- a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj @@ -1,9 +1,13 @@ - net10.0 + $(TargetFrameworksCore) + + + + diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs new file mode 100644 index 0000000000..361021d6a4 --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Http; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DevUIAggregatorHostedServiceTests +{ + #region RewriteAgentIdInQueryString Tests + + /// + /// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value. + /// + [Fact] + public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString() + { + // Arrange + var queryString = QueryString.Empty; + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Equal(string.Empty, result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value. + /// + [Fact] + public void RewriteAgentIdInQueryString_WithPrefixedAgentId_RewritesToUnprefixed() + { + // Arrange + var queryString = new QueryString("?agent_id=writer-agent%2Fwriter"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.DoesNotContain("writer-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString preserves other query parameters. + /// + [Fact] + public void RewriteAgentIdInQueryString_WithOtherParams_PreservesOtherParams() + { + // Arrange + var queryString = new QueryString("?agent_id=writer-agent%2Fwriter&conversation_id=123&page=5"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.Contains("conversation_id=123", result); + Assert.Contains("page=5", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter. + /// + [Fact] + public void RewriteAgentIdInQueryString_AgentIdNotFirst_StillRewrites() + { + // Arrange + var queryString = new QueryString("?page=1&agent_id=editor-agent%2Feditor&limit=10"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "editor"); + + // Assert + Assert.Contains("agent_id=editor", result); + Assert.DoesNotContain("editor-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID. + /// + [Fact] + public void RewriteAgentIdInQueryString_SpecialCharsInAgentId_UrlEncodesCorrectly() + { + // Arrange + var queryString = new QueryString("?agent_id=prefix%2Fmy-agent"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "my-agent"); + + // Assert + // The result should contain the agent_id with the value properly encoded if needed + Assert.Contains("agent_id=my-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix. + /// + [Fact] + public void RewriteAgentIdInQueryString_NoPrefix_SetsDirectly() + { + // Arrange + var queryString = new QueryString("?agent_id=simple"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "new-value"); + + // Assert + Assert.Contains("agent_id=new-value", result); + Assert.DoesNotContain("simple", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present. + /// + [Fact] + public void RewriteAgentIdInQueryString_NoAgentId_AddsAgentId() + { + // Arrange + var queryString = new QueryString("?page=1&limit=10"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.Contains("page=1", result); + Assert.Contains("limit=10", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?. + /// + [Fact] + public void RewriteAgentIdInQueryString_ValidQuery_ReturnsQueryStringFormat() + { + // Arrange + var queryString = new QueryString("?agent_id=test"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.StartsWith("?", result); + } + + #endregion + + #region Backend Resolution Behavior Tests + + /// + /// Verifies that ResolveBackends returns empty dictionary when no annotations are present. + /// These tests verify the expected behavior of the aggregator via the DevUI resource annotations. + /// + [Fact] + public void DevUIResource_NoAnnotations_ResolveBackendsReturnsEmpty() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + + // Assert - no AgentServiceAnnotation means no backends + var annotations = devui.Resource.Annotations + .OfType() + .ToList(); + + Assert.Empty(annotations); + } + + /// + /// Verifies that WithAgentService adds proper annotations for backend resolution. + /// + [Fact] + public void WithAgentService_AddsAnnotation_ForBackendResolution() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + + // Act + devui.WithAgentService(agentService); + + // Assert + var annotation = devui.Resource.Annotations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(annotation); + Assert.Equal("writer-agent", annotation.AgentService.Name); + } + + /// + /// Verifies that custom EntityIdPrefix is properly stored in the annotation. + /// + [Fact] + public void WithAgentService_CustomPrefix_StoresInAnnotation() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + + // Act + devui.WithAgentService(agentService, entityIdPrefix: "custom-writer"); + + // Assert + var annotation = devui.Resource.Annotations + .OfType() + .First(); + + Assert.Equal("custom-writer", annotation.EntityIdPrefix); + } + + /// + /// Verifies that multiple agent services create multiple annotations for backend resolution. + /// + [Fact] + public void WithAgentService_MultipleServices_CreatesMultipleAnnotations() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var writerService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + var editorService = CreateMockAgentServiceBuilder(builder, "editor-agent"); + + // Act + devui.WithAgentService(writerService); + devui.WithAgentService(editorService); + + // Assert + var annotations = devui.Resource.Annotations + .OfType() + .ToList(); + + Assert.Equal(2, annotations.Count); + Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent"); + Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent"); + } + + #endregion + + #region Entity ID Parsing Tests + + /// + /// Verifies the expected format for prefixed entity IDs in the aggregator. + /// + [Theory] + [InlineData("writer-agent/writer", "writer-agent", "writer")] + [InlineData("editor-agent/editor", "editor-agent", "editor")] + [InlineData("custom/my-agent", "custom", "my-agent")] + [InlineData("prefix/sub/path", "prefix", "sub/path")] + public void PrefixedEntityId_Format_ExtractsCorrectly(string prefixedId, string expectedPrefix, string expectedRest) + { + // This test documents the expected format for prefixed entity IDs + // The aggregator uses "prefix/entityId" format where: + // - prefix is typically the resource name or custom prefix + // - entityId is the original entity identifier from the backend + + var slashIndex = prefixedId.IndexOf('/'); + var prefix = prefixedId[..slashIndex]; + var rest = prefixedId[(slashIndex + 1)..]; + + Assert.Equal(expectedPrefix, prefix); + Assert.Equal(expectedRest, rest); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a mock agent service builder for testing. + /// Uses a minimal resource implementation that satisfies IResourceWithEndpoints. + /// + private static IResourceBuilder CreateMockAgentServiceBuilder( + IDistributedApplicationBuilder appBuilder, + string name) + { + // Create a mock resource that implements IResourceWithEndpoints + var mockResource = new Moq.Mock(); + mockResource.Setup(r => r.Name).Returns(name); + mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection()); + + var mockBuilder = new Moq.Mock>(); + mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object); + mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder); + + return mockBuilder.Object; + } + + #endregion +}