diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index a44a4d420e..3b6b016a92 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -7,13 +7,16 @@
- 13.0.2
+ 13.1.0
+
+
+
@@ -48,12 +51,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
@@ -70,18 +73,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
-
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 9801ccc105..fbb64e1165 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -4,6 +4,10 @@
+
+
+
+
@@ -37,6 +41,12 @@
+
+
+
+
+
+
@@ -480,6 +490,7 @@
+
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs
new file mode 100644
index 0000000000..c308963fc1
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Aspire.Hosting.AgentFramework;
+
+///
+/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI.
+///
+///
+///
+/// When added via ,
+/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the
+/// entity listing without querying each backend's /v1/entities endpoint.
+///
+///
+/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints
+/// (MapOpenAIResponses and MapOpenAIConversations), not a custom discovery endpoint.
+///
+///
+/// The unique identifier for the agent, typically matching the name passed to AddAIAgent.
+/// A short description of the agent's capabilities.
+public record AgentEntityInfo(string Id, string? Description = null)
+{
+ ///
+ /// Gets the display name for the agent. Defaults to if not specified.
+ ///
+ public string Name { get; init; } = Id;
+
+ ///
+ /// Gets the entity type. Defaults to "agent".
+ ///
+ public string Type { get; init; } = "agent";
+
+ ///
+ /// Gets the framework identifier. Defaults to "agent_framework".
+ ///
+ public string Framework { get; init; } = "agent_framework";
+}
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs
new file mode 100644
index 0000000000..09e9305910
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs
@@ -0,0 +1,182 @@
+// 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;
+
+///
+/// Provides extension methods for adding Agent Framework DevUI resources to the application model.
+///
+public static class AgentFrameworkBuilderExtensions
+{
+ ///
+ /// Adds a DevUI resource for testing AI agents in a distributed application.
+ ///
+ ///
+ ///
+ /// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol.
+ /// When configured with , it aggregates agents from multiple backend services
+ /// and provides a unified testing interface.
+ ///
+ ///
+ /// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image.
+ /// It proxies the DevUI frontend from the first configured backend and aggregates entity listings from all backends.
+ ///
+ ///
+ /// This resource is excluded from the deployment manifest as it is intended for development use only.
+ ///
+ ///
+ /// The .
+ /// The name to give the resource.
+ /// The host port for the DevUI web interface. If not specified, a random port will be assigned.
+ /// A reference to the for chaining.
+ ///
+ ///
+ /// var devui = builder.AddDevUI("devui")
+ /// .WithAgentService(dotnetAgent)
+ /// .WithAgentService(pythonAgent);
+ ///
+ ///
+ public static IResourceBuilder 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(resource, async (e, ct) =>
+ {
+ var logger = e.Logger;
+ var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService().CreateLogger());
+
+ 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()
+ .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();
+ 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 e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
+ {
+ State = KnownResourceStates.FailedToStart
+ }).ConfigureAwait(false);
+ }
+ });
+
+ return resourceBuilder;
+ }
+
+ ///
+ /// Configures DevUI to connect to an agent service backend.
+ ///
+ ///
+ ///
+ /// Each agent service should expose the OpenAI Responses and Conversations API endpoints
+ /// (via MapOpenAIResponses and MapOpenAIConversations).
+ ///
+ ///
+ /// When 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 /v1/entities endpoint.
+ ///
+ ///
+ /// The type of the agent service resource.
+ /// The DevUI resource builder.
+ /// The agent service resource to connect to.
+ ///
+ /// 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
+ /// resource. The backend doesn't need to expose a
+ /// /v1/entities endpoint in either case.
+ ///
+ ///
+ /// An optional prefix to add to entity IDs from this backend.
+ /// If not specified, the resource name will be used as the prefix.
+ ///
+ /// A reference to the for chaining.
+ ///
+ ///
+ /// 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);
+ ///
+ ///
+ public static IResourceBuilder WithAgentService(
+ this IResourceBuilder builder,
+ IResourceBuilder agentService,
+ IReadOnlyList? 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;
+ }
+}
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs
new file mode 100644
index 0000000000..15b3f7dd90
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Aspire.Hosting.AgentFramework;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// An annotation that tracks an agent service backend referenced by a DevUI resource.
+///
+///
+/// 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.
+///
+public class AgentServiceAnnotation : IResourceAnnotation
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The agent service resource.
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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 /v1/entities endpoint.
+ ///
+ public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList? agents = null)
+ {
+ ArgumentNullException.ThrowIfNull(agentService);
+
+ this.AgentService = agentService;
+ this.EntityIdPrefix = entityIdPrefix;
+ this.Agents = agents ?? [];
+ }
+
+ ///
+ /// Gets the agent service resource that exposes AI agents.
+ ///
+ public IResource AgentService { get; }
+
+ ///
+ /// Gets the prefix to use for entity IDs from this backend.
+ ///
+ ///
+ /// When null, the resource name will be used as the prefix.
+ /// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness
+ /// across multiple agent backends.
+ ///
+ public string? EntityIdPrefix { get; }
+
+ ///
+ /// Gets the list of agents declared by this backend.
+ ///
+ ///
+ /// 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
+ /// GET /v1/entities on the backend for discovery.
+ ///
+ public IReadOnlyList Agents { get; }
+}
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj
new file mode 100644
index 0000000000..0f45c95147
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj
@@ -0,0 +1,25 @@
+
+
+
+ $(TargetFrameworksCore)
+ true
+ aspire integration hosting agent-framework devui ai agents
+ Microsoft Agent Framework DevUI support for Aspire.
+
+
+ $(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
new file mode 100644
index 0000000000..aadb4e58f1
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
@@ -0,0 +1,772 @@
+// 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;
+
+///
+/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends.
+/// Serves the DevUI frontend directly from the Microsoft.Agents.AI.DevUI assembly's embedded
+/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing.
+///
+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? _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 _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase);
+
+ public DevUIAggregatorHostedService(
+ DevUIResource resource,
+ ILogger logger)
+ {
+ this._resource = resource;
+ this._logger = logger;
+ this._frontendResources = LoadFrontendResources(logger);
+ }
+
+ ///
+ /// Gets the port the aggregator is listening on, available after .
+ ///
+ 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()
+ .Features.Get();
+
+ 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;
+ }
+ }
+
+ ///
+ /// Loads the DevUI frontend resources from the Microsoft.Agents.AI.DevUI assembly.
+ /// The assembly embeds the Vite SPA build output as manifest resources.
+ /// Returns null if the assembly is not available.
+ ///
+ private static Dictionary? 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(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;
+ }
+
+ ///
+ /// Serves the DevUI frontend. Uses embedded assembly resources if available,
+ /// otherwise falls back to proxying from the first backend agent service.
+ ///
+ 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 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
+ {
+ ["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);
+ }
+
+ ///
+ /// Resolves backend URLs from the resource's annotations.
+ /// This method does not cache results to ensure late-allocated backends are always discovered.
+ ///
+ private Dictionary ResolveBackends()
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var annotation in this._resource.Annotations.OfType())
+ {
+ 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 AggregateEntitiesAsync(HttpContext context)
+ {
+ var backends = this.ResolveBackends();
+ var allEntities = new JsonArray();
+
+ foreach (var annotation in this._resource.Annotations.OfType())
+ {
+ 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();
+ 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() ?? cloned["name"]?.GetValue();
+
+ 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();
+ 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();
+
+ 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;
+ }
+ }
+
+ if (backendUrl is null && context.Request.ContentLength > 0)
+ {
+ var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false);
+ var json = JsonNode.Parse(bodyBytes);
+ var entityId = json?["metadata"]?["entity_id"]?.GetValue()
+ ?? json?["metadata"]?["agent_id"]?.GetValue();
+
+ 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;
+ }
+
+ var rewritten = JsonSerializer.SerializeToUtf8Bytes(json);
+ var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}";
+
+ // Also rewrite query string agent_id if present
+ var bodyQueryString = (agentId is not null)
+ ? RewriteAgentIdInQueryString(context.Request.QueryString, actualId)
+ : context.Request.QueryString.ToString();
+
+ await this.ProxyAndRecordConversationAsync(
+ context, backendUrl, path, targetPath + bodyQueryString, rewritten).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: null).ConfigureAwait(false);
+ }
+ else
+ {
+ await ProxyRequestAsync(
+ context, backendUrl, convPath + queryString, bodyBytes: null).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Rewrites the agent_id query parameter to the un-prefixed value for backend routing.
+ ///
+ internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId)
+ {
+ if (!queryString.HasValue)
+ {
+ return string.Empty;
+ }
+
+ var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value);
+ query["agent_id"] = actualAgentId;
+
+ return QueryString.Create(query).ToString();
+ }
+
+ private static string? ExtractConversationId(string? path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return null;
+ }
+
+ var slashIndex = path.IndexOf('/');
+ return slashIndex > 0 ? path[..slashIndex] : path;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ 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 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);
+ }
+}
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs
new file mode 100644
index 0000000000..9cf85dff07
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Net.Sockets;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a DevUI resource for testing AI agents in a distributed application.
+///
+///
+/// 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.
+///
+/// The name of the DevUI resource.
+public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
+{
+ internal const string PrimaryEndpointName = "http";
+
+ ///
+ /// Initializes a new instance of the class with endpoint annotations.
+ ///
+ /// The name of the resource.
+ /// An optional fixed port. If null, a dynamic port is assigned.
+ 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"
+ });
+ }
+
+ ///
+ /// Gets the optional fixed port for the DevUI web interface.
+ ///
+ internal int? Port { get; }
+
+ ///
+ /// Gets the primary HTTP endpoint for the DevUI web interface.
+ ///
+ public EndpointReference PrimaryEndpoint => field ??= new(this, PrimaryEndpointName);
+}
diff --git a/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md
new file mode 100644
index 0000000000..8dbace2514
--- /dev/null
+++ b/dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/README.md
@@ -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("writer-agent")
+ .WithHttpHealthCheck("/health");
+
+var editorAgent = builder.AddProject("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
diff --git a/dotnet/samples/DevUIIntegration/.aspire/settings.json b/dotnet/samples/DevUIIntegration/.aspire/settings.json
new file mode 100644
index 0000000000..842d8f7ce6
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/.aspire/settings.json
@@ -0,0 +1,3 @@
+{
+ "appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj"
+}
\ No newline at end of file
diff --git a/dotnet/samples/DevUIIntegration/.gitignore b/dotnet/samples/DevUIIntegration/.gitignore
new file mode 100644
index 0000000000..bdc7d02918
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/.gitignore
@@ -0,0 +1 @@
+**/**/*.Development.json
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj
new file mode 100644
index 0000000000..b8f2f1757d
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs
new file mode 100644
index 0000000000..06cf719d78
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Program.cs
@@ -0,0 +1,33 @@
+// 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.
+_ = builder.AddParameterFromConfiguration("tenant", "Azure:TenantId");
+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("writer-agent")
+ .WithHttpHealthCheck("/health")
+ .WithReference(foundry).WaitFor(foundry);
+
+// Add the editor agent service
+var editorAgent = builder.AddProject("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();
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000000..1012f97aa1
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json
new file mode 100644
index 0000000000..bfe8cb0cde
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Azure": {
+ "TenantId": "",
+ "SubscriptionId": "",
+ "AllowResourceGroupCreation": true,
+ "ResourceGroup": "",
+ "Location": "",
+ "CredentialSource": "AzureCli"
+ },
+ "Parameters": {
+ "existingFoundryName": "",
+ "existingFoundryResourceGroup": ""
+ }
+}
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj
new file mode 100644
index 0000000000..0c5573beac
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs
new file mode 100644
index 0000000000..504bc71621
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs
@@ -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(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(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(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(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(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;
+ }
+}
diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj
new file mode 100644
index 0000000000..01a3cf51d9
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/EditorAgent/EditorAgent.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ b2c3d4e5-f6a7-8901-bcde-f12345678901
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs b/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs
new file mode 100644
index 0000000000..9efc666953
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/EditorAgent/Program.cs
@@ -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 = true;
+ })
+ .AddChatClient("gpt-4.1");
+
+builder.AddAIAgent("editor", (sp, key) =>
+{
+ var chatClient = sp.GetRequiredService();
+ 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}
+ """;
diff --git a/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json
new file mode 100644
index 0000000000..3ad5a6f098
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/EditorAgent/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/DevUIIntegration/README.md b/dotnet/samples/DevUIIntegration/README.md
new file mode 100644
index 0000000000..091e4756bd
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/README.md
@@ -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": "",
+ "SubscriptionId": "",
+ "AllowResourceGroupCreation": true,
+ "ResourceGroup": "",
+ "Location": "",
+ "CredentialSource": "AzureCli"
+ },
+ "Parameters": {
+ "existingFoundryName": "",
+ "existingFoundryResourceGroup": ""
+ }
+}
+```
+
+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/DevUIIntegration
+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.
diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs b/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs
new file mode 100644
index 0000000000..70d1a757be
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/WriterAgent/Program.cs
@@ -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 = true;
+ })
+ .AddChatClient("gpt-4.1");
+
+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();
diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json b/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json
new file mode 100644
index 0000000000..5220475800
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/WriterAgent/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj
new file mode 100644
index 0000000000..d1a26923b4
--- /dev/null
+++ b/dotnet/samples/DevUIIntegration/WriterAgent/WriterAgent.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+ a1b2c3d4-e5f6-7890-abcd-ef1234567890
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs
new file mode 100644
index 0000000000..84273d6891
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs
@@ -0,0 +1,184 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
+
+///
+/// Unit tests for the record.
+///
+public class AgentEntityInfoTests
+{
+ #region Constructor Tests
+
+ ///
+ /// Verifies that the Id property is set from the constructor parameter.
+ ///
+ [Fact]
+ public void Constructor_WithId_SetsIdProperty()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent");
+
+ // Assert
+ Assert.Equal("test-agent", info.Id);
+ }
+
+ ///
+ /// Verifies that the Description property is set when provided.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that the Description property is null when not provided.
+ ///
+ [Fact]
+ public void Constructor_WithoutDescription_DescriptionIsNull()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent");
+
+ // Assert
+ Assert.Null(info.Description);
+ }
+
+ #endregion
+
+ #region Default Value Tests
+
+ ///
+ /// Verifies that Name defaults to the Id value when not explicitly set.
+ ///
+ [Fact]
+ public void Name_NotSet_DefaultsToId()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent");
+
+ // Assert
+ Assert.Equal("test-agent", info.Name);
+ }
+
+ ///
+ /// Verifies that Name can be overridden with a custom value.
+ ///
+ [Fact]
+ public void Name_Set_ReturnsCustomValue()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent") { Name = "Custom Name" };
+
+ // Assert
+ Assert.Equal("Custom Name", info.Name);
+ }
+
+ ///
+ /// Verifies that Type defaults to "agent".
+ ///
+ [Fact]
+ public void Type_NotSet_DefaultsToAgent()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent");
+
+ // Assert
+ Assert.Equal("agent", info.Type);
+ }
+
+ ///
+ /// Verifies that Type can be overridden with a custom value.
+ ///
+ [Fact]
+ public void Type_Set_ReturnsCustomValue()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent") { Type = "workflow" };
+
+ // Assert
+ Assert.Equal("workflow", info.Type);
+ }
+
+ ///
+ /// Verifies that Framework defaults to "agent_framework".
+ ///
+ [Fact]
+ public void Framework_NotSet_DefaultsToAgentFramework()
+ {
+ // Arrange & Act
+ var info = new AgentEntityInfo("test-agent");
+
+ // Assert
+ Assert.Equal("agent_framework", info.Framework);
+ }
+
+ ///
+ /// Verifies that Framework can be overridden with a custom value.
+ ///
+ [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
+
+ ///
+ /// Verifies that two AgentEntityInfo records with identical values are equal.
+ ///
+ [Fact]
+ public void Equality_SameValues_AreEqual()
+ {
+ // Arrange
+ var info1 = new AgentEntityInfo("agent", "description");
+ var info2 = new AgentEntityInfo("agent", "description");
+
+ // Assert
+ Assert.Equal(info1, info2);
+ }
+
+ ///
+ /// Verifies that two AgentEntityInfo records with different Ids are not equal.
+ ///
+ [Fact]
+ public void Equality_DifferentIds_AreNotEqual()
+ {
+ // Arrange
+ var info1 = new AgentEntityInfo("agent1");
+ var info2 = new AgentEntityInfo("agent2");
+
+ // Assert
+ Assert.NotEqual(info1, info2);
+ }
+
+ ///
+ /// Verifies that with-expression creates a modified copy.
+ ///
+ [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
+}
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs
new file mode 100644
index 0000000000..21699e3d64
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs
@@ -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;
+
+///
+/// Unit tests for the class.
+///
+public class AgentFrameworkBuilderExtensionsTests
+{
+ #region AddDevUI Validation Tests
+
+ ///
+ /// Verifies that AddDevUI throws ArgumentNullException when builder is null.
+ ///
+ [Fact]
+ public void AddDevUI_NullBuilder_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(
+ () => AgentFrameworkBuilderExtensions.AddDevUI(null!, "devui"));
+ Assert.Equal("builder", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddDevUI throws ArgumentNullException when name is null.
+ ///
+ [Fact]
+ public void AddDevUI_NullName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+
+ // Act & Assert
+ var exception = Assert.Throws(
+ () => builder.AddDevUI(null!));
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddDevUI creates a resource with the specified name.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that AddDevUI creates a DevUIResource.
+ ///
+ [Fact]
+ public void AddDevUI_ReturnsDevUIResourceBuilder()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+
+ // Act
+ var resourceBuilder = builder.AddDevUI("devui");
+
+ // Assert
+ Assert.IsType(resourceBuilder.Resource);
+ }
+
+ ///
+ /// Verifies that AddDevUI with port configures the endpoint.
+ ///
+ [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()
+ .FirstOrDefault(e => e.Name == "http");
+ Assert.NotNull(endpoint);
+ Assert.Equal(8090, endpoint.Port);
+ }
+
+ ///
+ /// Verifies that AddDevUI without port leaves port as null for dynamic allocation.
+ ///
+ [Fact]
+ public void AddDevUI_WithoutPort_EndpointHasDynamicPort()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+
+ // Act
+ var resourceBuilder = builder.AddDevUI("devui");
+
+ // Assert
+ var endpoint = resourceBuilder.Resource.Annotations
+ .OfType()
+ .FirstOrDefault(e => e.Name == "http");
+ Assert.NotNull(endpoint);
+ Assert.Null(endpoint.Port);
+ }
+
+ #endregion
+
+ #region WithAgentService Validation Tests
+
+ ///
+ /// Verifies that WithAgentService throws ArgumentNullException when builder is null.
+ ///
+ [Fact]
+ public void WithAgentService_NullBuilder_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var mockAgentService = CreateMockAgentServiceBuilder(appBuilder, "agent-service");
+
+ // Act & Assert
+ var exception = Assert.Throws(
+ () => AgentFrameworkBuilderExtensions.WithAgentService(null!, mockAgentService));
+ Assert.Equal("builder", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that WithAgentService throws ArgumentNullException when agentService is null.
+ ///
+ [Fact]
+ public void WithAgentService_NullAgentService_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var appBuilder = DistributedApplication.CreateBuilder();
+ var devuiBuilder = appBuilder.AddDevUI("devui");
+
+ // Act & Assert
+ var exception = Assert.Throws(
+ () => devuiBuilder.WithAgentService(null!));
+ Assert.Equal("agentService", exception.ParamName);
+ }
+
+ #endregion
+
+ #region WithAgentService Annotation Tests
+
+ ///
+ /// Verifies that WithAgentService adds an AgentServiceAnnotation to the resource.
+ ///
+ [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()
+ .FirstOrDefault();
+ Assert.NotNull(annotation);
+ Assert.Same(agentService.Resource, annotation.AgentService);
+ }
+
+ ///
+ /// Verifies that WithAgentService defaults to agent name being the resource name.
+ ///
+ [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()
+ .First();
+ Assert.Single(annotation.Agents);
+ Assert.Equal("writer-agent", annotation.Agents[0].Id);
+ }
+
+ ///
+ /// Verifies that WithAgentService with explicit agents uses those agents.
+ ///
+ [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()
+ .First();
+ Assert.Equal(2, annotation.Agents.Count);
+ Assert.Equal("agent1", annotation.Agents[0].Id);
+ Assert.Equal("agent2", annotation.Agents[1].Id);
+ }
+
+ ///
+ /// Verifies that WithAgentService with custom prefix uses that prefix.
+ ///
+ [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()
+ .First();
+ Assert.Equal("custom-prefix", annotation.EntityIdPrefix);
+ }
+
+ ///
+ /// Verifies that WithAgentService without prefix leaves EntityIdPrefix null.
+ ///
+ [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()
+ .First();
+ Assert.Null(annotation.EntityIdPrefix);
+ }
+
+ #endregion
+
+ #region Chaining Tests
+
+ ///
+ /// Verifies that WithAgentService returns the builder for chaining.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that multiple WithAgentService calls can be chained.
+ ///
+ [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()
+ .ToList();
+ Assert.Equal(2, annotations.Count);
+ Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
+ Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
+ }
+
+ ///
+ /// Verifies that AddDevUI returns a builder that can be chained with WithAgentService.
+ ///
+ [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()
+ .FirstOrDefault();
+ Assert.NotNull(annotation);
+ }
+
+ #endregion
+
+ #region Relationship Tests
+
+ ///
+ /// Verifies that WithAgentService creates a relationship annotation.
+ ///
+ [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()
+ .FirstOrDefault();
+ Assert.NotNull(relationship);
+ Assert.Equal("agent-backend", relationship.Type);
+ }
+
+ ///
+ /// Verifies that multiple WithAgentService calls create multiple relationship annotations.
+ ///
+ [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()
+ .ToList();
+ Assert.Equal(2, relationships.Count);
+ Assert.All(relationships, r => Assert.Equal("agent-backend", r.Type));
+ }
+
+ #endregion
+
+ #region Agent Metadata Tests
+
+ ///
+ /// Verifies that agent description is preserved when specified.
+ ///
+ [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()
+ .First();
+ Assert.Equal("Writes creative stories", annotation.Agents[0].Description);
+ }
+
+ ///
+ /// Verifies that custom agent properties are preserved.
+ ///
+ [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()
+ .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);
+ }
+
+ ///
+ /// Verifies that empty agents array can be explicitly provided and is respected.
+ ///
+ [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();
+
+ // Act
+ devuiBuilder.WithAgentService(agentService, agents: emptyAgents);
+
+ // Assert
+ var annotation = devuiBuilder.Resource.Annotations
+ .OfType()
+ .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
+
+ ///
+ /// Verifies that AddDevUI can be called multiple times with different names.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that same agent service can be added to multiple DevUI resources.
+ ///
+ [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().Single();
+ var annotation2 = devui2.Resource.Annotations.OfType().Single();
+ Assert.Same(annotation1.AgentService, annotation2.AgentService);
+ }
+
+ ///
+ /// Verifies that WithAgentService works with different entity ID prefixes for the same service.
+ ///
+ [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().Single();
+ var annotation2 = devui2.Resource.Annotations.OfType().Single();
+ Assert.Equal("prefix1", annotation1.EntityIdPrefix);
+ Assert.Equal("prefix2", annotation2.EntityIdPrefix);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ ///
+ /// Creates a mock agent service builder for testing.
+ /// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
+ ///
+ private static IResourceBuilder CreateMockAgentServiceBuilder(
+ IDistributedApplicationBuilder appBuilder,
+ string name)
+ {
+ // Create a mock resource that implements IResourceWithEndpoints
+ var mockResource = new Mock();
+ mockResource.Setup(r => r.Name).Returns(name);
+ mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
+
+ var mockBuilder = new Mock>();
+ mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
+ mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
+
+ return mockBuilder.Object;
+ }
+
+ #endregion
+}
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs
new file mode 100644
index 0000000000..0e297c56bf
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Aspire.Hosting.ApplicationModel;
+using Moq;
+
+namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public class AgentServiceAnnotationTests
+{
+ #region Constructor Validation Tests
+
+ ///
+ /// Verifies that passing null for agentService throws ArgumentNullException.
+ ///
+ [Fact]
+ public void Constructor_NullAgentService_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ Assert.Throws(() => new AgentServiceAnnotation(null!));
+ }
+
+ ///
+ /// Verifies that a valid agentService can be used to create the annotation.
+ ///
+ [Fact]
+ public void Constructor_ValidAgentService_CreatesAnnotation()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ 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
+
+ ///
+ /// Verifies that AgentService property returns the value passed to constructor.
+ ///
+ [Fact]
+ public void AgentService_ReturnsConstructorValue()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ mockResource.Setup(r => r.Name).Returns("my-service");
+
+ // Act
+ var annotation = new AgentServiceAnnotation(mockResource.Object);
+
+ // Assert
+ Assert.Same(mockResource.Object, annotation.AgentService);
+ }
+
+ ///
+ /// Verifies that EntityIdPrefix returns null when not specified.
+ ///
+ [Fact]
+ public void EntityIdPrefix_NotSpecified_ReturnsNull()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ mockResource.Setup(r => r.Name).Returns("test-service");
+
+ // Act
+ var annotation = new AgentServiceAnnotation(mockResource.Object);
+
+ // Assert
+ Assert.Null(annotation.EntityIdPrefix);
+ }
+
+ ///
+ /// Verifies that EntityIdPrefix returns the value passed to constructor.
+ ///
+ [Fact]
+ public void EntityIdPrefix_Specified_ReturnsValue()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ 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);
+ }
+
+ ///
+ /// Verifies that Agents returns empty collection when not specified.
+ ///
+ [Fact]
+ public void Agents_NotSpecified_ReturnsEmptyCollection()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ mockResource.Setup(r => r.Name).Returns("test-service");
+
+ // Act
+ var annotation = new AgentServiceAnnotation(mockResource.Object);
+
+ // Assert
+ Assert.NotNull(annotation.Agents);
+ Assert.Empty(annotation.Agents);
+ }
+
+ ///
+ /// Verifies that Agents returns the list passed to constructor.
+ ///
+ [Fact]
+ public void Agents_Specified_ReturnsValue()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ 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
+
+ ///
+ /// Verifies that all constructor parameters are correctly stored.
+ ///
+ [Fact]
+ public void Constructor_AllParameters_SetsAllProperties()
+ {
+ // Arrange
+ var mockResource = new Mock();
+ 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
+}
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
new file mode 100644
index 0000000000..b2ea33c1ab
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(TargetFrameworksCore)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
new file mode 100644
index 0000000000..28104aa67a
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
@@ -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;
+
+///
+/// Unit tests for the class.
+///
+public class DevUIAggregatorHostedServiceTests
+{
+ #region RewriteAgentIdInQueryString Tests
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString()
+ {
+ // Arrange
+ var queryString = QueryString.Empty;
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
+
+ // Assert
+ Assert.Equal(string.Empty, result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_WithPrefixedAgentId_RewritesToUnprefixed()
+ {
+ // Arrange
+ var queryString = new QueryString("?agent_id=writer-agent%2Fwriter");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
+
+ // Assert
+ Assert.Contains("agent_id=writer", result);
+ Assert.DoesNotContain("writer-agent", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString preserves other query parameters.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_WithOtherParams_PreservesOtherParams()
+ {
+ // Arrange
+ var queryString = new QueryString("?agent_id=writer-agent%2Fwriter&conversation_id=123&page=5");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
+
+ // Assert
+ Assert.Contains("agent_id=writer", result);
+ Assert.Contains("conversation_id=123", result);
+ Assert.Contains("page=5", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_AgentIdNotFirst_StillRewrites()
+ {
+ // Arrange
+ var queryString = new QueryString("?page=1&agent_id=editor-agent%2Feditor&limit=10");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "editor");
+
+ // Assert
+ Assert.Contains("agent_id=editor", result);
+ Assert.DoesNotContain("editor-agent", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_SpecialCharsInAgentId_UrlEncodesCorrectly()
+ {
+ // Arrange
+ var queryString = new QueryString("?agent_id=prefix%2Fmy-agent");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "my-agent");
+
+ // Assert
+ // The result should contain the agent_id with the value properly encoded if needed
+ Assert.Contains("agent_id=my-agent", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_NoPrefix_SetsDirectly()
+ {
+ // Arrange
+ var queryString = new QueryString("?agent_id=simple");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "new-value");
+
+ // Assert
+ Assert.Contains("agent_id=new-value", result);
+ Assert.DoesNotContain("simple", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_NoAgentId_AddsAgentId()
+ {
+ // Arrange
+ var queryString = new QueryString("?page=1&limit=10");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
+
+ // Assert
+ Assert.Contains("agent_id=writer", result);
+ Assert.Contains("page=1", result);
+ Assert.Contains("limit=10", result);
+ }
+
+ ///
+ /// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?.
+ ///
+ [Fact]
+ public void RewriteAgentIdInQueryString_ValidQuery_ReturnsQueryStringFormat()
+ {
+ // Arrange
+ var queryString = new QueryString("?agent_id=test");
+
+ // Act
+ var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer");
+
+ // Assert
+ Assert.StartsWith("?", result);
+ }
+
+ #endregion
+
+ #region Backend Resolution Behavior Tests
+
+ ///
+ /// Verifies that ResolveBackends returns empty dictionary when no annotations are present.
+ /// These tests verify the expected behavior of the aggregator via the DevUI resource annotations.
+ ///
+ [Fact]
+ public void DevUIResource_NoAnnotations_ResolveBackendsReturnsEmpty()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+ var devui = builder.AddDevUI("devui");
+
+ // Assert - no AgentServiceAnnotation means no backends
+ var annotations = devui.Resource.Annotations
+ .OfType()
+ .ToList();
+
+ Assert.Empty(annotations);
+ }
+
+ ///
+ /// Verifies that WithAgentService adds proper annotations for backend resolution.
+ ///
+ [Fact]
+ public void WithAgentService_AddsAnnotation_ForBackendResolution()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+ var devui = builder.AddDevUI("devui");
+ var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
+
+ // Act
+ devui.WithAgentService(agentService);
+
+ // Assert
+ var annotation = devui.Resource.Annotations
+ .OfType()
+ .FirstOrDefault();
+
+ Assert.NotNull(annotation);
+ Assert.Equal("writer-agent", annotation.AgentService.Name);
+ }
+
+ ///
+ /// Verifies that custom EntityIdPrefix is properly stored in the annotation.
+ ///
+ [Fact]
+ public void WithAgentService_CustomPrefix_StoresInAnnotation()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+ var devui = builder.AddDevUI("devui");
+ var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent");
+
+ // Act
+ devui.WithAgentService(agentService, entityIdPrefix: "custom-writer");
+
+ // Assert
+ var annotation = devui.Resource.Annotations
+ .OfType()
+ .First();
+
+ Assert.Equal("custom-writer", annotation.EntityIdPrefix);
+ }
+
+ ///
+ /// Verifies that multiple agent services create multiple annotations for backend resolution.
+ ///
+ [Fact]
+ public void WithAgentService_MultipleServices_CreatesMultipleAnnotations()
+ {
+ // Arrange
+ var builder = DistributedApplication.CreateBuilder();
+ var devui = builder.AddDevUI("devui");
+ var writerService = CreateMockAgentServiceBuilder(builder, "writer-agent");
+ var editorService = CreateMockAgentServiceBuilder(builder, "editor-agent");
+
+ // Act
+ devui.WithAgentService(writerService);
+ devui.WithAgentService(editorService);
+
+ // Assert
+ var annotations = devui.Resource.Annotations
+ .OfType()
+ .ToList();
+
+ Assert.Equal(2, annotations.Count);
+ Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent");
+ Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent");
+ }
+
+ #endregion
+
+ #region Entity ID Parsing Tests
+
+ ///
+ /// Verifies the expected format for prefixed entity IDs in the aggregator.
+ ///
+ [Theory]
+ [InlineData("writer-agent/writer", "writer-agent", "writer")]
+ [InlineData("editor-agent/editor", "editor-agent", "editor")]
+ [InlineData("custom/my-agent", "custom", "my-agent")]
+ [InlineData("prefix/sub/path", "prefix", "sub/path")]
+ public void PrefixedEntityId_Format_ExtractsCorrectly(string prefixedId, string expectedPrefix, string expectedRest)
+ {
+ // This test documents the expected format for prefixed entity IDs
+ // The aggregator uses "prefix/entityId" format where:
+ // - prefix is typically the resource name or custom prefix
+ // - entityId is the original entity identifier from the backend
+
+ var slashIndex = prefixedId.IndexOf('/');
+ var prefix = prefixedId[..slashIndex];
+ var rest = prefixedId[(slashIndex + 1)..];
+
+ Assert.Equal(expectedPrefix, prefix);
+ Assert.Equal(expectedRest, rest);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ ///
+ /// Creates a mock agent service builder for testing.
+ /// Uses a minimal resource implementation that satisfies IResourceWithEndpoints.
+ ///
+ private static IResourceBuilder CreateMockAgentServiceBuilder(
+ IDistributedApplicationBuilder appBuilder,
+ string name)
+ {
+ // Create a mock resource that implements IResourceWithEndpoints
+ var mockResource = new Moq.Mock();
+ mockResource.Setup(r => r.Name).Returns(name);
+ mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection());
+
+ var mockBuilder = new Moq.Mock>();
+ mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object);
+ mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder);
+
+ return mockBuilder.Object;
+ }
+
+ #endregion
+}
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs
new file mode 100644
index 0000000000..0f66286ae2
--- /dev/null
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs
@@ -0,0 +1,215 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Net.Sockets;
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public class DevUIResourceTests
+{
+ #region Constructor Tests
+
+ ///
+ /// Verifies that the resource name is correctly set.
+ ///
+ [Fact]
+ public void Constructor_WithName_SetsName()
+ {
+ // Arrange & Act
+ var resource = new DevUIResource("test-devui");
+
+ // Assert
+ Assert.Equal("test-devui", resource.Name);
+ }
+
+ ///
+ /// Verifies that the resource implements IResourceWithEndpoints.
+ ///
+ [Fact]
+ public void Resource_ImplementsIResourceWithEndpoints()
+ {
+ // Arrange & Act
+ var resource = new DevUIResource("test-devui");
+
+ // Assert
+ Assert.IsAssignableFrom(resource);
+ }
+
+ ///
+ /// Verifies that the resource implements IResourceWithWaitSupport.
+ ///
+ [Fact]
+ public void Resource_ImplementsIResourceWithWaitSupport()
+ {
+ // Arrange & Act
+ var resource = new DevUIResource("test-devui");
+
+ // Assert
+ Assert.IsAssignableFrom(resource);
+ }
+
+ #endregion
+
+ #region Endpoint Annotation Tests
+
+ ///
+ /// Verifies that the resource has an HTTP endpoint annotation when port is specified.
+ ///
+ [Fact]
+ public void Constructor_WithPort_AddsEndpointAnnotation()
+ {
+ // Arrange & Act
+ var resource = CreateResourceWithPort(8090);
+
+ // Assert
+ var endpoint = resource.Annotations.OfType().FirstOrDefault();
+ Assert.NotNull(endpoint);
+ Assert.Equal("http", endpoint.Name);
+ Assert.Equal(8090, endpoint.Port);
+ }
+
+ ///
+ /// Verifies that the endpoint annotation has correct protocol type.
+ ///
+ [Fact]
+ public void EndpointAnnotation_HasTcpProtocol()
+ {
+ // Arrange
+ var resource = CreateResourceWithPort(8080);
+
+ // Act
+ var endpoint = resource.Annotations.OfType().First();
+
+ // Assert
+ Assert.Equal(ProtocolType.Tcp, endpoint.Protocol);
+ }
+
+ ///
+ /// Verifies that the endpoint annotation has HTTP URI scheme.
+ ///
+ [Fact]
+ public void EndpointAnnotation_HasHttpUriScheme()
+ {
+ // Arrange
+ var resource = CreateResourceWithPort(8080);
+
+ // Act
+ var endpoint = resource.Annotations.OfType().First();
+
+ // Assert
+ Assert.Equal("http", endpoint.UriScheme);
+ }
+
+ ///
+ /// Verifies that the endpoint is not proxied.
+ ///
+ [Fact]
+ public void EndpointAnnotation_IsNotProxied()
+ {
+ // Arrange
+ var resource = CreateResourceWithPort(8080);
+
+ // Act
+ var endpoint = resource.Annotations.OfType().First();
+
+ // Assert
+ Assert.False(endpoint.IsProxied);
+ }
+
+ ///
+ /// Verifies that the endpoint target host is localhost.
+ ///
+ [Fact]
+ public void EndpointAnnotation_TargetHostIsLocalhost()
+ {
+ // Arrange
+ var resource = CreateResourceWithPort(8080);
+
+ // Act
+ var endpoint = resource.Annotations.OfType().First();
+
+ // Assert
+ Assert.Equal("localhost", endpoint.TargetHost);
+ }
+
+ ///
+ /// Verifies that the endpoint has no fixed port when null is passed.
+ ///
+ [Fact]
+ public void Constructor_WithNullPort_EndpointHasNullPort()
+ {
+ // Arrange & Act
+ var resource = CreateResourceWithPort(null);
+
+ // Assert
+ var endpoint = resource.Annotations.OfType().FirstOrDefault();
+ Assert.NotNull(endpoint);
+ Assert.Null(endpoint.Port);
+ }
+
+ #endregion
+
+ #region PrimaryEndpoint Tests
+
+ ///
+ /// Verifies that PrimaryEndpoint returns an endpoint reference.
+ ///
+ [Fact]
+ public void PrimaryEndpoint_ReturnsEndpointReference()
+ {
+ // Arrange
+ var resource = CreateResourceWithPort(8080);
+
+ // Act
+ var endpoint = resource.PrimaryEndpoint;
+
+ // Assert
+ Assert.NotNull(endpoint);
+ Assert.Same(resource, endpoint.Resource);
+ }
+
+ ///
+ /// Verifies that PrimaryEndpoint returns the same instance on multiple calls.
+ ///
+ [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
+
+ ///
+ /// Creates a DevUIResource using reflection to access the internal constructor.
+ /// This is necessary because the (name, port) constructor is internal.
+ ///
+ private static DevUIResource CreateResourceWithPort(int? port)
+ {
+ // Use reflection to call the internal constructor
+ var constructorInfo = typeof(DevUIResource).GetConstructor(
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic,
+ null,
+ [typeof(string), typeof(int?)],
+ null);
+
+ if (constructorInfo is null)
+ {
+ throw new InvalidOperationException("Could not find internal DevUIResource constructor");
+ }
+
+ return (DevUIResource)constructorInfo.Invoke(["test-devui", port]);
+ }
+}