From 9f0dbe5f8dc0c22861e761a26c4c6ed6e2436581 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:04:59 -0700 Subject: [PATCH] .NET: Improve workflow unit test coverage (#5072) * Improve workflow unit tests * Update test name prefix for clarity. * Update tests to surface any errors. --- .../AggregatingExecutorTests.cs | 77 ++++++++++ .../InputWaiterAndOutputFilterTests.cs | 145 ++++++++++++++++++ .../RoundRobinGroupChatManagerTests.cs | 139 +++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs new file mode 100644 index 0000000000..fac76744e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AggregatingExecutorTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class AggregatingExecutorTests +{ + [Fact] + public async Task AggregatingExecutor_HandleAsync_AggregatesIncrementallyAsync() + { + AggregatingExecutor executor = new("sum", (aggregate, input) => + aggregate == null ? input : $"{aggregate}+{input}"); + + TestWorkflowContext context = new(executor.Id); + + string? result1 = await executor.HandleAsync("a", context, default); + string? result2 = await executor.HandleAsync("b", context, default); + string? result3 = await executor.HandleAsync("c", context, default); + + result1.Should().Be("a"); + result2.Should().Be("a+b"); + result3.Should().Be("a+b+c"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_FirstCallReceivesNullAggregateAsync() + { + string? receivedAggregate = "sentinel"; + + AggregatingExecutor executor = new("first-call", (aggregate, input) => + { + receivedAggregate = aggregate; + return input; + }); + + TestWorkflowContext context = new(executor.Id); + await executor.HandleAsync("hello", context, default); + + receivedAggregate.Should().BeNull("the first invocation should receive a null aggregate for reference types"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_AggregatorReturningNullClearsStateAsync() + { + AggregatingExecutor executor = new("nullable", (aggregate, input) => + input == "clear" ? null : (aggregate ?? "") + input); + + TestWorkflowContext context = new(executor.Id); + + string? result1 = await executor.HandleAsync("a", context, default); + result1.Should().Be("a"); + + string? result2 = await executor.HandleAsync("clear", context, default); + result2.Should().BeNull("the aggregator returned null to clear the state"); + + // After clearing, the next call should receive null aggregate again + string? result3 = await executor.HandleAsync("b", context, default); + result3.Should().Be("b", "the aggregate should restart from null after being cleared"); + } + + [Fact] + public async Task AggregatingExecutor_HandleAsync_PersistsStateBetweenCallsAsync() + { + AggregatingExecutor executor = new("counter", (aggregate, _) => + aggregate == null ? "1" : $"{int.Parse(aggregate) + 1}"); + + TestWorkflowContext context = new(executor.Id); + + for (int i = 1; i <= 5; i++) + { + string? result = await executor.HandleAsync("tick", context, default); + result.Should().Be($"{i}", "the aggregate should increment with each call"); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs new file mode 100644 index 0000000000..77c0160200 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public sealed class InputWaiterTests : IDisposable +{ + private readonly InputWaiter _waiter = new(); + + public void Dispose() + { + this._waiter.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync() + { + this._waiter.SignalInput(); + + // WaitForInputAsync should complete immediately since input was already signaled + Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None); + Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); + + completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout"); + await waitTask; + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync() + { + Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(5)); + + await Task.Delay(50); + waitTask.IsCompleted.Should().BeFalse("the waiter should block until input is signaled"); + + this._waiter.SignalInput(); + + Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1))); + completed.Should().BeSameAs(waitTask, "the wait task should complete after being signaled"); + await waitTask; + } + + [Fact] + public void InputWaiter_SignalInput_DoubleSignalDoesNotThrow() + { + // Binary semaphore behavior: double signal should be idempotent + FluentActions.Invoking(() => + { + this._waiter.SignalInput(); + this._waiter.SignalInput(); + }).Should().NotThrow("double signaling should be handled gracefully"); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_RespectsCancellationAsync() + { + using CancellationTokenSource cts = new(); + Task waitTask = this._waiter.WaitForInputAsync(cts.Token); + + cts.Cancel(); + + Func act = () => waitTask; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_DoesNotCompleteWhenNotSignaledAsync() + { + using CancellationTokenSource cts = new(); + Task waitTask = this._waiter.WaitForInputAsync(cts.Token); + Task completed = await Task.WhenAny(waitTask, Task.Delay(100)); + + completed.Should().NotBeSameAs(waitTask, "the wait task should not complete when input is not signaled"); + + // Cancel and observe the pending task to avoid an unobserved exception on Dispose + cts.Cancel(); + try { await waitTask; } + catch (OperationCanceledException) { } + } + + [Fact] + public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync() + { + // First signal/wait cycle + this._waiter.SignalInput(); + await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + + // Second signal/wait cycle + this._waiter.SignalInput(); + await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1)); + } +} + +public class OutputFilterTests +{ + private static OutputFilter CreateFilterWithOutputFrom(string outputExecutorId) + { + NoOpExecutor start = new("start"); + NoOpExecutor end = new("end"); + + Workflow workflow = new WorkflowBuilder("start") + .AddEdge(start, end) + .WithOutputFrom(outputExecutorId == "end" ? end : start) + .Build(); + + return new OutputFilter(workflow); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsTrueForRegisteredExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("end", "some output").Should().BeTrue("the executor was registered via WithOutputFrom"); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsFalseForUnregisteredExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("start", "some output").Should().BeFalse("start was not registered as an output executor"); + } + + [Fact] + public void OutputFilter_CanOutput_ReturnsFalseForNonExistentExecutor() + { + OutputFilter filter = CreateFilterWithOutputFrom("end"); + + filter.CanOutput("nonexistent", "some output").Should().BeFalse("an executor not in the workflow should not be an output executor"); + } + + private sealed class NoOpExecutor(string id) : Executor(id) + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => + routeBuilder.AddHandler((msg, ctx) => ctx.SendMessageAsync(msg))); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs new file mode 100644 index 0000000000..3c87507ec6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RoundRobinGroupChatManagerTests.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class RoundRobinGroupChatManagerTests +{ + [Fact] + public async Task RoundRobinGroupChat_SelectNextAgent_CyclesInOrderAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + TestEchoAgent agent3 = new(id: "agent3"); + List agents = [agent1, agent2, agent3]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + + AIAgent first = await manager.SelectNextAgentAsync(history); + AIAgent second = await manager.SelectNextAgentAsync(history); + AIAgent third = await manager.SelectNextAgentAsync(history); + + first.Should().BeSameAs(agent1); + second.Should().BeSameAs(agent2); + third.Should().BeSameAs(agent3); + } + + [Fact] + public async Task RoundRobinGroupChat_SelectNextAgent_WrapsAroundAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + List agents = [agent1, agent2]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + + await manager.SelectNextAgentAsync(history); + await manager.SelectNextAgentAsync(history); + + AIAgent wrappedAgent = await manager.SelectNextAgentAsync(history); + + wrappedAgent.Should().BeSameAs(agent1, "the manager should wrap around to the first agent after cycling through all agents"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_DefaultBehaviorTerminatesAtMaxIterationsAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents) { MaximumIterationCount = 3 }; + + manager.IterationCount = 2; + bool shouldTerminateBefore = await manager.ShouldTerminateAsync(history); + shouldTerminateBefore.Should().BeFalse("the iteration count has not yet reached the maximum"); + + manager.IterationCount = 3; + bool shouldTerminateAt = await manager.ShouldTerminateAsync(history); + shouldTerminateAt.Should().BeTrue("the iteration count has reached the maximum"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncTerminatesEarlyAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = [new ChatMessage(ChatRole.Assistant, "done")]; + + RoundRobinGroupChatManager manager = new(agents, + shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done"))) + { + MaximumIterationCount = 100 + }; + + bool shouldTerminate = await manager.ShouldTerminateAsync(history); + shouldTerminate.Should().BeTrue("the custom termination function should cause early termination"); + } + + [Fact] + public async Task RoundRobinGroupChat_ShouldTerminate_CustomFuncDoesNotTerminateWhenNotMetAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + List agents = [agent1]; + List history = [new ChatMessage(ChatRole.Assistant, "continue")]; + + RoundRobinGroupChatManager manager = new(agents, + shouldTerminateFunc: (_, messages, _) => new(messages.Any(m => m.Text == "done"))) + { + MaximumIterationCount = 100 + }; + + bool shouldTerminate = await manager.ShouldTerminateAsync(history); + shouldTerminate.Should().BeFalse("the custom termination function should not cause termination when condition is not met"); + } + + [Fact] + public async Task RoundRobinGroupChat_Reset_ResetsIterationCountAndAgentIndexAsync() + { + TestEchoAgent agent1 = new(id: "agent1"); + TestEchoAgent agent2 = new(id: "agent2"); + List agents = [agent1, agent2]; + List history = []; + + RoundRobinGroupChatManager manager = new(agents); + manager.IterationCount = 5; + + // Advance the internal index past the first agent + await manager.SelectNextAgentAsync(history); + + manager.Reset(); + + manager.IterationCount.Should().Be(0, "Reset should clear the iteration count"); + + AIAgent afterReset = await manager.SelectNextAgentAsync(history); + afterReset.Should().BeSameAs(agent1, "Reset should cause the next selection to start from the first agent"); + } + + [Fact] + public void RoundRobinGroupChat_Constructor_ThrowsOnNullAgents() + { + FluentActions.Invoking(() => new RoundRobinGroupChatManager(null!)) + .Should().Throw() + .WithParameterName("agents"); + } + + [Fact] + public void RoundRobinGroupChat_Constructor_ThrowsOnEmptyAgents() + { + FluentActions.Invoking(() => new RoundRobinGroupChatManager([])) + .Should().Throw(); + } +}