diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ca1a7fdc8c..0cedcfbdde 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -191,7 +191,7 @@ - + @@ -374,6 +374,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs index 5e997c4f58..09e19a82f5 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/ActorFrameworkWebApplicationExtensions.cs @@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI; namespace AgentWebChat.AgentHost; @@ -10,24 +10,24 @@ internal static class ActorFrameworkWebApplicationExtensions { public static void MapAgentDiscovery(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path) { + var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); + var routeGroup = endpoints.MapGroup(path); - routeGroup.MapGet("/", async ( - AgentCatalog agentCatalog, - CancellationToken cancellationToken) => + routeGroup.MapGet("/", async (CancellationToken cancellationToken) => + { + var results = new List(); + foreach (var result in registeredAIAgents) { - var results = new List(); - await foreach (var result in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) + results.Add(new AgentDiscoveryCard { - results.Add(new AgentDiscoveryCard - { - Name = result.Name!, - Description = result.Description, - }); - } + Name = result.Name!, + Description = result.Description, + }); + } - return Results.Ok(results); - }) - .WithName("GetAgents"); + return Results.Ok(results); + }) + .WithName("GetAgents"); } internal sealed class AgentDiscoveryCard diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 53fd4757ee..b4141ba166 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -8,6 +8,7 @@ + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 46af2a5b19..7447c54aa1 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -5,6 +5,7 @@ using AgentWebChat.AgentHost.Custom; using AgentWebChat.AgentHost.Utilities; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -21,6 +22,13 @@ // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); +// Add DevUI services +builder.AddDevUI(); + +// Add OpenAI services +builder.AddOpenAIChatCompletions(); +builder.AddOpenAIResponses(); + var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", @@ -95,8 +103,48 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te return AgentWorkflowBuilder.BuildConcurrent(workflowName: key, agents: agents); }).AddAsAIAgent(); -builder.AddOpenAIChatCompletions(); -builder.AddOpenAIResponses(); +builder.AddWorkflow("nonAgentWorkflow", (sp, key) => +{ + List usedAgents = [pirateAgentBuilder, chemistryAgent]; + var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: key, agents: agents); +}); + +builder.Services.AddKeyedSingleton("NonAgentAndNonmatchingDINameWorkflow", (sp, key) => +{ + List usedAgents = [pirateAgentBuilder, chemistryAgent]; + var agents = usedAgents.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: "random-name", agents: agents); +}); + +builder.Services.AddSingleton(sp => +{ + var chatClient = sp.GetRequiredKeyedService("chat-model"); + return new ChatClientAgent(chatClient, name: "default-agent", instructions: "you are a default agent."); +}); + +builder.Services.AddKeyedSingleton("my-di-nonmatching-agent", (sp, name) => +{ + var chatClient = sp.GetRequiredKeyedService("chat-model"); + return new ChatClientAgent( + chatClient, + name: "some-random-name", // demonstrating registration can be different for DI and actual agent + instructions: "you are a dependency inject agent. Tell me all about dependency injection."); +}); + +builder.Services.AddKeyedSingleton("my-di-matchingname-agent", (sp, name) => +{ + if (name is not string nameStr) + { + throw new NotSupportedException("Name should be passed as a key"); + } + + var chatClient = sp.GetRequiredKeyedService("chat-model"); + return new ChatClientAgent( + chatClient, + name: nameStr, // demonstrating registration with the same name + instructions: "you are a dependency inject agent. Tell me all about dependency injection."); +}); var app = builder.Build(); @@ -118,7 +166,10 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te // Url = "http://localhost:5390/a2a/knights-and-knaves" }); +app.MapDevUI(); + app.MapOpenAIResponses(); +app.MapOpenAIConversations(); app.MapOpenAIChatCompletions(pirateAgentBuilder); app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs index a28b3e1902..328e3f5e83 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AppHost/Program.cs @@ -9,7 +9,9 @@ var chatModel = builder.AddAIModel("chat-model").AsAzureOpenAI("gpt-4o", o => o.AsExisting(azOpenAiResource, azOpenAiResourceGroup)); var agentHost = builder.AddProject("agenthost") - .WithReference(chatModel); + .WithHttpEndpoint(name: "devui") + .WithUrlForEndpoint("devui", (url) => new() { Url = "/devui", DisplayText = "Dev UI" }) + .WithReference(chatModel); builder.AddProject("webfrontend") .WithExternalHttpEndpoints() diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index 29b7dc588a..3271b40853 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Runtime.CompilerServices; using System.Text.Json; - using Microsoft.Agents.AI.DevUI.Entities; -using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -27,21 +24,26 @@ internal static class EntitiesApiExtensions /// GET /v1/entities/{entityId}/info - Get detailed information about a specific entity /// /// The endpoints are compatible with the Python DevUI frontend and automatically discover entities - /// from the registered and services. + /// from the registered agents and workflows in the dependency injection container. /// public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints) { + var registeredAIAgents = GetRegisteredEntities(endpoints.ServiceProvider); + var registeredWorkflows = GetRegisteredEntities(endpoints.ServiceProvider); + var group = endpoints.MapGroup("/v1/entities") .WithTags("Entities"); // List all entities - group.MapGet("", ListEntitiesAsync) + group.MapGet("", (CancellationToken cancellationToken) + => ListEntitiesAsync(registeredAIAgents, registeredWorkflows, cancellationToken)) .WithName("ListEntities") .WithSummary("List all registered entities (agents and workflows)") .Produces(StatusCodes.Status200OK, contentType: "application/json"); // Get detailed entity information - group.MapGet("{entityId}/info", GetEntityInfoAsync) + group.MapGet("{entityId}/info", (string entityId, string? type, CancellationToken cancellationToken) + => GetEntityInfoAsync(entityId, type, registeredAIAgents, registeredWorkflows, cancellationToken)) .WithName("GetEntityInfo") .WithSummary("Get detailed information about a specific entity") .Produces(StatusCodes.Status200OK, contentType: "application/json") @@ -51,8 +53,8 @@ public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder } private static async Task ListEntitiesAsync( - AgentCatalog? agentCatalog, - WorkflowCatalog? workflowCatalog, + IEnumerable agents, + IEnumerable workflows, CancellationToken cancellationToken) { try @@ -60,13 +62,13 @@ private static async Task ListEntitiesAsync( var entities = new Dictionary(); // Discover agents - await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) + foreach (var agentInfo in DiscoverAgents(agents, entityIdFilter: null)) { entities[agentInfo.Id] = agentInfo; } // Discover workflows - await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityIdFilter: null, cancellationToken).ConfigureAwait(false)) + foreach (var workflowInfo in DiscoverWorkflows(workflows, entityIdFilter: null)) { entities[workflowInfo.Id] = workflowInfo; } @@ -85,15 +87,15 @@ private static async Task ListEntitiesAsync( private static async Task GetEntityInfoAsync( string entityId, string? type, - AgentCatalog? agentCatalog, - WorkflowCatalog? workflowCatalog, + IEnumerable agents, + IEnumerable workflows, CancellationToken cancellationToken) { try { if (type is null || string.Equals(type, "workflow", StringComparison.OrdinalIgnoreCase)) { - await foreach (var workflowInfo in DiscoverWorkflowsAsync(workflowCatalog, entityId, cancellationToken).ConfigureAwait(false)) + foreach (var workflowInfo in DiscoverWorkflows(workflows, entityId)) { return Results.Json(workflowInfo, EntitiesJsonContext.Default.EntityInfo); } @@ -101,7 +103,7 @@ private static async Task GetEntityInfoAsync( if (type is null || string.Equals(type, "agent", StringComparison.OrdinalIgnoreCase)) { - await foreach (var agentInfo in DiscoverAgentsAsync(agentCatalog, entityId, cancellationToken).ConfigureAwait(false)) + foreach (var agentInfo in DiscoverAgents(agents, entityId)) { return Results.Json(agentInfo, EntitiesJsonContext.Default.EntityInfo); } @@ -118,17 +120,9 @@ private static async Task GetEntityInfoAsync( } } - private static async IAsyncEnumerable DiscoverAgentsAsync( - AgentCatalog? agentCatalog, - string? entityIdFilter, - [EnumeratorCancellation] CancellationToken cancellationToken) + private static IEnumerable DiscoverAgents(IEnumerable agents, string? entityIdFilter) { - if (agentCatalog is null) - { - yield break; - } - - await foreach (var agent in agentCatalog.GetAgentsAsync(cancellationToken).ConfigureAwait(false)) + foreach (var agent in agents) { // If filtering by entity ID, skip non-matching agents if (entityIdFilter is not null && @@ -148,17 +142,9 @@ private static async IAsyncEnumerable DiscoverAgentsAsync( } } - private static async IAsyncEnumerable DiscoverWorkflowsAsync( - WorkflowCatalog? workflowCatalog, - string? entityIdFilter, - [EnumeratorCancellation] CancellationToken cancellationToken) + private static IEnumerable DiscoverWorkflows(IEnumerable workflows, string? entityIdFilter) { - if (workflowCatalog is null) - { - yield break; - } - - await foreach (var workflow in workflowCatalog.GetWorkflowsAsync(cancellationToken).ConfigureAwait(false)) + foreach (var workflow in workflows) { var workflowId = workflow.Name ?? workflow.StartExecutorId; @@ -304,4 +290,14 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow) StartExecutorId = workflow.StartExecutorId }; } + + private static IEnumerable GetRegisteredEntities(IServiceProvider serviceProvider) + { + var keyedEntities = serviceProvider.GetKeyedServices(KeyedService.AnyKey); + var defaultEntities = serviceProvider.GetServices() ?? []; + + return keyedEntities + .Concat(defaultEntities) + .Where(entity => entity is not null); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..30fa9ad29e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for to configure DevUI. +/// +public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions +{ + /// + /// Adds DevUI services to the host application builder. + /// + /// The to configure. + /// The for method chaining. + public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddDevUI(); + + return builder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj index 37aa6c37f8..6c9c5bd9e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj @@ -1,7 +1,8 @@  - net9.0 + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) enable enable Microsoft.Agents.AI.DevUI @@ -12,6 +13,10 @@ $(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812 + + true + + @@ -33,4 +38,7 @@ Provides Microsoft Agent Framework support for developer UI. + + + diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md index b55869748d..104c43729b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md @@ -24,9 +24,15 @@ var builder = WebApplication.CreateBuilder(args); // Register your agents builder.AddAIAgent("assistant", "You are a helpful assistant."); +// Register DevUI services +if (builder.Environment.IsDevelopment()) +{ + builder.AddDevUI(); +} + // Register services for OpenAI responses and conversations (also required for DevUI) -builder.Services.AddOpenAIResponses(); -builder.Services.AddOpenAIConversations(); +builder.AddOpenAIResponses(); +builder.AddOpenAIConversations(); var app = builder.Build(); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs new file mode 100644 index 0000000000..6971e3d2e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for to configure DevUI. +/// +public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions +{ + /// + /// Adds services required for DevUI integration. + /// + /// The to configure. + /// The for method chaining. + public static IServiceCollection AddDevUI(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // a factory that tries to construct an AIAgent from Workflow, + // even if workflow was not explicitly registered as an AIAgent. + +#pragma warning disable IDE0001 // Simplify Names + services.AddKeyedSingleton(KeyedService.AnyKey, (sp, key) => + { + var keyAsStr = key as string; + Throw.IfNullOrEmpty(keyAsStr); + + var workflow = sp.GetKeyedService(keyAsStr); + if (workflow is not null) + { + return workflow.AsAgent(name: workflow.Name); + } + + // another thing we can do is resolve a non-keyed workflow. + // however, we can't rely on anything than key to be equal to the workflow.Name. + // so we try: if we fail, we return null. + workflow = sp.GetService(); + if (workflow is not null && workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true) + { + return workflow.AsAgent(name: workflow.Name); + } + + // and it's possible to lookup at the default-registered AIAgent + // with the condition of same name as the key. + var agent = sp.GetService(); + if (agent is not null && agent.Name?.Equals(keyAsStr, StringComparison.Ordinal) == true) + { + return agent; + } + + return null!; + }); +#pragma warning restore IDE0001 // Simplify Names + + return services; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs index f90e47b070..01e7c60137 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -63,7 +63,11 @@ public HostedAgentResponseExecutor( return ValueTask.FromResult(new ResponseError { Code = "agent_not_found", - Message = $"Agent '{agentName}' not found. Ensure the agent is registered with AddAIAgent()." + Message = $""" + Agent '{agentName}' not found. + Ensure the agent is registered with '{agentName}' name in the dependency injection container. + We recommend using 'builder.AddAIAgent()' for simplicity. + """ }); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs deleted file mode 100644 index 0d2ef69640..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Provides a catalog of registered AI agents within the hosting environment. -/// -/// -/// The agent catalog allows enumeration of all registered agents in the dependency injection container. -/// This is useful for scenarios where you need to discover and interact with multiple agents programmatically. -/// -public abstract class AgentCatalog -{ - /// - /// Initializes a new instance of the class. - /// - protected AgentCatalog() - { - } - - /// - /// Asynchronously retrieves all registered AI agents from the catalog. - /// - /// The to monitor for cancellation requests. The default is . - /// - /// An asynchronous enumerable of instances representing all registered agents. - /// The enumeration will only include agents that are successfully resolved from the service provider. - /// - /// - /// This method enumerates through all registered agent names and attempts to resolve each agent - /// from the dependency injection container. Only successfully resolved agents are yielded. - /// The enumeration is lazy and agents are resolved on-demand during iteration. - /// - public abstract IAsyncEnumerable GetAgentsAsync(CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs index d958fc3578..e12d017343 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentHostingServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -126,31 +125,9 @@ public static IHostedAgentBuilder AddAIAgent(this IServiceCollection services, s return agent; }); - // Register the agent by name for discovery. - var agentHostBuilder = GetAgentRegistry(services); - agentHostBuilder.AgentNames.Add(name); - return new HostedAgentBuilder(name, services); } - private static LocalAgentRegistry GetAgentRegistry(IServiceCollection services) - { - var descriptor = services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalAgentRegistry))); - if (descriptor?.ImplementationInstance is not LocalAgentRegistry instance) - { - instance = new LocalAgentRegistry(); - ConfigureHostBuilder(services, instance); - } - - return instance; - } - - private static void ConfigureHostBuilder(IServiceCollection services, LocalAgentRegistry agentHostBuilderContext) - { - services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); - services.AddSingleton(); - } - private static IList GetRegisteredToolsForAgent(IServiceProvider serviceProvider, string agentName) { var registry = serviceProvider.GetService(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs index 2215a52a69..8075caec59 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Linq; -using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -47,28 +45,6 @@ public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder bu return workflow; }); - // Register the workflow by name for discovery. - var workflowRegistry = GetWorkflowRegistry(builder); - workflowRegistry.WorkflowNames.Add(name); - return new HostedWorkflowBuilder(name, builder); } - - private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder) - { - var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry))); - if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance) - { - instance = new LocalWorkflowRegistry(); - ConfigureHostBuilder(builder, instance); - } - - return instance; - } - - private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext) - { - builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); - builder.Services.AddSingleton(); - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs deleted file mode 100644 index 0b44ad60cb..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.Local; - -// Implementation of an AgentCatalog which enumerates agents registered in the local service provider. -internal sealed class LocalAgentCatalog : AgentCatalog -{ - public readonly HashSet _registeredAgents; - private readonly IServiceProvider _serviceProvider; - - public LocalAgentCatalog(LocalAgentRegistry agentHostBuilder, IServiceProvider serviceProvider) - { - this._registeredAgents = [.. agentHostBuilder.AgentNames]; - this._serviceProvider = serviceProvider; - } - - public override async IAsyncEnumerable GetAgentsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask.ConfigureAwait(false); - - foreach (var name in this._registeredAgents) - { - var agent = this._serviceProvider.GetKeyedService(name); - if (agent is not null) - { - yield return agent; - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs deleted file mode 100644 index df3db8f554..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Local; - -internal sealed class LocalAgentRegistry -{ - public HashSet AgentNames { get; } = []; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs deleted file mode 100644 index 572b41830e..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.Local; - -internal sealed class LocalWorkflowCatalog : WorkflowCatalog -{ - public readonly HashSet _registeredWorkflows; - private readonly IServiceProvider _serviceProvider; - - public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider) - { - this._registeredWorkflows = [.. workflowRegistry.WorkflowNames]; - this._serviceProvider = serviceProvider; - } - - public override async IAsyncEnumerable GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.CompletedTask.ConfigureAwait(false); - - foreach (var name in this._registeredWorkflows) - { - var workflow = this._serviceProvider.GetKeyedService(name); - if (workflow is not null) - { - yield return workflow; - } - } - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs deleted file mode 100644 index 803c24660f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Local; - -internal sealed class LocalWorkflowRegistry -{ - public HashSet WorkflowNames { get; } = []; -} diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs new file mode 100644 index 0000000000..d002068626 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.DevUI.UnitTests; + +/// +/// Unit tests for DevUI service collection extensions. +/// Tests verify that workflows and agents can be resolved even when registered non-conventionally. +/// +public class DevUIExtensionsTests +{ + /// + /// Verifies that AddDevUI throws ArgumentNullException when services collection is null. + /// + [Fact] + public void AddDevUI_NullServices_ThrowsArgumentNullException() + { + IServiceCollection services = null!; + Assert.Throws(() => services.AddDevUI()); + } + + /// + /// Verifies that GetRequiredKeyedService throws for non-existent keys. + /// + [Fact] + public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + var serviceProvider = services.BuildServiceProvider(); + + // Act & Assert + Assert.Throws(() => serviceProvider.GetRequiredKeyedService("non-existent")); + } + + /// + /// Verifies that an agent with null name can be resolved by its workflow. + /// + [Fact] + public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null); + var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2); + + services.AddKeyedSingleton("workflow", workflow); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService("workflow"); + + // Assert + Assert.NotNull(resolvedWorkflowAsAgent); + Assert.Null(resolvedWorkflowAsAgent.Name); + } + + /// + /// Verifies that an agent with null name can be resolved by its workflow. + /// + [Fact] + public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent() + { + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null); + var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1, agent2); + var workflow2 = AgentWorkflowBuilder.BuildSequential(agent1, agent2); + + services.AddKeyedSingleton("workflow1", workflow1); + services.AddKeyedSingleton("workflow2", workflow2); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + var resolvedWorkflow1AsAgent = serviceProvider.GetKeyedService("workflow1"); + Assert.NotNull(resolvedWorkflow1AsAgent); + Assert.Null(resolvedWorkflow1AsAgent.Name); + + var resolvedWorkflow2AsAgent = serviceProvider.GetKeyedService("workflow2"); + Assert.NotNull(resolvedWorkflow2AsAgent); + Assert.Null(resolvedWorkflow2AsAgent.Name); + + Assert.False(resolvedWorkflow1AsAgent == resolvedWorkflow2AsAgent); + } + + /// + /// Verifies that an agent with null name can be resolved by its workflow. + /// + [Fact] + public void AddDevUI_NonKeyedWorkflow_CanBeResolved_AsAIAgent() + { + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null); + var workflow = AgentWorkflowBuilder.BuildSequential(agent1, agent2); + + services.AddKeyedSingleton("workflow", workflow); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + var resolvedWorkflowAsAgent = serviceProvider.GetKeyedService("workflow"); + Assert.NotNull(resolvedWorkflowAsAgent); + Assert.Null(resolvedWorkflowAsAgent.Name); + } + + /// + /// Verifies that an agent with null name can be resolved by its workflow. + /// + [Fact] + public void AddDevUI_NonKeyedWorkflow_PlusKeyedWorkflow_CanBeResolved_AsAIAgent() + { + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test 1", name: null); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test 2", name: null); + var workflow = AgentWorkflowBuilder.BuildSequential("standardname", agent1, agent2); + var keyedWorkflow = AgentWorkflowBuilder.BuildSequential("keyedname", agent1, agent2); + + services.AddSingleton(workflow); + services.AddKeyedSingleton("keyed", keyedWorkflow); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // resolve a workflow with the same name as workflow's name (which is registered without a key) + var standardAgent = serviceProvider.GetKeyedService("standardname"); + Assert.NotNull(standardAgent); + Assert.Equal("standardname", standardAgent.Name); + + var keyedAgent = serviceProvider.GetKeyedService("keyed"); + Assert.NotNull(keyedAgent); + Assert.Equal("keyedname", keyedAgent.Name); + + var nonExisting = serviceProvider.GetKeyedService("random-non-existing!!!"); + Assert.Null(nonExisting); + } + + /// + /// Verifies that an agent registered with a different key than its name can be resolved by key. + /// + [Fact] + public void AddDevUI_AgentRegisteredWithDifferentKey_CanBeResolvedByKey() + { + // Arrange + var services = new ServiceCollection(); + const string AgentName = "actual-agent-name"; + const string RegistrationKey = "different-key"; + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", AgentName); + + services.AddKeyedSingleton(RegistrationKey, agent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedAgent = serviceProvider.GetKeyedService(RegistrationKey); + + // Assert + Assert.NotNull(resolvedAgent); + // The resolved agent should have the agent's name, not the registration key + Assert.Equal(AgentName, resolvedAgent.Name); + } + + /// + /// Verifies that an agent registered with a different key than its name can be resolved by key. + /// + [Fact] + public void AddDevUI_Keyed_AndStandard_BothCanBeResolved() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var defaultAgent = new ChatClientAgent(mockChatClient.Object, "default", "default"); + var keyedAgent = new ChatClientAgent(mockChatClient.Object, "keyed", "keyed"); + + services.AddSingleton(defaultAgent); + services.AddKeyedSingleton("keyed-registration", keyedAgent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + var resolvedKeyedAgent = serviceProvider.GetKeyedService("keyed-registration"); + Assert.NotNull(resolvedKeyedAgent); + Assert.Equal("keyed", resolvedKeyedAgent.Name); + + // resolving default agent based on its name, not on the registration-key + var resolvedDefaultAgent = serviceProvider.GetKeyedService("default"); + Assert.NotNull(resolvedDefaultAgent); + Assert.Equal("default", resolvedDefaultAgent.Name); + } + + /// + /// Verifies that the DevUI fallback handler error message includes helpful information. + /// + [Fact] + public void AddDevUI_InvalidResolution_ErrorMessageIsInformative() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + var serviceProvider = services.BuildServiceProvider(); + const string InvalidKey = "invalid-key-name"; + + // Act & Assert + var exception = Assert.Throws(() => serviceProvider.GetRequiredKeyedService(InvalidKey)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs new file mode 100644 index 0000000000..b8512a856e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.DevUI.Entities; +using Microsoft.Agents.AI.Workflows; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.DevUI.UnitTests; + +public class DevUIIntegrationTests +{ + private sealed class NoOpExecutor(string id) : Executor(id) + { + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler( + (msg, ctx) => ctx.SendMessageAsync(msg)); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name"); + + builder.Services.AddKeyedSingleton("registration-key", agent); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var resolvedAgent = app.Services.GetKeyedService("registration-key"); + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(discoveryResponse); + Assert.Single(discoveryResponse.Entities); + Assert.Equal("agent-name", discoveryResponse.Entities[0].Name); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesMultipleAIAgents_ByKeyAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-one"); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-two"); + var agent3 = new ChatClientAgent(mockChatClient.Object, "Test", "agent-three"); + + builder.Services.AddKeyedSingleton("key-1", agent1); + builder.Services.AddKeyedSingleton("key-2", agent2); + builder.Services.AddKeyedSingleton("key-3", agent3); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(discoveryResponse); + Assert.Equal(3, discoveryResponse.Entities.Count); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-one" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-two" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "agent-three" && e.Type == "agent"); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesAIAgents_WithKeyedAndDefaultRegistrationAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockChatClient = new Mock(); + var agentKeyed1 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-one"); + var agentKeyed2 = new ChatClientAgent(mockChatClient.Object, "Test", "keyed-agent-two"); + var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-agent"); + + builder.Services.AddKeyedSingleton("key-1", agentKeyed1); + builder.Services.AddKeyedSingleton("key-2", agentKeyed2); + builder.Services.AddSingleton(agentDefault); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(discoveryResponse); + Assert.Equal(3, discoveryResponse.Entities.Count); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-one" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-agent-two" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-agent" && e.Type == "agent"); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesMultipleWorkflows_ByKeyAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var workflow1 = new WorkflowBuilder("executor-1") + .WithName("workflow-one") + .WithDescription("First workflow") + .BindExecutor(new NoOpExecutor("executor-1")) + .Build(); + + var workflow2 = new WorkflowBuilder("executor-2") + .WithName("workflow-two") + .WithDescription("Second workflow") + .BindExecutor(new NoOpExecutor("executor-2")) + .Build(); + + var workflow3 = new WorkflowBuilder("executor-3") + .WithName("workflow-three") + .WithDescription("Third workflow") + .BindExecutor(new NoOpExecutor("executor-3")) + .Build(); + + builder.Services.AddKeyedSingleton("key-1", workflow1); + builder.Services.AddKeyedSingleton("key-2", workflow2); + builder.Services.AddKeyedSingleton("key-3", workflow3); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(discoveryResponse); + Assert.Equal(3, discoveryResponse.Entities.Count); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-one" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-two" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "workflow-three" && e.Type == "workflow"); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegistrationAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var workflowKeyed1 = new WorkflowBuilder("executor-1") + .WithName("keyed-workflow-one") + .BindExecutor(new NoOpExecutor("executor-1")) + .Build(); + + var workflowKeyed2 = new WorkflowBuilder("executor-2") + .WithName("keyed-workflow-two") + .BindExecutor(new NoOpExecutor("executor-2")) + .Build(); + + var workflowDefault = new WorkflowBuilder("executor-default") + .WithName("default-workflow") + .BindExecutor(new NoOpExecutor("executor-default")) + .Build(); + + builder.Services.AddKeyedSingleton("key-1", workflowKeyed1); + builder.Services.AddKeyedSingleton("key-2", workflowKeyed2); + builder.Services.AddSingleton(workflowDefault); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(discoveryResponse); + Assert.Equal(3, discoveryResponse.Entities.Count); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-one" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "keyed-workflow-two" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-workflow" && e.Type == "workflow"); + } + + [Fact] + public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistrationsAsync() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockChatClient = new Mock(); + + // Create AIAgents + var agent1 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-one"); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Test", "mixed-agent-two"); + var agentDefault = new ChatClientAgent(mockChatClient.Object, "Test", "default-mixed-agent"); + + // Create Workflows + var workflow1 = new WorkflowBuilder("executor-1") + .WithName("mixed-workflow-one") + .BindExecutor(new NoOpExecutor("executor-1")) + .Build(); + + var workflow2 = new WorkflowBuilder("executor-2") + .WithName("mixed-workflow-two") + .BindExecutor(new NoOpExecutor("executor-2")) + .Build(); + + var workflowDefault = new WorkflowBuilder("executor-default") + .WithName("default-mixed-workflow") + .BindExecutor(new NoOpExecutor("executor-default")) + .Build(); + + // Register all + builder.Services.AddKeyedSingleton("agent-key-1", agent1); + builder.Services.AddKeyedSingleton("agent-key-2", agent2); + builder.Services.AddSingleton(agentDefault); + builder.Services.AddKeyedSingleton("workflow-key-1", workflow1); + builder.Services.AddKeyedSingleton("workflow-key-2", workflow2); + builder.Services.AddSingleton(workflowDefault); + builder.Services.AddDevUI(); + + using WebApplication app = builder.Build(); + app.MapDevUI(); + + await app.StartAsync(); + + // Act + var client = app.GetTestClient(); + var response = await client.GetAsync(new Uri("/v1/entities", uriKind: UriKind.Relative)); + + var discoveryResponse = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(discoveryResponse); + Assert.Equal(6, discoveryResponse.Entities.Count); + + // Verify agents + Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-one" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-agent-two" && e.Type == "agent"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-agent" && e.Type == "agent"); + + // Verify workflows + Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-one" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "mixed-workflow-two" && e.Type == "workflow"); + Assert.Contains(discoveryResponse.Entities, e => e.Name == "default-mixed-workflow" && e.Type == "workflow"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj new file mode 100644 index 0000000000..9135a90e2e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj @@ -0,0 +1,20 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + false + $(NoWarn);CA1812 + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json new file mode 100644 index 0000000000..783215ce29 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Microsoft.Agents.AI.DevUI.UnitTests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:63009;http://localhost:63010" + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json new file mode 100644 index 0000000000..6b8f8d04a4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Microsoft.Agents.AI.Hosting.A2A.UnitTests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:52186;http://localhost:52187" + } + } +} \ No newline at end of file