mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Features/3768-devui-aspire-integration (#3771)
* adds devui integration and samples
* adds unit tests for devui integration
* fix: correct formatting of copyright notice in unit test files
* fixes formatting issues
* fixes build for net8 target
* fixes formatting errors on test apphost
* adds copyright notice to multiple files and removes unnecessary using directives
* Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor project files to use TargetFrameworks instead of TargetFramework for multi-targeting support; add optional port property to DevUIResource class.
* Add unit tests for DevUIAggregatorHostedService; refactor project files for TargetFrameworks support
* Refactor project files to use TargetFrameworks for multi-targeting support in DevUIIntegration samples
* Remove unnecessary using directive for Aspire.Hosting in DevUIAggregatorHostedServiceTests
* merge
* fixes Conversation routing for non-first backends
* add documentation for devui integration sample
* update project references in solution file for improved integration
* fixes package versions post merge
* move Aspire.Hosting.AgentFramework.DevUI to dotnet/src
Move the project from aspire-integration/ to src/ to be consistent
with the location of all other projects in the repo.
* move DevUI sample to samples/05-end-to-end/DevUIAspireIntegration
Move the sample from samples/DevUIIntegration/ to
samples/05-end-to-end/DevUIAspireIntegration/ to match the location
of other end-to-end samples.
* remove unnecessary net472 framework condition from sample csproj files
These projects only target net10.0, so the
Condition="'$(TargetFramework)' != 'net472'" on ItemGroup is unnecessary.
* update sample model name from gpt-4.1 to gpt-5.4
Use a more up-to-date model name in the DevUI integration samples.
* Revert "remove unnecessary net472 framework condition from sample csproj files"
This reverts commit 08cf41253b.
* fix: use TargetFrameworks to override multi-targeting from Directory.Build.props
The parent Directory.Build.props sets TargetFrameworks to net10.0;net472,
which overrides the singular TargetFramework in each csproj. Use the plural
TargetFrameworks property set to net10.0 only to properly override it, and
remove the now-unnecessary net472 condition on ItemGroup.
* fixes aspire config
* fix: update Microsoft.Extensions packages to version 10.0.1
* Address Copilot review feedback on DevUI Aspire integration
- Fix request body dropping in ProxyConversationsAsync: always read the
body when ContentLength > 0 before routing, then pass it through to
all proxy calls (previously null was passed when backend was resolved
from query param or conversation map)
- Fix resource leak: dispose aggregator on startup failure in catch block
- Fix XML docs: accurately describe embedded resource serving behavior
- Remove reflection from DevUIResourceTests (InternalsVisibleTo already set)
- Make sensitive telemetry conditional on Development environment in samples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: update chat client version to gpt41 in both EditorAgent and WriterAgent
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
69894eded8
commit
60af59ba8b
@@ -235,3 +235,4 @@ python/dotnet-ref
|
||||
|
||||
# Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1)
|
||||
dotnet/filtered-*.slnx
|
||||
**/*.lscache
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<!-- Aspire -->
|
||||
<AspireAppHostSdkVersion>13.0.2</AspireAppHostSdkVersion>
|
||||
<AspireAppHostSdkVersion>13.1.0</AspireAppHostSdkVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.13.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.5.0" />
|
||||
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.Inference" Version="13.1.0-preview.1.25616.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.AIFoundry" Version="13.1.0-preview.1.25616.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
@@ -48,12 +51,12 @@
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||
@@ -71,18 +74,18 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<BuildType Name="Publish" />
|
||||
<BuildType Name="Release" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/">
|
||||
<File Path="samples/AGENTS.md" />
|
||||
<File Path="samples/README.md" />
|
||||
@@ -37,6 +40,12 @@
|
||||
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIChatCompletion/Agent_With_OpenAIChatCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Agent_With_OpenAIResponses.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/DevUIAspireIntegration/">
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj" />
|
||||
<Project Path="samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Agents/">
|
||||
<File Path="samples/02-agents/Agents/README.md" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Agent_Step01_UsingFunctionToolsWithApprovals.csproj" />
|
||||
@@ -542,6 +551,7 @@
|
||||
<Project Path="tests/OpenAIResponse.IntegrationTests/OpenAIResponse.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
|
||||
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
|
||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj"
|
||||
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
|
||||
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
**/**/*.Development.json
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="$(AspireAppHostSdkVersion)" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Azure.AIFoundry" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DevUI\Microsoft.Agents.AI.DevUI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\..\..\..\src\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" IsAspireProjectResource="false" />
|
||||
<ProjectReference Include="..\WriterAgent\WriterAgent.csproj" />
|
||||
<ProjectReference Include="..\EditorAgent\EditorAgent.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var foundry = builder.AddAzureAIFoundry("foundry");
|
||||
|
||||
// Comment the following lines to create a new Foundry instance instead of connecting to an existing one. If creating a new instance, the DevUI resource will wait for the Foundry to be ready before starting, ensuring the DevUI frontend is available as soon as the app starts.
|
||||
var existingFoundryName = builder.AddParameter("existingFoundryName")
|
||||
.WithDescription("The name of the existing Azure Foundry resource.");
|
||||
var existingFoundryResourceGroup = builder.AddParameter("existingFoundryResourceGroup")
|
||||
.WithDescription("The resource group of the existing Azure Foundry resource.");
|
||||
foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup);
|
||||
|
||||
// Add the writer agent service
|
||||
var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(foundry).WaitFor(foundry);
|
||||
|
||||
// Add the editor agent service
|
||||
var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(foundry).WaitFor(foundry);
|
||||
|
||||
// Add DevUI integration that aggregates agents from all agent services.
|
||||
// Agent metadata is declared here so backends don't need a /v1/entities endpoint.
|
||||
_ = builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer")]) // the name of the agent should match the agent declaration in WriterAgent/Program.cs
|
||||
.WithAgentService(editorAgent, agents: [new("editor")]) // the name of the agent should match the agent declaration in EditorAgent/Program.cs
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
|
||||
builder.Build().Run();
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:16500;http://localhost:16501",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17250",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17250",
|
||||
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:16501",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17251",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17251",
|
||||
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Azure": {
|
||||
"TenantId": "",
|
||||
"SubscriptionId": "",
|
||||
"AllowResourceGroupCreation": true,
|
||||
"ResourceGroup": "",
|
||||
"Location": "",
|
||||
"CredentialSource": "AzureCli"
|
||||
},
|
||||
"Parameters": {
|
||||
"existingFoundryName": "",
|
||||
"existingFoundryResourceGroup": ""
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||
#pragma warning disable CA1724 // Type name 'Extensions' conflicts with namespace - acceptable for Aspire pattern
|
||||
public static class Extensions
|
||||
#pragma warning restore CA1724
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
// Exclude health check requests from tracing
|
||||
tracing.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||
)
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>b2c3d4e5-f6a7-8901-bcde-f12345678901</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.Inference" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.AddAzureChatCompletionsClient(connectionName: "foundry",
|
||||
configureSettings: settings =>
|
||||
{
|
||||
settings.TokenCredential = new DefaultAzureCredential();
|
||||
settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment();
|
||||
})
|
||||
.AddChatClient("gpt41");
|
||||
|
||||
builder.AddAIAgent("editor", (sp, key) =>
|
||||
{
|
||||
var chatClient = sp.GetRequiredService<IChatClient>();
|
||||
return new ChatClientAgent(
|
||||
chatClient,
|
||||
name: key,
|
||||
instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.",
|
||||
tools: [AIFunctionFactory.Create(FormatStory)]
|
||||
);
|
||||
});
|
||||
|
||||
// Register services for OpenAI responses and conversations
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map OpenAI API endpoints — DevUI aggregator routes requests here
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
[Description("Formats the story for publication, revealing its title.")]
|
||||
static string FormatStory(string title, string story) => $"""
|
||||
**Title**: {title}
|
||||
|
||||
{story}
|
||||
""";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5281",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# DevUI Integration Sample
|
||||
|
||||
This sample demonstrates how to use the **Aspire.Hosting.AgentFramework.DevUI** library to test and debug multiple AI agents through a unified DevUI web interface, orchestrated by an Aspire AppHost.
|
||||
|
||||
The solution contains two agent services:
|
||||
|
||||
- **WriterAgent** — a simple agent that writes short stories (≤ 300 words) about a given topic.
|
||||
- **EditorAgent** — an agent that edits stories for grammar and style, selects a title, and formats the result for publishing. It also demonstrates tool use via `AIFunctionFactory`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)
|
||||
- [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling)
|
||||
- An Azure subscription with access to [Azure AI Foundry](https://learn.microsoft.com/azure/ai-studio/)
|
||||
- Azure CLI authenticated (`az login`)
|
||||
|
||||
## Azure AI Foundry configuration
|
||||
|
||||
The sample requires an Azure AI Foundry resource with a deployed `gpt-4.1` model. You have two options:
|
||||
|
||||
### Option 1: Connect to an existing Foundry resource
|
||||
|
||||
Fill in the parameters in `DevUIIntegration.AppHost/appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Azure": {
|
||||
"TenantId": "<your-tenant-id>",
|
||||
"SubscriptionId": "<your-subscription-id>",
|
||||
"AllowResourceGroupCreation": true,
|
||||
"ResourceGroup": "<your-resource-group>",
|
||||
"Location": "<your-azure-region>",
|
||||
"CredentialSource": "AzureCli"
|
||||
},
|
||||
"Parameters": {
|
||||
"existingFoundryName": "<your-foundry-resource-name>",
|
||||
"existingFoundryResourceGroup": "<resource-group-containing-your-foundry>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The AppHost calls `foundry.AsExisting(...)` with these parameters, so Aspire connects to the existing resource instead of provisioning a new one.
|
||||
|
||||
### Option 2: Let Aspire provision a new Foundry resource
|
||||
|
||||
Remove or comment out the `AsExisting` block in `DevUIIntegration.AppHost/Program.cs`:
|
||||
|
||||
```csharp
|
||||
// Comment the following lines to create a new Foundry instance
|
||||
// _ = builder.AddParameterFromConfiguration("tenant", "Azure:TenantId");
|
||||
// var existingFoundryName = builder.AddParameter("existingFoundryName") ...
|
||||
// foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup);
|
||||
```
|
||||
|
||||
Aspire will provision a new Azure AI Foundry resource on startup. The DevUI resource uses `.WaitFor(foundry)` transitively through the agent services, so the frontend won't become available until provisioning completes. This can take several minutes on first run.
|
||||
|
||||
You still need to fill in the `Azure` section of `appsettings.json` (subscription, location, etc.) so Aspire knows where to create the resource.
|
||||
|
||||
## Agent name matching with `WithAgentService`
|
||||
|
||||
When connecting agent services to DevUI in the AppHost, you must pass the correct agent name via the `agents:` parameter. **This name must match the name used in `AddAIAgent(...)` inside each agent service's `Program.cs` — not the Aspire resource name.**
|
||||
|
||||
For example, the WriterAgent Aspire resource is named `"writer-agent"`, but the agent is registered as `"writer"`:
|
||||
|
||||
```csharp
|
||||
// WriterAgent/Program.cs
|
||||
builder.AddAIAgent("writer", "You write short stories ...");
|
||||
// ^^^^^^^^ this is the agent name
|
||||
```
|
||||
|
||||
```csharp
|
||||
// EditorAgent/Program.cs
|
||||
builder.AddAIAgent("editor", (sp, key) => { ... });
|
||||
// ^^^^^^^^ this is the agent name
|
||||
```
|
||||
|
||||
The AppHost must use these exact names:
|
||||
|
||||
```csharp
|
||||
// DevUIIntegration.AppHost/Program.cs
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer")]) // ✅ matches AddAIAgent("writer", ...)
|
||||
.WithAgentService(editorAgent, agents: [new("editor")]) // ✅ matches AddAIAgent("editor", ...)
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
```
|
||||
|
||||
Using the wrong name (e.g., `new("writer-agent")` instead of `new("writer")`) will cause the aggregator to send an entity ID the backend doesn't recognize, resulting in 404 errors when interacting with the agent.
|
||||
|
||||
If you omit the `agents:` parameter entirely, the aggregator defaults to a single agent named after the Aspire resource (e.g., `"writer-agent"`). Since agent services don't expose a `/v1/entities` discovery endpoint, **the Aspire resource name must exactly match the agent name registered via `AddAIAgent(...)` in the service's `Program.cs`**.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/05-end-to-end/DevUIAspireIntegration
|
||||
aspire run
|
||||
```
|
||||
|
||||
Once all services are running, open the **DevUI** URL shown in the Aspire dashboard. You should see both the writer and editor agents listed — select one and start a conversation.
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.AddAzureChatCompletionsClient(connectionName: "foundry",
|
||||
configureSettings: settings =>
|
||||
{
|
||||
settings.TokenCredential = new DefaultAzureCredential();
|
||||
settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment();
|
||||
})
|
||||
.AddChatClient("gpt41");
|
||||
|
||||
builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic.");
|
||||
|
||||
// Register services for OpenAI responses and conversations
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map OpenAI API endpoints — DevUI aggregator routes requests here
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5280",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.AI.Inference" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\DevUIIntegration.ServiceDefaults\DevUIIntegration.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When added via <see cref="AgentFrameworkBuilderExtensions.WithAgentService{TSource}"/>,
|
||||
/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
|
||||
/// entity listing without querying each backend's <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
|
||||
/// (<c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>), not a custom discovery endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Id">The unique identifier for the agent, typically matching the name passed to <c>AddAIAgent</c>.</param>
|
||||
/// <param name="Description">A short description of the agent's capabilities.</param>
|
||||
public record AgentEntityInfo(string Id, string? Description = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display name for the agent. Defaults to <see cref="Id"/> if not specified.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity type. Defaults to <c>"agent"</c>.
|
||||
/// </summary>
|
||||
public string Type { get; init; } = "agent";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the framework identifier. Defaults to <c>"agent_framework"</c>.
|
||||
/// </summary>
|
||||
public string Framework { get; init; } = "agent_framework";
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Aspire.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
|
||||
/// </summary>
|
||||
public static class AgentFrameworkBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a DevUI resource for testing AI agents in a distributed application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
|
||||
/// When configured with <see cref="WithAgentService{TSource}"/>, it aggregates agents from multiple backend services
|
||||
/// and provides a unified testing interface.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
|
||||
/// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and
|
||||
/// falls back to proxying from the first configured backend. It aggregates entity listings from all backends.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This resource is excluded from the deployment manifest as it is intended for development use only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
|
||||
/// <param name="name">The name to give the resource.</param>
|
||||
/// <param name="port">The host port for the DevUI web interface. If not specified, a random port will be assigned.</param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var devui = builder.AddDevUI("devui")
|
||||
/// .WithAgentService(dotnetAgent)
|
||||
/// .WithAgentService(pythonAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> AddDevUI(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? port = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
var resource = new DevUIResource(name, port);
|
||||
|
||||
var resourceBuilder = builder.AddResource(resource)
|
||||
.ExcludeFromManifest(); // DevUI is a dev-only tool
|
||||
|
||||
// Initialize the in-process aggregator when the resource is initialized by the orchestrator
|
||||
builder.Eventing.Subscribe<InitializeResourceEvent>(resource, async (e, ct) =>
|
||||
{
|
||||
var logger = e.Logger;
|
||||
var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService<ILoggerFactory>().CreateLogger<DevUIAggregatorHostedService>());
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for dependencies (e.g. agent service backends) before starting.
|
||||
// Custom resources must manually publish BeforeResourceStartedEvent to trigger
|
||||
// the orchestrator's WaitFor mechanism.
|
||||
await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Starting
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
await aggregator.StartAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Allocate the endpoint so the URL appears in the Aspire dashboard
|
||||
var endpointAnnotation = resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.First(ea => ea.Name == DevUIResource.PrimaryEndpointName);
|
||||
|
||||
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
|
||||
endpointAnnotation, "localhost", aggregator.AllocatedPort);
|
||||
|
||||
var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/";
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Running,
|
||||
Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)]
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
// Shut down the aggregator when the app stops
|
||||
var lifetime = e.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.Finished
|
||||
}).GetAwaiter().GetResult();
|
||||
|
||||
aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to start DevUI aggregator");
|
||||
|
||||
await aggregator.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
|
||||
{
|
||||
State = KnownResourceStates.FailedToStart
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
return resourceBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures DevUI to connect to an agent service backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each agent service should expose the OpenAI Responses and Conversations API endpoints
|
||||
/// (via <c>MapOpenAIResponses</c> and <c>MapOpenAIConversations</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <paramref name="agents"/> is provided, the aggregator builds the entity listing from
|
||||
/// these declarations without querying the backend. When not provided, a single agent named
|
||||
/// after the service resource is assumed. Agent services don't need a <c>/v1/entities</c> endpoint.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <typeparam name="TSource">The type of the agent service resource.</typeparam>
|
||||
/// <param name="builder">The DevUI resource builder.</param>
|
||||
/// <param name="agentService">The agent service resource to connect to.</param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator uses these
|
||||
/// declarations directly. When not provided, defaults to a single agent named after the
|
||||
/// <paramref name="agentService"/> resource. The backend doesn't need to expose a
|
||||
/// <c>/v1/entities</c> endpoint in either case.
|
||||
/// </param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent");
|
||||
/// var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent");
|
||||
///
|
||||
/// builder.AddDevUI("devui")
|
||||
/// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
|
||||
/// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")])
|
||||
/// .WaitFor(writerAgent)
|
||||
/// .WaitFor(editorAgent);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IResourceBuilder<DevUIResource> WithAgentService<TSource>(
|
||||
this IResourceBuilder<DevUIResource> builder,
|
||||
IResourceBuilder<TSource> agentService,
|
||||
IReadOnlyList<AgentEntityInfo>? agents = null,
|
||||
string? entityIdPrefix = null)
|
||||
where TSource : IResourceWithEndpoints
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
// Default to a single agent named after the service resource
|
||||
agents ??= [new AgentEntityInfo(agentService.Resource.Name)];
|
||||
|
||||
builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents));
|
||||
builder.WithRelationship(agentService.Resource, "agent-backend");
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Aspire.Hosting.AgentFramework;
|
||||
|
||||
namespace Aspire.Hosting.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// An annotation that tracks an agent service backend referenced by a DevUI resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This annotation is used to configure DevUI to aggregate entities from multiple
|
||||
/// agent service backends. Each annotation represents one backend that DevUI should
|
||||
/// connect to for entity discovery and request routing.
|
||||
/// </remarks>
|
||||
public class AgentServiceAnnotation : IResourceAnnotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentServiceAnnotation"/> class.
|
||||
/// </summary>
|
||||
/// <param name="agentService">The agent service resource.</param>
|
||||
/// <param name="entityIdPrefix">
|
||||
/// An optional prefix to add to entity IDs from this backend to avoid conflicts.
|
||||
/// If not specified, the resource name will be used as the prefix.
|
||||
/// </param>
|
||||
/// <param name="agents">
|
||||
/// Optional list of agents declared by this backend. When provided, the aggregator builds the entity
|
||||
/// listing directly from these declarations instead of querying the backend's <c>/v1/entities</c> endpoint.
|
||||
/// </param>
|
||||
public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList<AgentEntityInfo>? agents = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentService);
|
||||
|
||||
this.AgentService = agentService;
|
||||
this.EntityIdPrefix = entityIdPrefix;
|
||||
this.Agents = agents ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the agent service resource that exposes AI agents.
|
||||
/// </summary>
|
||||
public IResource AgentService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prefix to use for entity IDs from this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>null</c>, the resource name will be used as the prefix.
|
||||
/// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
|
||||
/// across multiple agent backends.
|
||||
/// </remarks>
|
||||
public string? EntityIdPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of agents declared by this backend.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When non-empty, the DevUI aggregator uses these declarations to build the entity listing
|
||||
/// without querying the backend. When empty, the aggregator falls back to calling
|
||||
/// <c>GET /v1/entities</c> on the backend for discovery.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<AgentEntityInfo> Agents { get; }
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
|
||||
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
|
||||
<!-- Suppress analyzer warnings for Aspire integration code -->
|
||||
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,779 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends.
|
||||
/// Serves the DevUI frontend directly from the <c>Microsoft.Agents.AI.DevUI</c> assembly's embedded
|
||||
/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing.
|
||||
/// </summary>
|
||||
internal sealed class DevUIAggregatorHostedService : IAsyncDisposable
|
||||
{
|
||||
private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new();
|
||||
|
||||
private WebApplication? _app;
|
||||
private readonly DevUIResource _resource;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
// Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable)
|
||||
private readonly Dictionary<string, (string ResourceName, string ContentType)>? _frontendResources;
|
||||
|
||||
// Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context.
|
||||
// Populated when the aggregator routes conversation requests to a positively-resolved backend.
|
||||
private readonly ConcurrentDictionary<string, string> _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DevUIAggregatorHostedService(
|
||||
DevUIResource resource,
|
||||
ILogger logger)
|
||||
{
|
||||
this._resource = resource;
|
||||
this._logger = logger;
|
||||
this._frontendResources = LoadFrontendResources(logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the port the aggregator is listening on, available after <see cref="StartAsync"/>.
|
||||
/// </summary>
|
||||
internal int AllocatedPort { get; private set; }
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
|
||||
builder.Services.AddHttpClient("devui-proxy")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
this._app = builder.Build();
|
||||
|
||||
// Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation.
|
||||
var port = this._resource.Port ?? 0;
|
||||
this._app.Urls.Add($"http://127.0.0.1:{port}");
|
||||
this.MapRoutes(this._app);
|
||||
|
||||
await this._app.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serverAddresses = this._app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>();
|
||||
|
||||
if (serverAddresses is not null)
|
||||
{
|
||||
var address = serverAddresses.Addresses.First();
|
||||
var uri = new Uri(address);
|
||||
this.AllocatedPort = uri.Port;
|
||||
this._logger.LogInformation("DevUI aggregator started on port {Port}", this.AllocatedPort);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.StopAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (this._app is not null)
|
||||
{
|
||||
await this._app.DisposeAsync().ConfigureAwait(false);
|
||||
this._app = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the DevUI frontend resources from the <c>Microsoft.Agents.AI.DevUI</c> assembly.
|
||||
/// The assembly embeds the Vite SPA build output as manifest resources.
|
||||
/// Returns null if the assembly is not available.
|
||||
/// </summary>
|
||||
private static Dictionary<string, (string ResourceName, string ContentType)>? LoadFrontendResources(ILogger logger)
|
||||
{
|
||||
Assembly assembly;
|
||||
try
|
||||
{
|
||||
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var prefix = $"{assembly.GetName().Name}.resources.";
|
||||
var resources = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var name in assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (!name.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The DevUI middleware maps resource names by replacing dots with slashes.
|
||||
// Both the key and lookup use the same transform, so they match.
|
||||
var key = name[prefix.Length..].Replace('.', '/');
|
||||
s_contentTypeProvider.TryGetContentType(name, out var contentType);
|
||||
resources[key] = (name, contentType ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
if (resources.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count);
|
||||
return resources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the DevUI frontend. Uses embedded assembly resources if available,
|
||||
/// otherwise falls back to proxying from the first backend agent service.
|
||||
/// </summary>
|
||||
private async Task ServeDevUIFrontendAsync(HttpContext context, string? path)
|
||||
{
|
||||
// Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly
|
||||
if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/'))
|
||||
{
|
||||
var redirect = reqPath + "/";
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
redirect += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
|
||||
context.Response.Headers.Location = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try embedded resources first
|
||||
if (this._frontendResources is not null)
|
||||
{
|
||||
var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path;
|
||||
|
||||
if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html for paths without a file extension (client-side routing)
|
||||
if (!resourcePath.Contains('.', StringComparison.Ordinal) &&
|
||||
await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: proxy from the first backend that serves /devui
|
||||
var backends = this.ResolveBackends();
|
||||
var firstBackendUrl = backends.Values.FirstOrDefault();
|
||||
|
||||
if (firstBackendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
context.Response.ContentType = "text/plain";
|
||||
await context.Response.WriteAsync(
|
||||
"DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}";
|
||||
await ProxyRequestAsync(
|
||||
context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> TryServeResourceAsync(HttpContext context, string resourcePath)
|
||||
{
|
||||
if (this._frontendResources is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = resourcePath.Replace('.', '/');
|
||||
|
||||
if (!this._frontendResources.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Assembly assembly;
|
||||
try
|
||||
{
|
||||
assembly = Assembly.Load("Microsoft.Agents.AI.DevUI");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(entry.ResourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
context.Response.ContentType = entry.ContentType;
|
||||
context.Response.Headers.CacheControl = "no-cache, no-store";
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult GetMeta()
|
||||
{
|
||||
return Results.Json(new
|
||||
{
|
||||
ui_mode = "developer",
|
||||
version = "0.1.0",
|
||||
framework = "agent_framework",
|
||||
runtime = "dotnet",
|
||||
capabilities = new Dictionary<string, bool>
|
||||
{
|
||||
["tracing"] = false,
|
||||
["openai_proxy"] = false,
|
||||
["deployment"] = false
|
||||
},
|
||||
auth_required = false
|
||||
});
|
||||
}
|
||||
|
||||
private void MapRoutes(WebApplication app)
|
||||
{
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
// Intercept API calls for multi-backend aggregation and routing
|
||||
app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync);
|
||||
app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync);
|
||||
app.MapPost("/v1/responses", this.RouteResponsesAsync);
|
||||
app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync);
|
||||
app.MapGet("/meta", GetMeta);
|
||||
|
||||
// Serve the DevUI frontend from embedded assembly resources
|
||||
app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves backend URLs from the resource's <see cref="AgentServiceAnnotation"/> annotations.
|
||||
/// This method does not cache results to ensure late-allocated backends are always discovered.
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ResolveBackends()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
|
||||
{
|
||||
if (annotation.AgentService is not IResourceWithEndpoints rwe)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = rwe.GetEndpoint("http");
|
||||
if (endpoint.IsAllocated)
|
||||
{
|
||||
result[prefix] = endpoint.Url;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IResult> AggregateEntitiesAsync(HttpContext context)
|
||||
{
|
||||
var backends = this.ResolveBackends();
|
||||
var allEntities = new JsonArray();
|
||||
|
||||
foreach (var annotation in this._resource.Annotations.OfType<AgentServiceAnnotation>())
|
||||
{
|
||||
var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name;
|
||||
|
||||
if (annotation.Agents.Count > 0)
|
||||
{
|
||||
// Build entities from AppHost-declared metadata — no backend call needed
|
||||
foreach (var agent in annotation.Agents)
|
||||
{
|
||||
allEntities.Add(new JsonObject
|
||||
{
|
||||
["id"] = $"{prefix}/{agent.Id}",
|
||||
["type"] = agent.Type,
|
||||
["name"] = agent.Name,
|
||||
["description"] = agent.Description,
|
||||
["framework"] = agent.Framework,
|
||||
["_original_id"] = agent.Id,
|
||||
["_backend"] = prefix
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: query backend /v1/entities for discovery
|
||||
if (!backends.TryGetValue(prefix, out var baseUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
var response = await client.GetAsync(
|
||||
new Uri(new Uri(baseUrl), "/v1/entities"),
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}",
|
||||
prefix, baseUrl, response.StatusCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false);
|
||||
var doc = JsonNode.Parse(json);
|
||||
var entities = doc?["entities"]?.AsArray();
|
||||
|
||||
if (entities is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cloned = entity.DeepClone();
|
||||
var id = cloned["id"]?.GetValue<string>() ?? cloned["name"]?.GetValue<string>();
|
||||
|
||||
if (id is not null)
|
||||
{
|
||||
cloned["id"] = $"{prefix}/{id}";
|
||||
cloned["_original_id"] = id;
|
||||
cloned["_backend"] = prefix;
|
||||
}
|
||||
|
||||
allEntities.Add(cloned);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
this._logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Json(new { entities = allEntities });
|
||||
}
|
||||
|
||||
private async Task RouteEntityInfoAsync(HttpContext context, string entityPath)
|
||||
{
|
||||
var (backendUrl, actualPath) = this.ResolveBackend(entityPath);
|
||||
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}");
|
||||
|
||||
using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false);
|
||||
await CopyResponseAsync(response, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RouteResponsesAsync(HttpContext context)
|
||||
{
|
||||
var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
|
||||
var json = JsonNode.Parse(bodyBytes);
|
||||
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>();
|
||||
|
||||
if (entityId is null)
|
||||
{
|
||||
var firstBackend = this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (firstBackend is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (backendUrl, actualEntityId) = this.ResolveBackend(entityId);
|
||||
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
await context.Response.WriteAsJsonAsync(
|
||||
new { error = $"No backend found for entity '{entityId}'" },
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewrite entity_id to the un-prefixed original value
|
||||
json!["metadata"]!["entity_id"] = actualEntityId;
|
||||
var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json);
|
||||
|
||||
await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ProxyConversationsAsync(HttpContext context, string? path)
|
||||
{
|
||||
// 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, 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();
|
||||
|
||||
// Try conversation→backend map for previously-seen conversations
|
||||
if (backendUrl is null)
|
||||
{
|
||||
var conversationId = ExtractConversationId(path);
|
||||
if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl))
|
||||
{
|
||||
backendUrl = mappedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Always read the request body when present so it isn't dropped during proxying
|
||||
byte[]? bodyBytes = null;
|
||||
if (context.Request.ContentLength > 0)
|
||||
{
|
||||
bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Try to resolve backend from request body metadata when not yet determined
|
||||
if (backendUrl is null && bodyBytes is not null)
|
||||
{
|
||||
var json = JsonNode.Parse(bodyBytes);
|
||||
var entityId = json?["metadata"]?["entity_id"]?.GetValue<string>()
|
||||
?? json?["metadata"]?["agent_id"]?.GetValue<string>();
|
||||
|
||||
if (entityId is not null)
|
||||
{
|
||||
string actualId;
|
||||
(backendUrl, actualId) = this.ResolveBackend(entityId);
|
||||
|
||||
if (backendUrl is not null)
|
||||
{
|
||||
// Rewrite the entity/agent id to the un-prefixed value
|
||||
if (json?["metadata"]?["entity_id"] is not null)
|
||||
{
|
||||
json!["metadata"]!["entity_id"] = actualId;
|
||||
}
|
||||
|
||||
if (json?["metadata"]?["agent_id"] is not null)
|
||||
{
|
||||
json!["metadata"]!["agent_id"] = actualId;
|
||||
}
|
||||
|
||||
bodyBytes = 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 this.ProxyAndRecordConversationAsync(
|
||||
context, backendUrl, path, targetPath + bodyQueryString, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Couldn't determine backend from body; proxy raw bytes to first backend
|
||||
backendUrl = this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
|
||||
await ProxyRequestAsync(
|
||||
context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to resolved backend (from query or conversation map), or fall back to first backend
|
||||
var backendKnown = backendUrl is not null;
|
||||
backendUrl ??= this.ResolveBackends().Values.FirstOrDefault();
|
||||
if (backendUrl is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
return;
|
||||
}
|
||||
|
||||
var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
|
||||
if (backendKnown)
|
||||
{
|
||||
await this.ProxyAndRecordConversationAsync(
|
||||
context, backendUrl, path, convPath + queryString, bodyBytes).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ProxyRequestAsync(
|
||||
context, backendUrl, convPath + queryString, bodyBytes).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 string? ExtractConversationId(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slashIndex = path.IndexOf('/');
|
||||
return slashIndex > 0 ? path[..slashIndex] : path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the conversation→backend mapping and proxies the request.
|
||||
/// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID.
|
||||
/// </summary>
|
||||
private async Task ProxyAndRecordConversationAsync(
|
||||
HttpContext context,
|
||||
string backendUrl,
|
||||
string? conversationPath,
|
||||
string targetUrl,
|
||||
byte[]? bodyBytes)
|
||||
{
|
||||
var conversationId = ExtractConversationId(conversationPath);
|
||||
if (conversationId is not null)
|
||||
{
|
||||
// We already know the conversation ID — record and proxy normally
|
||||
this._conversationBackendMap[conversationId] = backendUrl;
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Creation POST: intercept response to capture the new conversation ID
|
||||
if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var originalBody = context.Response.Body;
|
||||
using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
|
||||
try
|
||||
{
|
||||
await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false);
|
||||
|
||||
if (context.Response.StatusCode is >= 200 and < 300)
|
||||
{
|
||||
buffer.Position = 0;
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(
|
||||
buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false);
|
||||
if (doc.RootElement.TryGetProperty("id", out var idProp) &&
|
||||
idProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var createdId = idProp.GetString();
|
||||
if (createdId is not null)
|
||||
{
|
||||
this._conversationBackendMap[createdId] = backendUrl;
|
||||
this._logger.LogDebug(
|
||||
"Recorded conversation '{ConversationId}' → backend '{BackendUrl}'",
|
||||
createdId, backendUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: response may not be parseable JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
buffer.Position = 0;
|
||||
await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ProxyRequestAsync(
|
||||
HttpContext context,
|
||||
string backendUrl,
|
||||
string path,
|
||||
byte[]? bodyBytes,
|
||||
bool streaming = false)
|
||||
{
|
||||
var httpClientFactory = context.RequestServices.GetRequiredService<IHttpClientFactory>();
|
||||
using var client = httpClientFactory.CreateClient("devui-proxy");
|
||||
|
||||
var targetUri = new Uri(new Uri(backendUrl), path);
|
||||
using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri);
|
||||
|
||||
foreach (var header in context.Request.Headers)
|
||||
{
|
||||
if (IsHopByHopHeader(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
|
||||
}
|
||||
|
||||
if (bodyBytes is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(bodyBytes);
|
||||
if (context.Request.ContentType is not null)
|
||||
{
|
||||
request.Content.Headers.ContentType =
|
||||
System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType);
|
||||
}
|
||||
}
|
||||
|
||||
var completionOption = streaming
|
||||
? HttpCompletionOption.ResponseHeadersRead
|
||||
: HttpCompletionOption.ResponseContentRead;
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
request, completionOption, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream")
|
||||
{
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false);
|
||||
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CopyResponseAsync(response, context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId)
|
||||
{
|
||||
var backends = this.ResolveBackends();
|
||||
var slashIndex = prefixedId.IndexOf('/');
|
||||
|
||||
if (slashIndex > 0)
|
||||
{
|
||||
var prefix = prefixedId[..slashIndex];
|
||||
var rest = prefixedId[(slashIndex + 1)..];
|
||||
|
||||
if (backends.TryGetValue(prefix, out var url))
|
||||
{
|
||||
return (url, rest);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check all prefixes
|
||||
foreach (var (prefix, url) in backends)
|
||||
{
|
||||
if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal))
|
||||
{
|
||||
return (url, prefixedId[(prefix.Length + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, prefixedId);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadRequestBodyAsync(HttpRequest request)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await request.Body.CopyToAsync(ms).ConfigureAwait(false);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
foreach (var header in response.Headers.Where(h => !IsHopByHopHeader(h.Key)))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
|
||||
await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsHopByHopHeader(string headerName)
|
||||
{
|
||||
return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase)
|
||||
|| headerName.Equals("Host", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace Aspire.Hosting.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a DevUI resource for testing AI agents in a distributed application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// DevUI aggregates agents from multiple backend services and provides a unified
|
||||
/// web interface for testing and debugging AI agents using the OpenAI Responses protocol.
|
||||
/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no
|
||||
/// external container image.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name of the DevUI resource.</param>
|
||||
public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
|
||||
{
|
||||
internal const string PrimaryEndpointName = "http";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevUIResource"/> class with endpoint annotations.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the resource.</param>
|
||||
/// <param name="port">An optional fixed port. If <c>null</c>, a dynamic port is assigned.</param>
|
||||
internal DevUIResource(string name, int? port) : this(name)
|
||||
{
|
||||
this.Port = port;
|
||||
this.Annotations.Add(new EndpointAnnotation(
|
||||
ProtocolType.Tcp,
|
||||
uriScheme: "http",
|
||||
name: PrimaryEndpointName,
|
||||
port: port,
|
||||
isProxied: false)
|
||||
{
|
||||
TargetHost = "localhost"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional fixed port for the DevUI web interface.
|
||||
/// </summary>
|
||||
internal int? Port { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary HTTP endpoint for the DevUI web interface.
|
||||
/// </summary>
|
||||
public EndpointReference PrimaryEndpoint => field ??= new(this, PrimaryEndpointName);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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).
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Agent services must expose the OpenAI Responses and Conversations API endpoints. This is compatible with services using [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with `MapOpenAIResponses()` and `MapOpenAIConversations()` mapped.
|
||||
|
||||
### Install the package
|
||||
|
||||
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.DevUI
|
||||
```
|
||||
|
||||
## Usage example
|
||||
|
||||
Then, in the _AppHost.cs_ file of `AppHost`, add a DevUI resource and connect it to your agent services using the following methods:
|
||||
|
||||
```csharp
|
||||
var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent")
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent")
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
var devui = builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent)
|
||||
.WithAgentService(editorAgent)
|
||||
.WaitFor(writerAgent)
|
||||
.WaitFor(editorAgent);
|
||||
```
|
||||
|
||||
Each agent service only needs to map the standard OpenAI API endpoints — no custom discovery endpoints are required:
|
||||
|
||||
```csharp
|
||||
// In the agent service's Program.cs
|
||||
builder.AddAIAgent("writer", "You write short stories.");
|
||||
builder.Services.AddOpenAIResponses();
|
||||
builder.Services.AddOpenAIConversations();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenAIResponses();
|
||||
app.MapOpenAIConversations();
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
`AddDevUI` starts an **in-process aggregator** inside the AppHost — no external container image is needed. The aggregator is a lightweight Kestrel server that:
|
||||
|
||||
1. **Serves the DevUI frontend** from the `Microsoft.Agents.AI.DevUI` assembly's embedded resources (loaded at runtime). If the assembly is not available, it falls back to proxying the frontend from the first backend.
|
||||
2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`).
|
||||
3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service.
|
||||
4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time.
|
||||
|
||||
The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link.
|
||||
|
||||
## Agent discovery
|
||||
|
||||
By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")])
|
||||
.WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]);
|
||||
```
|
||||
|
||||
Agent metadata is declared at the AppHost level so the aggregator builds the entity listing directly — agent services don't need a `/v1/entities` endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Custom entity ID prefix
|
||||
|
||||
By default, entity IDs are prefixed with the Aspire resource name. You can specify a custom prefix:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui")
|
||||
.WithAgentService(myService, entityIdPrefix: "custom-prefix");
|
||||
```
|
||||
|
||||
### Custom port
|
||||
|
||||
You can specify a fixed host port for the DevUI web interface:
|
||||
|
||||
```csharp
|
||||
builder.AddDevUI("devui", port: 8090);
|
||||
```
|
||||
|
||||
### DevUI frontend assembly
|
||||
|
||||
To serve the DevUI frontend directly from the aggregator (instead of proxying from a backend), add the `Microsoft.Agents.AI.DevUI` NuGet package to your AppHost project. The aggregator loads its embedded resources at runtime via `Assembly.Load`.
|
||||
|
||||
## Additional documentation
|
||||
|
||||
* https://github.com/microsoft/agent-framework
|
||||
* https://github.com/microsoft/agent-framework/tree/main/dotnet/src/Microsoft.Agents.AI.DevUI
|
||||
|
||||
## Feedback & contributing
|
||||
|
||||
https://github.com/dotnet/aspire
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentEntityInfo"/> record.
|
||||
/// </summary>
|
||||
public class AgentEntityInfoTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Id property is set from the constructor parameter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithId_SetsIdProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent", info.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Description property is set when provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithDescription_SetsDescriptionProperty()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent", "A test agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("A test agent", info.Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the Description property is null when not provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithoutDescription_DescriptionIsNull()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(info.Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Default Value Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Name defaults to the Id value when not explicitly set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_NotSet_DefaultsToId()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-agent", info.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Name can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Name_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Name = "Custom Name" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Custom Name", info.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Type defaults to "agent".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Type_NotSet_DefaultsToAgent()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("agent", info.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Type can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Type_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Type = "workflow" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("workflow", info.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Framework defaults to "agent_framework".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Framework_NotSet_DefaultsToAgentFramework()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("agent_framework", info.Framework);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Framework can be overridden with a custom value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Framework_Set_ReturnsCustomValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var info = new AgentEntityInfo("test-agent") { Framework = "custom_framework" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("custom_framework", info.Framework);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Record Equality Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AgentEntityInfo records with identical values are equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equality_SameValues_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
var info1 = new AgentEntityInfo("agent", "description");
|
||||
var info2 = new AgentEntityInfo("agent", "description");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(info1, info2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AgentEntityInfo records with different Ids are not equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equality_DifferentIds_AreNotEqual()
|
||||
{
|
||||
// Arrange
|
||||
var info1 = new AgentEntityInfo("agent1");
|
||||
var info2 = new AgentEntityInfo("agent2");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(info1, info2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that with-expression creates a modified copy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithExpression_ModifiesProperty_CreatesNewInstance()
|
||||
{
|
||||
// Arrange
|
||||
var original = new AgentEntityInfo("agent", "Original description");
|
||||
|
||||
// Act
|
||||
var modified = original with { Description = "Modified description" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original description", original.Description);
|
||||
Assert.Equal("Modified description", modified.Description);
|
||||
Assert.Equal(original.Id, modified.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Moq;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentFrameworkBuilderExtensions"/> class.
|
||||
/// </summary>
|
||||
public class AgentFrameworkBuilderExtensionsTests
|
||||
{
|
||||
#region AddDevUI Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI throws ArgumentNullException when builder is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => AgentFrameworkBuilderExtensions.AddDevUI(null!, "devui"));
|
||||
Assert.Equal("builder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI throws ArgumentNullException when name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => builder.AddDevUI(null!));
|
||||
Assert.Equal("name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI creates a resource with the specified name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_ValidName_CreatesResourceWithName()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("my-devui");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("my-devui", resourceBuilder.Resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI creates a DevUIResource.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_ReturnsDevUIResourceBuilder()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsType<DevUIResource>(resourceBuilder.Resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI with port configures the endpoint.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_WithPort_ConfiguresEndpointWithPort()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui", port: 8090);
|
||||
|
||||
// Assert
|
||||
var endpoint = resourceBuilder.Resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.FirstOrDefault(e => e.Name == "http");
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Equal(8090, endpoint.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI without port leaves port as null for dynamic allocation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_WithoutPort_EndpointHasDynamicPort()
|
||||
{
|
||||
// Arrange
|
||||
var builder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var resourceBuilder = builder.AddDevUI("devui");
|
||||
|
||||
// Assert
|
||||
var endpoint = resourceBuilder.Resource.Annotations
|
||||
.OfType<EndpointAnnotation>()
|
||||
.FirstOrDefault(e => e.Name == "http");
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Null(endpoint.Port);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WithAgentService Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService throws ArgumentNullException when builder is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NullBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var mockAgentService = CreateMockAgentServiceBuilder(appBuilder, "agent-service");
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => AgentFrameworkBuilderExtensions.WithAgentService(null!, mockAgentService));
|
||||
Assert.Equal("builder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService throws ArgumentNullException when agentService is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NullAgentService_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(
|
||||
() => devuiBuilder.WithAgentService<IResourceWithEndpoints>(null!));
|
||||
Assert.Equal("agentService", exception.ParamName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WithAgentService Annotation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService adds an AgentServiceAnnotation to the resource.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_ValidService_AddsAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Same(agentService.Resource, annotation.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService defaults to agent name being the resource name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NoAgents_DefaultsToResourceNameAsAgent()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Single(annotation.Agents);
|
||||
Assert.Equal("writer-agent", annotation.Agents[0].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService with explicit agents uses those agents.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_WithAgents_UsesProvidedAgents()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "multi-agent-service");
|
||||
var agents = new[]
|
||||
{
|
||||
new AgentEntityInfo("agent1", "First agent"),
|
||||
new AgentEntityInfo("agent2", "Second agent")
|
||||
};
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal(2, annotation.Agents.Count);
|
||||
Assert.Equal("agent1", annotation.Agents[0].Id);
|
||||
Assert.Equal("agent2", annotation.Agents[1].Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService with custom prefix uses that prefix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_WithEntityIdPrefix_UsesProvidedPrefix()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, entityIdPrefix: "custom-prefix");
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService without prefix leaves EntityIdPrefix null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_NoEntityIdPrefix_PrefixIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Null(annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chaining Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService returns the builder for chaining.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_ReturnsSameBuilder_ForChaining()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
var result = devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
Assert.Same(devuiBuilder, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple WithAgentService calls can be chained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_MultipleCalls_AddsMultipleAnnotations()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder
|
||||
.WithAgentService(writerService)
|
||||
.WithAgentService(editorService);
|
||||
|
||||
// Assert
|
||||
var annotations = devuiBuilder.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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI returns a builder that can be chained with WithAgentService.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_CanChainWithAgentService()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act - Chain AddDevUI with WithAgentService
|
||||
var result = appBuilder.AddDevUI("devui").WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var annotation = result.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(annotation);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Relationship Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService creates a relationship annotation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_CreatesRelationshipAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var relationship = devuiBuilder.Resource.Annotations
|
||||
.OfType<ResourceRelationshipAnnotation>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(relationship);
|
||||
Assert.Equal("agent-backend", relationship.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple WithAgentService calls create multiple relationship annotations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_MultipleCalls_CreatesMultipleRelationships()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent");
|
||||
|
||||
// Act
|
||||
devuiBuilder
|
||||
.WithAgentService(writerService)
|
||||
.WithAgentService(editorService);
|
||||
|
||||
// Assert
|
||||
var relationships = devuiBuilder.Resource.Annotations
|
||||
.OfType<ResourceRelationshipAnnotation>()
|
||||
.ToList();
|
||||
Assert.Equal(2, relationships.Count);
|
||||
Assert.All(relationships, r => Assert.Equal("agent-backend", r.Type));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Agent Metadata Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that agent description is preserved when specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_AgentWithDescription_PreservesDescription()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var agents = new[] { new AgentEntityInfo("writer", "Writes creative stories") };
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
Assert.Equal("Writes creative stories", annotation.Agents[0].Description);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom agent properties are preserved.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_CustomAgentProperties_ArePreserved()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "custom-service");
|
||||
var agents = new[]
|
||||
{
|
||||
new AgentEntityInfo("custom-agent")
|
||||
{
|
||||
Name = "Custom Display Name",
|
||||
Type = "workflow",
|
||||
Framework = "custom_framework"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: agents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
var agent = annotation.Agents[0];
|
||||
Assert.Equal("custom-agent", agent.Id);
|
||||
Assert.Equal("Custom Display Name", agent.Name);
|
||||
Assert.Equal("workflow", agent.Type);
|
||||
Assert.Equal("custom_framework", agent.Framework);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty agents array can be explicitly provided and is respected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_EmptyAgentsArray_UsesEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devuiBuilder = appBuilder.AddDevUI("devui");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
var emptyAgents = Array.Empty<AgentEntityInfo>();
|
||||
|
||||
// Act
|
||||
devuiBuilder.WithAgentService(agentService, agents: emptyAgents);
|
||||
|
||||
// Assert
|
||||
var annotation = devuiBuilder.Resource.Annotations
|
||||
.OfType<AgentServiceAnnotation>()
|
||||
.First();
|
||||
// When explicitly passing an empty array, the extension method respects it
|
||||
// This is the expected behavior - explicit empty means "discover at runtime"
|
||||
Assert.Empty(annotation.Agents);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddDevUI can be called multiple times with different names.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddDevUI_MultipleCalls_CreatesSeparateResources()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
|
||||
// Act
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
|
||||
// Assert
|
||||
Assert.NotSame(devui1.Resource, devui2.Resource);
|
||||
Assert.Equal("devui1", devui1.Resource.Name);
|
||||
Assert.Equal("devui2", devui2.Resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that same agent service can be added to multiple DevUI resources.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_SameServiceToMultipleDevUI_Works()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "shared-agent");
|
||||
|
||||
// Act
|
||||
devui1.WithAgentService(agentService);
|
||||
devui2.WithAgentService(agentService);
|
||||
|
||||
// Assert
|
||||
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
Assert.Same(annotation1.AgentService, annotation2.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WithAgentService works with different entity ID prefixes for the same service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WithAgentService_DifferentPrefixesToDifferentDevUI_Works()
|
||||
{
|
||||
// Arrange
|
||||
var appBuilder = DistributedApplication.CreateBuilder();
|
||||
var devui1 = appBuilder.AddDevUI("devui1");
|
||||
var devui2 = appBuilder.AddDevUI("devui2");
|
||||
var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent");
|
||||
|
||||
// Act
|
||||
devui1.WithAgentService(agentService, entityIdPrefix: "prefix1");
|
||||
devui2.WithAgentService(agentService, entityIdPrefix: "prefix2");
|
||||
|
||||
// Assert
|
||||
var annotation1 = devui1.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
var annotation2 = devui2.Resource.Annotations.OfType<AgentServiceAnnotation>().Single();
|
||||
Assert.Equal("prefix1", annotation1.EntityIdPrefix);
|
||||
Assert.Equal("prefix2", annotation2.EntityIdPrefix);
|
||||
}
|
||||
|
||||
#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 Mock<IResourceWithEndpoints>();
|
||||
mockResource.Setup(r => r.Name).Returns(name);
|
||||
mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
|
||||
|
||||
var mockBuilder = new Mock<IResourceBuilder<IResourceWithEndpoints>>();
|
||||
mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
|
||||
mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
|
||||
|
||||
return mockBuilder.Object;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
using Moq;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentServiceAnnotation"/> class.
|
||||
/// </summary>
|
||||
public class AgentServiceAnnotationTests
|
||||
{
|
||||
#region Constructor Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that passing null for agentService throws ArgumentNullException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_NullAgentService_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new AgentServiceAnnotation(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a valid agentService can be used to create the annotation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_ValidAgentService_CreatesAnnotation()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(annotation);
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AgentService property returns the value passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AgentService_ReturnsConstructorValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("my-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that EntityIdPrefix returns null when not specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityIdPrefix_NotSpecified_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Null(annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that EntityIdPrefix returns the value passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityIdPrefix_Specified_ReturnsValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object, entityIdPrefix: "custom-prefix");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Agents returns empty collection when not specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Agents_NotSpecified_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(annotation.Agents);
|
||||
Assert.Empty(annotation.Agents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Agents returns the list passed to constructor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Agents_Specified_ReturnsValue()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("test-service");
|
||||
var agents = new[] { new AgentEntityInfo("agent1"), new AgentEntityInfo("agent2") };
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(mockResource.Object, agents: agents);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, annotation.Agents.Count);
|
||||
Assert.Equal("agent1", annotation.Agents[0].Id);
|
||||
Assert.Equal("agent2", annotation.Agents[1].Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all constructor parameters are correctly stored.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_AllParameters_SetsAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var mockResource = new Mock<IResource>();
|
||||
mockResource.Setup(r => r.Name).Returns("full-service");
|
||||
var agents = new[] { new AgentEntityInfo("writer", "Writes stories") };
|
||||
|
||||
// Act
|
||||
var annotation = new AgentServiceAnnotation(
|
||||
mockResource.Object,
|
||||
entityIdPrefix: "writer-backend",
|
||||
agents: agents);
|
||||
|
||||
// Assert
|
||||
Assert.Same(mockResource.Object, annotation.AgentService);
|
||||
Assert.Equal("writer-backend", annotation.EntityIdPrefix);
|
||||
Assert.Single(annotation.Agents);
|
||||
Assert.Equal("writer", annotation.Agents[0].Id);
|
||||
Assert.Equal("Writes stories", annotation.Agents[0].Description);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Aspire.Hosting.AgentFramework.DevUI\Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
|
||||
namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="DevUIResource"/> class.
|
||||
/// </summary>
|
||||
public class DevUIResourceTests
|
||||
{
|
||||
#region Constructor Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource name is correctly set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithName_SetsName()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("test-devui", resource.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource implements IResourceWithEndpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resource_ImplementsIResourceWithEndpoints()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<IResourceWithEndpoints>(resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource implements IResourceWithWaitSupport.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resource_ImplementsIResourceWithWaitSupport()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = new DevUIResource("test-devui");
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<IResourceWithWaitSupport>(resource);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Endpoint Annotation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the resource has an HTTP endpoint annotation when port is specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithPort_AddsEndpointAnnotation()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = CreateResourceWithPort(8090);
|
||||
|
||||
// Assert
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Equal("http", endpoint.Name);
|
||||
Assert.Equal(8090, endpoint.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint annotation has correct protocol type.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_HasTcpProtocol()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint annotation has HTTP URI scheme.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_HasHttpUriScheme()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("http", endpoint.UriScheme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint is not proxied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_IsNotProxied()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.False(endpoint.IsProxied);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint target host is localhost.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EndpointAnnotation_TargetHostIsLocalhost()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().First();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("localhost", endpoint.TargetHost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the endpoint has no fixed port when null is passed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Constructor_WithNullPort_EndpointHasNullPort()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resource = CreateResourceWithPort(null);
|
||||
|
||||
// Assert
|
||||
var endpoint = resource.Annotations.OfType<EndpointAnnotation>().FirstOrDefault();
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Null(endpoint.Port);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PrimaryEndpoint Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PrimaryEndpoint returns an endpoint reference.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrimaryEndpoint_ReturnsEndpointReference()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint = resource.PrimaryEndpoint;
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(endpoint);
|
||||
Assert.Same(resource, endpoint.Resource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that PrimaryEndpoint returns the same instance on multiple calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PrimaryEndpoint_MultipleCalls_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
var resource = CreateResourceWithPort(8080);
|
||||
|
||||
// Act
|
||||
var endpoint1 = resource.PrimaryEndpoint;
|
||||
var endpoint2 = resource.PrimaryEndpoint;
|
||||
|
||||
// Assert
|
||||
Assert.Same(endpoint1, endpoint2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static DevUIResource CreateResourceWithPort(int? port) => new("test-devui", port);
|
||||
}
|
||||
Reference in New Issue
Block a user