mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Add unit tests for DevUIAggregatorHostedService; refactor project files for TargetFrameworks support
This commit is contained in:
+4
@@ -10,6 +10,10 @@
|
||||
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
+33
-19
@@ -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<string, (string ResourceName, string ContentType)>? _frontendResources;
|
||||
|
||||
// Lazily resolved and cached backend map: prefix → base URL
|
||||
private Dictionary<string, string>? _cachedBackends;
|
||||
|
||||
public DevUIAggregatorHostedService(
|
||||
DevUIResource resource,
|
||||
ILogger logger)
|
||||
@@ -279,15 +276,10 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Resolves backend URLs from the resource's <see cref="AgentServiceAnnotation"/> 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.
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ResolveBackends()
|
||||
{
|
||||
if (this._cachedBackends is not null)
|
||||
{
|
||||
return this._cachedBackends;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the agent_id query parameter to the un-prefixed value for backend routing.
|
||||
/// </summary>
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b2c3d4e5-f6a7-8901-bcde-f12345678901</UserSecretsId>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
|
||||
|
||||
+5
-1
@@ -1,9 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
+299
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DevUIAggregatorHostedService"/> class.
|
||||
/// </summary>
|
||||
public class DevUIAggregatorHostedServiceTests
|
||||
{
|
||||
#region RewriteAgentIdInQueryString Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var queryString = QueryString.Empty;
|
||||
|
||||
// Act
|
||||
var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString preserves other query parameters.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AgentServiceAnnotation>()
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(annotations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService adds proper annotations for backend resolution.
|
||||
/// </summary>
|
||||
[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<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Equal("writer-agent", annotation.AgentService.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom EntityIdPrefix is properly stored in the annotation.
|
||||
/// </summary>
|
||||
[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<AgentServiceAnnotation>()
|
||||
.First();
|
||||
|
||||
Assert.Equal("custom-writer", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agent services create multiple annotations for backend resolution.
|
||||
/// </summary>
|
||||
[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<AgentServiceAnnotation>()
|
||||
.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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the expected format for prefixed entity IDs in the aggregator.
|
||||
/// </summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock agent service builder for testing.
|
||||
/// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
|
||||
/// </summary>
|
||||
private static IResourceBuilder<IResourceWithEndpoints> CreateMockAgentServiceBuilder(
|
||||
IDistributedApplicationBuilder appBuilder,
|
||||
string name)
|
||||
{
|
||||
// Create a mock resource that implements IResourceWithEndpoints
|
||||
var mockResource = new Moq.Mock<IResourceWithEndpoints>();
|
||||
mockResource.Setup(r => r.Name).Returns(name);
|
||||
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
|
||||
|
||||
var mockBuilder = new Moq.Mock<IResourceBuilder<IResourceWithEndpoints>>();
|
||||
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
|
||||
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
|
||||
|
||||
return mockBuilder.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user