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]); + } +}