mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/fix_multimodel_sample
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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)));
|
||||
}
|
||||
}
|
||||
+139
@@ -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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user