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
+}