From 595329cb57c892fee20b780a05278f10102ffd7c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 16:12:24 +0100 Subject: [PATCH 01/16] wip --- .../ActorFrameworkWebApplicationExtensions.cs | 30 ++++----- .../AgentWebChat.AgentHost.csproj | 1 + .../AgentWebChat.AgentHost/Program.cs | 48 +++++++++++++- .../AgentWebChat.AppHost/Program.cs | 4 +- .../EntitiesApiExtensions.cs | 65 +++++++++---------- .../ServiceCollections.cs | 44 +++++++++++++ .../Responses/HostedAgentResponseExecutor.cs | 6 +- .../AgentCatalog.cs | 38 ----------- ...AgentHostingServiceCollectionExtensions.cs | 23 ------- ...ostApplicationBuilderWorkflowExtensions.cs | 24 ------- .../Local/LocalAgentCatalog.cs | 37 ----------- .../Local/LocalAgentRegistry.cs | 10 --- .../Local/LocalWorkflowCatalog.cs | 37 ----------- .../Local/LocalWorkflowRegistry.cs | 10 --- .../Properties/launchSettings.json | 12 ++++ 15 files changed, 157 insertions(+), 232 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/AgentCatalog.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Properties/launchSettings.json 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..04ec88fe49 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,12 @@ // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); +builder.Services.AddDevUI(); + +// Add OpenAI services +builder.AddOpenAIChatCompletions(); +builder.AddOpenAIResponses(); + var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", @@ -95,8 +102,42 @@ 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.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 +159,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..92935797d9 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 = ResolveRegisteredAgents(endpoints.ServiceProvider); + var registeredWorkflows = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); + 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,15 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow) StartExecutorId = workflow.StartExecutorId }; } + + private static IEnumerable ResolveRegisteredAgents(IServiceProvider serviceProvider) + { + var agentsProvider = serviceProvider.GetService(); + if (agentsProvider != null) + { + return agentsProvider.ResolveAgents(); + } + + return serviceProvider.GetKeyedServices(KeyedService.AnyKey); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs new file mode 100644 index 0000000000..5866a1f7e6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollections +{ + public static void AddDevUI(this IServiceCollection services) + { + services.AddSingleton(sp => + { + return new(sp, services); + }); + } +} + +internal class RegisteredAgentsProvider +{ + private readonly IServiceProvider _serviceProvider; + private readonly IServiceCollection _services; + + public RegisteredAgentsProvider(IServiceProvider serviceProvider, IServiceCollection services) + { + this._serviceProvider = serviceProvider; + this._services = services; + } + + public List ResolveAgents() + { + var agentsMap = this._serviceProvider.GetKeyedServices(KeyedService.AnyKey) + .ToDictionary(x => x.DisplayName); + + var workflows = this._serviceProvider.GetKeyedServices(KeyedService.AnyKey); + var workflowsAsAgents = workflows.Select(x => x.AsAgent(name: x.Name)); + foreach (var workflowAsAgent in workflowsAsAgents.Where(w => w.Name is not null)) + { + agentsMap.TryAdd(workflowAsAgent.Name!, workflowAsAgent); + } + + return agentsMap.Values.ToList(); + } +} 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..8b2cb33ca0 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.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 From d5ab68fd5680137469467a73b0293bda9b18d27e Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 16:46:12 +0100 Subject: [PATCH 02/16] resolve non-agent workflows as well! --- .../AgentWebChat.AgentHost/Program.cs | 3 +- .../EntitiesApiExtensions.cs | 13 +----- .../HostApplicationBuilderExtensions.cs | 23 ++++++++++ .../Microsoft.Agents.AI.DevUI.csproj | 4 ++ .../src/Microsoft.Agents.AI.DevUI/README.md | 10 ++++- .../ServiceCollections.cs | 44 ------------------- .../ServiceCollectionsExtensions.cs | 37 ++++++++++++++++ 7 files changed, 75 insertions(+), 59 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 04ec88fe49..7eaa8423ce 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -22,7 +22,8 @@ // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); -builder.Services.AddDevUI(); +// Add DevUI services +builder.AddDevUI(); // Add OpenAI services builder.AddOpenAIChatCompletions(); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index 92935797d9..4ad688dbaa 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -28,7 +28,7 @@ internal static class EntitiesApiExtensions /// public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints) { - var registeredAIAgents = ResolveRegisteredAgents(endpoints.ServiceProvider); + var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); var registeredWorkflows = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); var group = endpoints.MapGroup("/v1/entities") @@ -290,15 +290,4 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow) StartExecutorId = workflow.StartExecutorId }; } - - private static IEnumerable ResolveRegisteredAgents(IServiceProvider serviceProvider) - { - var agentsProvider = serviceProvider.GetService(); - if (agentsProvider != null) - { - return agentsProvider.ResolveAgents(); - } - - return serviceProvider.GetKeyedServices(KeyedService.AnyKey); - } } 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..62045fbeea 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 @@ -12,6 +12,10 @@ $(NoWarn);CS1591;CA1852;CA1050;RCS1037;RCS1036;RCS1124;RCS1021;RCS1146;RCS1211;CA2007;CA1308;IL2026;IL3050;CA1812 + + true + + diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md index b55869748d..35929676bb 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 DevIU 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/ServiceCollections.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs deleted file mode 100644 index 5866a1f7e6..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollections.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; - -namespace Microsoft.Extensions.DependencyInjection; - -public static class ServiceCollections -{ - public static void AddDevUI(this IServiceCollection services) - { - services.AddSingleton(sp => - { - return new(sp, services); - }); - } -} - -internal class RegisteredAgentsProvider -{ - private readonly IServiceProvider _serviceProvider; - private readonly IServiceCollection _services; - - public RegisteredAgentsProvider(IServiceProvider serviceProvider, IServiceCollection services) - { - this._serviceProvider = serviceProvider; - this._services = services; - } - - public List ResolveAgents() - { - var agentsMap = this._serviceProvider.GetKeyedServices(KeyedService.AnyKey) - .ToDictionary(x => x.DisplayName); - - var workflows = this._serviceProvider.GetKeyedServices(KeyedService.AnyKey); - var workflowsAsAgents = workflows.Select(x => x.AsAgent(name: x.Name)); - foreach (var workflowAsAgent in workflowsAsAgents.Where(w => w.Name is not null)) - { - agentsMap.TryAdd(workflowAsAgent.Name!, workflowAsAgent); - } - - return agentsMap.Values.ToList(); - } -} 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..050e369820 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -0,0 +1,37 @@ +// 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 void AddDevUI(this IServiceCollection services) + { + // a factory, that tries to construct an AIAgent from Workflow, + // even if workflow was not explicitly registered as an AIAgent. + services.AddKeyedSingleton(KeyedService.AnyKey, (sp, key) => + { + var keyAsStr = key as string; + Throw.IfNullOrEmpty(keyAsStr); + + var workflow = sp.GetKeyedService(keyAsStr); + if (workflow is null) + { + throw new InvalidOperationException($"Can't find registered {nameof(AIAgent)} or {nameof(Workflow)} with name '{keyAsStr}'"); + } + + return workflow.AsAgent(name: workflow.Name); + }); + } +} From 153118230c893e46822a1b264e36599fecff1c38 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 17:25:33 +0100 Subject: [PATCH 03/16] add tests for devui registrations and resolving --- dotnet/agent-framework-dotnet.slnx | 3 +- .../ServiceCollectionsExtensions.cs | 3 +- .../DevUIExtensionsTests.cs | 315 ++++++++++++++++++ ...Microsoft.Agents.AI.DevUI.UnitTests.csproj | 22 ++ 4 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c7af228299..4093ac2d85 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -172,8 +172,8 @@ - + @@ -333,6 +333,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index 050e369820..8a6fb231dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Shared.Diagnostics; @@ -28,7 +27,7 @@ public static void AddDevUI(this IServiceCollection services) var workflow = sp.GetKeyedService(keyAsStr); if (workflow is null) { - throw new InvalidOperationException($"Can't find registered {nameof(AIAgent)} or {nameof(Workflow)} with name '{keyAsStr}'"); + return null!; } return workflow.AsAgent(name: workflow.Name); 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..6ab49b2ddb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -0,0 +1,315 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +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 a directly registered AIAgent is resolved correctly. + /// + [Fact] + public void AddDevUI_DirectlyRegisteredAgent_CanBeResolved() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var directAgent = new ChatClientAgent(mockChatClient.Object, "Direct agent", "direct-agent"); + + services.AddKeyedSingleton("direct-agent", directAgent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedAgent = serviceProvider.GetKeyedService("direct-agent"); + + // Assert + Assert.NotNull(resolvedAgent); + Assert.Same(directAgent, resolvedAgent); + } + + /// + /// Verifies that resolving a non-existent agent/workflow throws InvalidOperationException when using GetRequiredKeyedService. + /// + [Fact] + public void AddDevUI_ResolvingNonExistentEntity_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + var serviceProvider = services.BuildServiceProvider(); + + // Act & Assert + var exception = Assert.Throws(() => serviceProvider.GetRequiredKeyedService("non-existent")); + } + + /// + /// Verifies that GetKeyedService returns null for non-matching keys (no exception). + /// + [Fact] + public void AddDevUI_GetKeyedServiceNonExistent_ReturnsNull() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var result = serviceProvider.GetKeyedService("non-existent"); + + // Assert + Assert.Null(result); + } + + /// + /// 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 AddDevUI can be called multiple times without issues. + /// + [Fact] + public void AddDevUI_CalledMultipleTimes_StillWorks() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + services.AddDevUI(); + + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); + services.AddKeyedSingleton("test-agent", agent); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedAgent = serviceProvider.GetKeyedService("test-agent"); + + // Assert + Assert.NotNull(resolvedAgent); + } + + /// + /// Verifies that directly registered agents with special characters in names can be resolved. + /// + [Theory] + [InlineData("agent_name")] + [InlineData("agent-name")] + [InlineData("agent.name")] + [InlineData("agent:name")] + [InlineData("my_agent-name.v1:test")] + public void AddDevUI_AgentWithSpecialCharactersInName_CanBeResolved(string agentName) + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", agentName); + services.AddKeyedSingleton(agentName, agent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedAgent = serviceProvider.GetKeyedService(agentName); + + // Assert + Assert.NotNull(resolvedAgent); + Assert.Equal(agentName, resolvedAgent.Name); + } + + /// + /// Verifies that the same agent instance can be resolved multiple times. + /// + [Fact] + public void AddDevUI_ResolvingSameAgentMultipleTimes_ReturnsSameInstance() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); + services.AddKeyedSingleton("test-agent", agent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var agent1 = serviceProvider.GetKeyedService("test-agent"); + var agent2 = serviceProvider.GetKeyedService("test-agent"); + + // Assert + Assert.NotNull(agent1); + Assert.NotNull(agent2); + // Should return the same singleton instance + Assert.Same(agent1, agent2); + } + + /// + /// Verifies that multiple directly registered agents can coexist and be resolved. + /// + [Fact] + public void AddDevUI_MultipleDirectAgents_CanAllBeResolved() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent1 = new ChatClientAgent(mockChatClient.Object, "Agent 1", "agent-1"); + var agent2 = new ChatClientAgent(mockChatClient.Object, "Agent 2", "agent-2"); + var agent3 = new ChatClientAgent(mockChatClient.Object, "Agent 3", "agent-3"); + + services.AddKeyedSingleton("agent-1", agent1); + services.AddKeyedSingleton("agent-2", agent2); + services.AddKeyedSingleton("agent-3", agent3); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolved1 = serviceProvider.GetKeyedService("agent-1"); + var resolved2 = serviceProvider.GetKeyedService("agent-2"); + var resolved3 = serviceProvider.GetKeyedService("agent-3"); + + // Assert + Assert.NotNull(resolved1); + Assert.NotNull(resolved2); + Assert.NotNull(resolved3); + Assert.Same(agent1, resolved1); + Assert.Same(agent2, resolved2); + Assert.Same(agent3, resolved3); + } + + /// + /// Verifies that an agent with null name can be resolved by its key. + /// + [Fact] + public void AddDevUI_AgentWithNullName_CanBeResolved() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", name: null); + + services.AddKeyedSingleton("null-name-agent", agent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var resolvedAgent = serviceProvider.GetKeyedService("null-name-agent"); + + // Assert + Assert.NotNull(resolvedAgent); + Assert.Null(resolvedAgent.Name); + } + + /// + /// 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 trying to resolve with null key throws appropriate exception. + /// + [Fact] + public void AddDevUI_ResolveWithNullKey_ReturnsNull() + { + // Arrange + var services = new ServiceCollection(); + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); + services.AddKeyedSingleton("test-agent", agent); + services.AddDevUI(); + + var serviceProvider = services.BuildServiceProvider(); + + // Act & Assert + var result = serviceProvider.GetKeyedService(null!); + Assert.Null(result); + } + + /// + /// Verifies that agent services are registered as keyed singletons. + /// + [Fact] + public void AddDevUI_RegisteredServices_IncludeKeyedSingletons() + { + // Arrange + var services = new ServiceCollection(); + services.AddDevUI(); + + // Assert - Verify that a KeyedService.AnyKey fallback is registered + var fallbackDescriptors = services.Where(d => + d.ServiceKey == KeyedService.AnyKey && + d.ServiceType == typeof(AIAgent)); + + Assert.Single(fallbackDescriptors); + } + + /// + /// 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/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..32ffa40c14 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj @@ -0,0 +1,22 @@ + + + + $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) + false + $(NoWarn);CA1812 + + + + + + + + + + + + + + + From 54d7e9954e91f03c3e8f19aa68746d8f66d63d92 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 17:29:43 +0100 Subject: [PATCH 04/16] fixes --- dotnet/src/Microsoft.Agents.AI.DevUI/README.md | 2 +- .../ServiceCollectionsExtensions.cs | 8 ++++++-- .../Responses/HostedAgentResponseExecutor.cs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md index 35929676bb..104c43729b 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md @@ -24,7 +24,7 @@ var builder = WebApplication.CreateBuilder(args); // Register your agents builder.AddAIAgent("assistant", "You are a helpful assistant."); -// Register DevIU services +// Register DevUI services if (builder.Environment.IsDevelopment()) { builder.AddDevUI(); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index 8a6fb231dc..b57802fdee 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -15,9 +15,11 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions /// /// The to configure. /// The for method chaining. - public static void AddDevUI(this IServiceCollection services) + public static IServiceCollection AddDevUI(this IServiceCollection services) { - // a factory, that tries to construct an AIAgent from Workflow, + ArgumentNullException.ThrowIfNull(services); + + // a factory that tries to construct an AIAgent from Workflow, // even if workflow was not explicitly registered as an AIAgent. services.AddKeyedSingleton(KeyedService.AnyKey, (sp, key) => { @@ -32,5 +34,7 @@ public static void AddDevUI(this IServiceCollection services) return workflow.AsAgent(name: workflow.Name); }); + + 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 8b2cb33ca0..01e7c60137 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/HostedAgentResponseExecutor.cs @@ -66,7 +66,7 @@ public HostedAgentResponseExecutor( 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. + We recommend using 'builder.AddAIAgent()' for simplicity. """ }); } From 6118593b7353cec246b7db8381869c0239c1defc Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 17:36:19 +0100 Subject: [PATCH 05/16] devui for net8 as well! --- .../Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 62045fbeea..122c94066b 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 From 8a8dbd80ef855c9fc83c44801f63313f94ce3ee6 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 17:59:40 +0100 Subject: [PATCH 06/16] simplify TFM --- .../Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj | 1 - .../Microsoft.Agents.AI.DevUI.UnitTests.csproj | 1 - 2 files changed, 2 deletions(-) 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 122c94066b..2e5e560074 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 @@ -2,7 +2,6 @@ $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) enable enable Microsoft.Agents.AI.DevUI 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 index 32ffa40c14..cb6f37bba8 100644 --- 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 @@ -2,7 +2,6 @@ $(ProjectsCoreTargetFrameworks) - $(ProjectsDebugCoreTargetFrameworks) false $(NoWarn);CA1812 From 29b632c71ccc31d1da93d8d3d66519c7b2535bb6 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 18:01:25 +0100 Subject: [PATCH 07/16] update tfm... --- .../Microsoft.Agents.AI.DevUI.UnitTests.csproj | 1 + 1 file changed, 1 insertion(+) 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 index cb6f37bba8..32ffa40c14 100644 --- 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 @@ -2,6 +2,7 @@ $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) false $(NoWarn);CA1812 From 1acd676faa06e1fbbb3d1c0f123ab6bbc2bc1756 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Fri, 14 Nov 2025 18:04:55 +0100 Subject: [PATCH 08/16] tfm rules.... --- .../Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj | 1 + 1 file changed, 1 insertion(+) 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 2e5e560074..122c94066b 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 @@ -2,6 +2,7 @@ $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) enable enable Microsoft.Agents.AI.DevUI From c2bdf4c33fbda1d263599ba3afd3d9a4ddc19806 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Sun, 16 Nov 2025 19:24:53 +0100 Subject: [PATCH 09/16] wip --- dotnet/agent-framework-dotnet.slnx | 4 +- .../EntitiesApiExtensions.cs | 6 ++- .../Microsoft.Agents.AI.DevUI.csproj | 3 ++ .../DevUIExtensionsTests.cs | 27 +++++++++++ .../DevUIIntegrationTests.cs | 46 +++++++++++++++++++ ...Microsoft.Agents.AI.DevUI.UnitTests.csproj | 2 - .../Properties/launchSettings.json | 12 +++++ 7 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/Properties/launchSettings.json diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 4093ac2d85..41e5ceed67 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -158,7 +158,7 @@ - + @@ -333,7 +333,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index 4ad688dbaa..c521de35eb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -28,7 +28,11 @@ internal static class EntitiesApiExtensions /// public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints) { - var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); + var nonKeyedAgent = endpoints.ServiceProvider.GetService(); + var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey) + .Concat(nonKeyedAgent is not null ? [nonKeyedAgent] : []); + + var nonKeyedWorkflow = endpoints.ServiceProvider.GetService(); var registeredWorkflows = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); var group = endpoints.MapGroup("/v1/entities") 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 122c94066b..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 @@ -38,4 +38,7 @@ Provides Microsoft Agent Framework support for developer UI. + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs index 6ab49b2ddb..f169517a22 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -232,6 +233,32 @@ public void AddDevUI_AgentWithNullName_CanBeResolved() Assert.Null(resolvedAgent.Name); } + /// + /// 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 registered with a different key than its name can be resolved by key. /// 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..b4b724aba2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -0,0 +1,46 @@ +// 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.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.DevUI.UnitTests; + +public class DevUIIntegrationTests +{ + [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); + } +} 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 index 32ffa40c14..9135a90e2e 100644 --- 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 @@ -11,8 +11,6 @@ - - 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 From 395a6534839ffd97ea880ed53236ef767bc73802 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Sun, 16 Nov 2025 19:27:06 +0100 Subject: [PATCH 10/16] roll --- .../src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index c521de35eb..4ad688dbaa 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -28,11 +28,7 @@ internal static class EntitiesApiExtensions /// public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints) { - var nonKeyedAgent = endpoints.ServiceProvider.GetService(); - var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey) - .Concat(nonKeyedAgent is not null ? [nonKeyedAgent] : []); - - var nonKeyedWorkflow = endpoints.ServiceProvider.GetService(); + var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); var registeredWorkflows = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); var group = endpoints.MapGroup("/v1/entities") From 48ae51fcfbf67710223772c3b38a3adef3ab5ab9 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Sun, 16 Nov 2025 20:10:01 +0100 Subject: [PATCH 11/16] verify entities are registered with a devui call --- .../EntitiesApiExtensions.cs | 14 +- .../DevUIIntegrationTests.cs | 241 +++++++++++++++++- 2 files changed, 252 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index 4ad688dbaa..a5a8c9408f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -28,8 +28,8 @@ internal static class EntitiesApiExtensions /// public static IEndpointConventionBuilder MapEntities(this IEndpointRouteBuilder endpoints) { - var registeredAIAgents = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); - var registeredWorkflows = endpoints.ServiceProvider.GetKeyedServices(KeyedService.AnyKey); + var registeredAIAgents = GetRegisteredEntities(endpoints.ServiceProvider); + var registeredWorkflows = GetRegisteredEntities(endpoints.ServiceProvider); var group = endpoints.MapGroup("/v1/entities") .WithTags("Entities"); @@ -290,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 defaultEntity = serviceProvider.GetService(); + + return keyedEntities + .Concat(defaultEntity is not null ? [defaultEntity] : []) + .Where(entity => entity is not null); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs index b4b724aba2..4a2dd61982 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -5,8 +5,8 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.DevUI.Entities; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -15,6 +15,13 @@ 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() { @@ -43,4 +50,236 @@ public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync() 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"); + } } From bca1d3278f7fe4bf2eb6f637fc3e97cb988d8287 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Sun, 16 Nov 2025 22:19:07 +0100 Subject: [PATCH 12/16] tests --- .../DevUIExtensionsTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs index f169517a22..9c83ed4728 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -259,6 +259,36 @@ public void AddDevUI_WorkflowWithName_CanBeResolved_AsAIAgent() 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 registered with a different key than its name can be resolved by key. /// From daff5bead88b47e80c408792ab19166e4488c8cb Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Sun, 16 Nov 2025 22:36:49 +0100 Subject: [PATCH 13/16] add a proper support for non-keyed workflows --- .../ServiceCollectionsExtensions.cs | 9 +++- .../DevUIExtensionsTests.cs | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index b57802fdee..17fb335b49 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -29,7 +29,14 @@ public static IServiceCollection AddDevUI(this IServiceCollection services) var workflow = sp.GetKeyedService(keyAsStr); if (workflow is null) { - return null!; + // 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 null || workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == false) + { + return null!; + } } return workflow.AsAgent(name: workflow.Name); diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs index 9c83ed4728..5b3adea16f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -289,6 +289,60 @@ public void AddDevUI_MultipleWorkflowsWithName_CanBeResolved_AsAIAgent() 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. /// From b921889ce9ea0e2696f21735ed85244a55b27417 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 17 Nov 2025 12:04:32 +0100 Subject: [PATCH 14/16] resolve default aiagent registration --- .../AgentWebChat.AgentHost/Program.cs | 6 ++++ .../ServiceCollectionsExtensions.cs | 36 +++++++++++++------ .../DevUIExtensionsTests.cs | 28 +++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 7eaa8423ce..7447c54aa1 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -117,6 +117,12 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te 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"); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index 17fb335b49..6971e3d2e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Shared.Diagnostics; @@ -21,26 +22,39 @@ public static IServiceCollection AddDevUI(this IServiceCollection services) // a factory that tries to construct an AIAgent from Workflow, // even if workflow was not explicitly registered as an AIAgent. - services.AddKeyedSingleton(KeyedService.AnyKey, (sp, key) => + +#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 null) + 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) { - // 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 null || workflow.Name?.Equals(keyAsStr, StringComparison.Ordinal) == false) - { - return null!; - } + return agent; } - return workflow.AsAgent(name: workflow.Name); + return null!; }); +#pragma warning restore IDE0001 // Simplify Names return services; } diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs index 5b3adea16f..a0ea08ef06 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -370,6 +370,34 @@ public void AddDevUI_AgentRegisteredWithDifferentKey_CanBeResolvedByKey() 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 trying to resolve with null key throws appropriate exception. /// From 774e9f2dfa7d308e726928e1740653f013cb2e81 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 17 Nov 2025 12:08:34 +0100 Subject: [PATCH 15/16] sort usings :) --- .../DevUIIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs index 4a2dd61982..b8512a856e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -4,9 +4,9 @@ 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.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Moq; From 16cf9201d70e5063a65246ce92d57b34aeb379c3 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 18 Nov 2025 12:28:09 +0100 Subject: [PATCH 16/16] cleanup tests --- .../EntitiesApiExtensions.cs | 4 +- .../DevUIExtensionsTests.cs | 232 ------------------ 2 files changed, 2 insertions(+), 234 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs index a5a8c9408f..3271b40853 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/EntitiesApiExtensions.cs @@ -294,10 +294,10 @@ private static EntityInfo CreateWorkflowEntityInfo(Workflow workflow) private static IEnumerable GetRegisteredEntities(IServiceProvider serviceProvider) { var keyedEntities = serviceProvider.GetKeyedServices(KeyedService.AnyKey); - var defaultEntity = serviceProvider.GetService(); + var defaultEntities = serviceProvider.GetServices() ?? []; return keyedEntities - .Concat(defaultEntity is not null ? [defaultEntity] : []) + .Concat(defaultEntities) .Where(entity => entity is not null); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs index a0ea08ef06..d002068626 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIExtensionsTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Linq; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -25,63 +24,6 @@ public void AddDevUI_NullServices_ThrowsArgumentNullException() Assert.Throws(() => services.AddDevUI()); } - /// - /// Verifies that a directly registered AIAgent is resolved correctly. - /// - [Fact] - public void AddDevUI_DirectlyRegisteredAgent_CanBeResolved() - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var directAgent = new ChatClientAgent(mockChatClient.Object, "Direct agent", "direct-agent"); - - services.AddKeyedSingleton("direct-agent", directAgent); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var resolvedAgent = serviceProvider.GetKeyedService("direct-agent"); - - // Assert - Assert.NotNull(resolvedAgent); - Assert.Same(directAgent, resolvedAgent); - } - - /// - /// Verifies that resolving a non-existent agent/workflow throws InvalidOperationException when using GetRequiredKeyedService. - /// - [Fact] - public void AddDevUI_ResolvingNonExistentEntity_ThrowsInvalidOperationException() - { - // Arrange - var services = new ServiceCollection(); - services.AddDevUI(); - var serviceProvider = services.BuildServiceProvider(); - - // Act & Assert - var exception = Assert.Throws(() => serviceProvider.GetRequiredKeyedService("non-existent")); - } - - /// - /// Verifies that GetKeyedService returns null for non-matching keys (no exception). - /// - [Fact] - public void AddDevUI_GetKeyedServiceNonExistent_ReturnsNull() - { - // Arrange - var services = new ServiceCollection(); - services.AddDevUI(); - var serviceProvider = services.BuildServiceProvider(); - - // Act - var result = serviceProvider.GetKeyedService("non-existent"); - - // Assert - Assert.Null(result); - } - /// /// Verifies that GetRequiredKeyedService throws for non-existent keys. /// @@ -97,142 +39,6 @@ public void AddDevUI_GetRequiredKeyedServiceNonExistent_ThrowsInvalidOperationEx Assert.Throws(() => serviceProvider.GetRequiredKeyedService("non-existent")); } - /// - /// Verifies that AddDevUI can be called multiple times without issues. - /// - [Fact] - public void AddDevUI_CalledMultipleTimes_StillWorks() - { - // Arrange - var services = new ServiceCollection(); - services.AddDevUI(); - services.AddDevUI(); - - var mockChatClient = new Mock(); - var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); - services.AddKeyedSingleton("test-agent", agent); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var resolvedAgent = serviceProvider.GetKeyedService("test-agent"); - - // Assert - Assert.NotNull(resolvedAgent); - } - - /// - /// Verifies that directly registered agents with special characters in names can be resolved. - /// - [Theory] - [InlineData("agent_name")] - [InlineData("agent-name")] - [InlineData("agent.name")] - [InlineData("agent:name")] - [InlineData("my_agent-name.v1:test")] - public void AddDevUI_AgentWithSpecialCharactersInName_CanBeResolved(string agentName) - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var agent = new ChatClientAgent(mockChatClient.Object, "Test", agentName); - services.AddKeyedSingleton(agentName, agent); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var resolvedAgent = serviceProvider.GetKeyedService(agentName); - - // Assert - Assert.NotNull(resolvedAgent); - Assert.Equal(agentName, resolvedAgent.Name); - } - - /// - /// Verifies that the same agent instance can be resolved multiple times. - /// - [Fact] - public void AddDevUI_ResolvingSameAgentMultipleTimes_ReturnsSameInstance() - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); - services.AddKeyedSingleton("test-agent", agent); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var agent1 = serviceProvider.GetKeyedService("test-agent"); - var agent2 = serviceProvider.GetKeyedService("test-agent"); - - // Assert - Assert.NotNull(agent1); - Assert.NotNull(agent2); - // Should return the same singleton instance - Assert.Same(agent1, agent2); - } - - /// - /// Verifies that multiple directly registered agents can coexist and be resolved. - /// - [Fact] - public void AddDevUI_MultipleDirectAgents_CanAllBeResolved() - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var agent1 = new ChatClientAgent(mockChatClient.Object, "Agent 1", "agent-1"); - var agent2 = new ChatClientAgent(mockChatClient.Object, "Agent 2", "agent-2"); - var agent3 = new ChatClientAgent(mockChatClient.Object, "Agent 3", "agent-3"); - - services.AddKeyedSingleton("agent-1", agent1); - services.AddKeyedSingleton("agent-2", agent2); - services.AddKeyedSingleton("agent-3", agent3); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var resolved1 = serviceProvider.GetKeyedService("agent-1"); - var resolved2 = serviceProvider.GetKeyedService("agent-2"); - var resolved3 = serviceProvider.GetKeyedService("agent-3"); - - // Assert - Assert.NotNull(resolved1); - Assert.NotNull(resolved2); - Assert.NotNull(resolved3); - Assert.Same(agent1, resolved1); - Assert.Same(agent2, resolved2); - Assert.Same(agent3, resolved3); - } - - /// - /// Verifies that an agent with null name can be resolved by its key. - /// - [Fact] - public void AddDevUI_AgentWithNullName_CanBeResolved() - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var agent = new ChatClientAgent(mockChatClient.Object, "Test", name: null); - - services.AddKeyedSingleton("null-name-agent", agent); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act - var resolvedAgent = serviceProvider.GetKeyedService("null-name-agent"); - - // Assert - Assert.NotNull(resolvedAgent); - Assert.Null(resolvedAgent.Name); - } - /// /// Verifies that an agent with null name can be resolved by its workflow. /// @@ -398,44 +204,6 @@ public void AddDevUI_Keyed_AndStandard_BothCanBeResolved() Assert.Equal("default", resolvedDefaultAgent.Name); } - /// - /// Verifies that trying to resolve with null key throws appropriate exception. - /// - [Fact] - public void AddDevUI_ResolveWithNullKey_ReturnsNull() - { - // Arrange - var services = new ServiceCollection(); - var mockChatClient = new Mock(); - var agent = new ChatClientAgent(mockChatClient.Object, "Test", "test-agent"); - services.AddKeyedSingleton("test-agent", agent); - services.AddDevUI(); - - var serviceProvider = services.BuildServiceProvider(); - - // Act & Assert - var result = serviceProvider.GetKeyedService(null!); - Assert.Null(result); - } - - /// - /// Verifies that agent services are registered as keyed singletons. - /// - [Fact] - public void AddDevUI_RegisteredServices_IncludeKeyedSingletons() - { - // Arrange - var services = new ServiceCollection(); - services.AddDevUI(); - - // Assert - Verify that a KeyedService.AnyKey fallback is registered - var fallbackDescriptors = services.Where(d => - d.ServiceKey == KeyedService.AnyKey && - d.ServiceType == typeof(AIAgent)); - - Assert.Single(fallbackDescriptors); - } - /// /// Verifies that the DevUI fallback handler error message includes helpful information. ///