Merge branch 'main' into dev/dotnet_workflow/fix_multimodel_sample

This commit is contained in:
Jacob Alber
2026-04-03 11:19:28 -04:00
committed by GitHub
Unverified
3 changed files with 361 additions and 0 deletions
@@ -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<string, string> 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<string, string> 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<string, string> 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<string, string> 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");
}
}
}
@@ -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<Task> act = () => waitTask;
await act.Should().ThrowAsync<OperationCanceledException>();
}
[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<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
}
@@ -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<AIAgent> agents = [agent1, agent2, agent3];
List<ChatMessage> 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<AIAgent> agents = [agent1, agent2];
List<ChatMessage> 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<AIAgent> agents = [agent1];
List<ChatMessage> 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<AIAgent> agents = [agent1];
List<ChatMessage> 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<AIAgent> agents = [agent1];
List<ChatMessage> 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<AIAgent> agents = [agent1, agent2];
List<ChatMessage> 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<System.ArgumentNullException>()
.WithParameterName("agents");
}
[Fact]
public void RoundRobinGroupChat_Constructor_ThrowsOnEmptyAgents()
{
FluentActions.Invoking(() => new RoundRobinGroupChatManager([]))
.Should().Throw<System.ArgumentException>();
}
}