diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs new file mode 100644 index 0000000000..863ccede63 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.ConcurrentTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +#pragma warning disable SYSLIB1045 // Use GeneratedRegex +#pragma warning disable RCS1186 // Use Regex instance instead of static method + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public static partial class AgentWorkflowBuilderTests +{ + public class ConcurrentTests + { + [Fact] + public void BuildConcurrent_InvalidArguments_Throws() + { + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!)); + } + + [Fact] + public async Task BuildConcurrent_AgentsRunInParallelAsync() + { + StrongBox> barrier = new(); + StrongBox remaining = new(); + + var workflow = AgentWorkflowBuilder.BuildConcurrent( + [ + new DoubleEchoAgentWithBarrier("agent1", barrier, remaining), + new DoubleEchoAgentWithBarrier("agent2", barrier, remaining), + ]); + + for (int iter = 0; iter < 3; iter++) + { + barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + remaining.Value = 2; + + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + Assert.NotEmpty(updateText); + Assert.NotNull(result); + + // TODO: https://github.com/microsoft/agent-framework/issues/784 + // These asserts are flaky until we guarantee message delivery order. + Assert.Single(Regex.Matches(updateText, "agent1")); + Assert.Single(Regex.Matches(updateText, "agent2")); + Assert.Equal(4, Regex.Matches(updateText, "abc").Count); + Assert.Equal(2, result.Count); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs new file mode 100644 index 0000000000..ae467a1352 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilder.SequentialTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public static partial class AgentWorkflowBuilderTests +{ + public class SequentialTests + { + [Fact] + public void BuildSequential_InvalidArguments_Throws() + { + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!)); + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential()); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents) + { + var workflow = AgentWorkflowBuilder.BuildSequential( + from i in Enumerable.Range(1, numAgents) + select new DoubleEchoAgent($"agent{i}")); + + for (int iter = 0; iter < 3; iter++) + { + const string UserInput = "abc"; + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + + Assert.NotNull(result); + Assert.Equal(numAgents + 1, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Null(result[0].AuthorName); + Assert.Equal(UserInput, result[0].Text); + + string[] texts = new string[numAgents + 1]; + texts[0] = UserInput; + string expectedTotal = string.Empty; + for (int i = 1; i < numAgents + 1; i++) + { + string id = $"agent{((i - 1) % numAgents) + 1}"; + texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}"; + Assert.Equal(ChatRole.Assistant, result[i].Role); + Assert.Equal(id, result[i].AuthorName); + Assert.Equal(texts[i], result[i].Text); + expectedTotal += texts[i]; + } + + Assert.Equal(expectedTotal, updateText); + Assert.Equal(UserInput + expectedTotal, string.Concat(result)); + + static string Double(string s) => s + s; + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index d477140373..bb17c8644d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -6,156 +6,26 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; -#pragma warning disable SYSLIB1045 // Use GeneratedRegex -#pragma warning disable RCS1186 // Use Regex instance instead of static method - namespace Microsoft.Agents.AI.Workflows.UnitTests; -public class AgentWorkflowBuilderTests +/// +/// Container for tests covering entry points that +/// do not produce a dedicated builder type (currently BuildSequential and +/// BuildConcurrent). The actual test methods live in nested classes +/// ( and ) split across +/// partial files. Shared test helpers — the DoubleEchoAgent family and the +/// RunWorkflow* methods — are declared on this outer partial as +/// internal so the nested test classes and the standalone +/// can all reuse them. +/// +public static partial class AgentWorkflowBuilderTests { - [Fact] - public void BuildSequential_InvalidArguments_Throws() - { - Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!)); - Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential()); - } - - [Fact] - public void BuildConcurrent_InvalidArguments_Throws() - { - Assert.Throws("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!)); - } - - [Fact] - public void BuildGroupChat_InvalidArguments_Throws() - { - Assert.Throws("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!)); - - var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); - Assert.NotNull(groupChat); - Assert.Throws("agents", () => groupChat.AddParticipants(null!)); - Assert.Throws("agents", () => groupChat.AddParticipants([null!])); - Assert.Throws("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!)); - - Assert.Throws("agents", () => new RoundRobinGroupChatManager(null!)); - } - - [Fact] - public void GroupChatManager_MaximumIterationCount_Invalid_Throws() - { - var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); - - const int DefaultMaxIterations = 40; - Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); - Assert.Throws("value", void () => manager.MaximumIterationCount = 0); - Assert.Throws("value", void () => manager.MaximumIterationCount = -1); - Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); - - manager.MaximumIterationCount = 30; - Assert.Equal(30, manager.MaximumIterationCount); - - manager.MaximumIterationCount = 1; - Assert.Equal(1, manager.MaximumIterationCount); - - manager.MaximumIterationCount = int.MaxValue; - Assert.Equal(int.MaxValue, manager.MaximumIterationCount); - } - - [Fact] - public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription() - { - const string WorkflowName = "Test Group Chat"; - const string WorkflowDescription = "A test group chat workflow"; - - var workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) - .AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2")) - .WithName(WorkflowName) - .WithDescription(WorkflowDescription) - .Build(); - - Assert.Equal(WorkflowName, workflow.Name); - Assert.Equal(WorkflowDescription, workflow.Description); - } - - [Fact] - public void BuildGroupChat_WithNameOnly_SetsWorkflowName() - { - const string WorkflowName = "Named Group Chat"; - - var workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) - .AddParticipants(new DoubleEchoAgent("agent1")) - .WithName(WorkflowName) - .Build(); - - Assert.Equal(WorkflowName, workflow.Name); - Assert.Null(workflow.Description); - } - - [Fact] - public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull() - { - var workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) - .AddParticipants(new DoubleEchoAgent("agent1")) - .Build(); - - Assert.Null(workflow.Name); - Assert.Null(workflow.Description); - } - - [Theory] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] - public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents) - { - var workflow = AgentWorkflowBuilder.BuildSequential( - from i in Enumerable.Range(1, numAgents) - select new DoubleEchoAgent($"agent{i}")); - - for (int iter = 0; iter < 3; iter++) - { - const string UserInput = "abc"; - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); - - Assert.NotNull(result); - Assert.Equal(numAgents + 1, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Null(result[0].AuthorName); - Assert.Equal(UserInput, result[0].Text); - - string[] texts = new string[numAgents + 1]; - texts[0] = UserInput; - string expectedTotal = string.Empty; - for (int i = 1; i < numAgents + 1; i++) - { - string id = $"agent{((i - 1) % numAgents) + 1}"; - texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}"; - Assert.Equal(ChatRole.Assistant, result[i].Role); - Assert.Equal(id, result[i].AuthorName); - Assert.Equal(texts[i], result[i].Text); - expectedTotal += texts[i]; - } - - Assert.Equal(expectedTotal, updateText); - Assert.Equal(UserInput + expectedTotal, string.Concat(result)); - - static string Double(string s) => s + s; - } - } - - private class DoubleEchoAgent(string name) : AIAgent + internal class DoubleEchoAgent(string name) : AIAgent { public override string Name => name; @@ -185,110 +55,31 @@ public class AgentWorkflowBuilderTests } } - private sealed class DoubleEchoAgentSession() : AgentSession(); + internal sealed class DoubleEchoAgentSession() : AgentSession(); - [Fact] - public async Task BuildConcurrent_AgentsRunInParallelAsync() + internal sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) { - StrongBox> barrier = new(); - StrongBox remaining = new(); - - var workflow = AgentWorkflowBuilder.BuildConcurrent( - [ - new DoubleEchoAgentWithBarrier("agent1", barrier, remaining), - new DoubleEchoAgentWithBarrier("agent2", barrier, remaining), - ]); - - for (int iter = 0; iter < 3; iter++) + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - remaining.Value = 2; + if (Interlocked.Decrement(ref remaining.Value) == 0) + { + barrier.Value!.SetResult(true); + } - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - Assert.NotEmpty(updateText); - Assert.NotNull(result); + await barrier.Value!.Task.ConfigureAwait(false); - // TODO: https://github.com/microsoft/agent-framework/issues/784 - // These asserts are flaky until we guarantee message delivery order. - Assert.Single(Regex.Matches(updateText, "agent1")); - Assert.Single(Regex.Matches(updateText, "agent2")); - Assert.Equal(4, Regex.Matches(updateText, "abc").Count); - Assert.Equal(2, result.Count); + await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken)) + { + await Task.Yield(); + yield return update; + } } } - [Theory] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] - public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) - { - const int NumAgents = 3; - var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) - .AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2")) - .AddParticipants(new DoubleEchoAgent("agent3")) - .Build(); + internal sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); - for (int iter = 0; iter < 3; iter++) - { - const string UserInput = "abc"; - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); - - Assert.NotNull(result); - Assert.Equal(maxIterations + 1, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Null(result[0].AuthorName); - Assert.Equal(UserInput, result[0].Text); - - // The group-chat host broadcasts each new message (initial user input + each speaker's - // response) to every participant except the speaker that produced it. The selected - // speaker therefore sees only what's been broadcast to it since its previous turn. - string[] agentIds = ["agent1", "agent2", "agent3"]; - List[] buffers = new List[NumAgents]; - for (int a = 0; a < NumAgents; a++) - { - buffers[a] = [UserInput]; - } - - string[] texts = new string[maxIterations + 1]; - texts[0] = UserInput; - string expectedTotal = string.Empty; - for (int i = 1; i < maxIterations + 1; i++) - { - int speakerIdx = (i - 1) % NumAgents; - string id = agentIds[speakerIdx]; - string concatReceived = string.Concat(buffers[speakerIdx]); - texts[i] = $"{id}{Double(concatReceived)}"; - buffers[speakerIdx].Clear(); - for (int a = 0; a < NumAgents; a++) - { - if (a == speakerIdx) - { - continue; - } - - buffers[a].Add(texts[i]); - } - - Assert.Equal(ChatRole.Assistant, result[i].Role); - Assert.Equal(id, result[i].AuthorName); - Assert.Equal(texts[i], result[i].Text); - expectedTotal += texts[i]; - } - - Assert.Equal(expectedTotal, updateText); - Assert.Equal(UserInput + expectedTotal, string.Concat(result)); - - static string Double(string s) => s + s; - } - } - - private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); - - private static async Task RunWorkflowCheckpointedAsync( + internal static async Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) { await using StreamingRun run = @@ -301,7 +92,7 @@ public class AgentWorkflowBuilderTests return await ProcessWorkflowRunAsync(run); } - private static async Task ProcessWorkflowRunAsync(StreamingRun run) + internal static async Task ProcessWorkflowRunAsync(StreamingRun run) { StringBuilder sb = new(); WorkflowOutputEvent? output = null; @@ -338,280 +129,7 @@ public class AgentWorkflowBuilderTests return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); } - private static Task RunWorkflowAsync( + internal static Task RunWorkflowAsync( Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); - - private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) - { - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - if (Interlocked.Decrement(ref remaining.Value) == 0) - { - barrier.Value!.SetResult(true); - } - - await barrier.Value!.Task.ConfigureAwait(false); - - await foreach (var update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken)) - { - await Task.Yield(); - yield return update; - } - } - } - - private sealed class RecordingAgent(string name) : AIAgent - { - public List> Invocations { get; } = []; - - public override string Name => name; - - protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) - => new(new RecordingAgentSession()); - - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => new(new RecordingAgentSession()); - - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) - => default; - - protected override Task RunCoreAsync( - IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); - - protected override async IAsyncEnumerable RunCoreStreamingAsync( - IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await Task.Yield(); - - this.Invocations.Add(messages.Select(m => m.Text).ToList()); - - string id = Guid.NewGuid().ToString("N"); - yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id }; - } - } - - private sealed class RecordingAgentSession() : AgentSession(); - - [Fact] - public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync() - { - var agentA = new RecordingAgent("agentA"); - var agentB = new RecordingAgent("agentB"); - var agentC = new RecordingAgent("agentC"); - - var workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 }) - .AddParticipants(agentA, agentB, agentC) - .Build(); - - const string UserInput = "hello"; - (_, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); - - Assert.NotNull(result); - Assert.Equal(5, result.Count); // initial user input + 4 agent turns - Assert.Collection( - result, - m => Assert.Equal(UserInput, m.Text), - m => Assert.Equal("agentA", m.Text), - m => Assert.Equal("agentB", m.Text), - m => Assert.Equal("agentC", m.Text), - m => Assert.Equal("agentA", m.Text)); - - // Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts - // confirm only the chosen participant receives a TurnToken on each round. - Assert.Equal(2, agentA.Invocations.Count); - Assert.Single(agentB.Invocations); - Assert.Single(agentC.Invocations); - - // Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so - // agentA's only buffered message is the user input. - Assert.Equal([UserInput], agentA.Invocations[0]); - - // Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of - // agentA's response (agentA itself is excluded as the last speaker). - Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]); - - // Turn 3: agentC. It also received every broadcast so far (it has never been excluded). - Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]); - - // Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but - // received turn-3 (agentB's response) and turn-4 (agentC's response) deltas. - Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]); - } - - [Fact] - public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync() - { - var agentA = new RecordingAgent("agentA"); - var agentB = new RecordingAgent("agentB"); - - var workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 }) - .AddParticipants(agentA, agentB) - .Build(); - - const string UserInput = "hello"; - await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); - - // Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync - // prefixed. - Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]); - - // Turn 2: agentB received both the initial broadcast and agentA's response — both passed - // through UpdateHistoryAsync before being broadcast. - Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]); - } - - [Fact] - public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync() - { - const string UserInput = "hello"; - const int MaxIterations = 6; - - // --- Baseline: run the full conversation under checkpointing and capture every checkpoint - // plus the final transcript. The same workflow + agents are reused for the resume, - // because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. --- - BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations); - - // We need at least one mid-conversation checkpoint to resume from. The baseline produces a - // checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint - // captured roughly midway so the resumed run still has work to do. - Assert.True(baseline.Checkpoints.Count >= 5, - $"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}"); - - int midIndex = baseline.Checkpoints.Count / 2; - CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex]; - - // Snapshot per-agent invocation counts before the resume so we can isolate the invocations - // produced after the checkpoint is restored. - int aPreCount = baseline.AgentA.Invocations.Count; - int bPreCount = baseline.AgentB.Invocations.Count; - int cPreCount = baseline.AgentC.Invocations.Count; - - // --- Resume the same workflow from the mid-conversation checkpoint. --- - List? resumedResult = null; - await using (StreamingRun resumed = await baseline.Environment - .ResumeStreamingAsync(baseline.Workflow, midCheckpoint)) - { - await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false)) - { - if (evt is WorkflowOutputEvent o) - { - resumedResult = o.As>(); - } - else if (evt is WorkflowErrorEvent err) - { - Assert.Fail($"Resumed workflow failed: {err.Exception}"); - } - } - } - - // (1) Iteration-count continuity: the resumed run terminates with exactly the same number - // of turns the baseline produced — proves IterationCount was rehydrated and the manager - // honored MaximumIterationCount across the boundary. - Assert.NotNull(resumedResult); - Assert.Equal(baseline.Result.Count, resumedResult!.Count); - - // (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn, - // in order) matches the baseline — proves the round-robin cursor was restored. - List baselineTranscript = [.. baseline.Result.Select(m => m.Text)]; - List resumedTranscript = [.. resumedResult.Select(m => m.Text)]; - Assert.Equal(baselineTranscript, resumedTranscript); - - // (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its - // own Name. Examine only the invocations recorded after the resume. If the host failed - // to exclude the current speaker from its post-resume broadcasts, an agent's next - // invocation buffer would contain its own previously produced response. Asserting that - // no post-resume invocation input contains the invoking agent's own name proves the - // exclusion was preserved through checkpoint+restore. - AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount); - AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount); - AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount); - - // Sanity: at least one agent was actually invoked after the resume; otherwise the test - // would trivially pass even if the host stopped scheduling turns after restore. - int totalPost = baseline.AgentA.Invocations.Count - aPreCount - + (baseline.AgentB.Invocations.Count - bPreCount) - + (baseline.AgentC.Invocations.Count - cPreCount); - Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint"); - - static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount) - { - for (int i = preCount; i < agent.Invocations.Count; i++) - { - Assert.DoesNotContain(agent.Name, agent.Invocations[i]); - } - } - } - - private sealed record BaselineRunResult( - Workflow Workflow, - InProcessExecutionEnvironment Environment, - RecordingAgent AgentA, - RecordingAgent AgentB, - RecordingAgent AgentC, - List Result, - List Checkpoints, - CheckpointManager CheckpointManager); - - private static async Task RunGroupChatBaselineAsync(string userInput, int maxIterations) - { - var agentA = new RecordingAgent("agentA"); - var agentB = new RecordingAgent("agentB"); - var agentC = new RecordingAgent("agentC"); - - Workflow workflow = AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) - .AddParticipants(agentA, agentB, agentC) - .Build(); - - CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory(); - InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep - .ToWorkflowExecutionEnvironment() - .WithCheckpointing(checkpointMgr); - - List checkpoints = []; - List? finalResult = null; - - await using (StreamingRun run = await env.OpenStreamingAsync(workflow)) - { - await run.TrySendMessageAsync(new List { new(ChatRole.User, userInput) }); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false)) - { - switch (evt) - { - case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp: - checkpoints.Add(cp); - break; - case WorkflowOutputEvent o: - finalResult = o.As>(); - break; - case WorkflowErrorEvent err: - Assert.Fail($"Baseline workflow failed: {err.Exception}"); - break; - } - } - } - - Assert.NotNull(finalResult); - return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr); - } - - private sealed class PrefixingGroupChatManager(IReadOnlyList agents, string prefix) : RoundRobinGroupChatManager(agents) - { - protected internal override ValueTask> UpdateHistoryAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default) - { - IEnumerable prefixed = - history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName }); - - return new(prefixed); - } - } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs new file mode 100644 index 0000000000..3e201e89e6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/GroupChatWorkflowBuilderTests.cs @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class GroupChatWorkflowBuilderTests +{ + [Fact] + public void BuildGroupChat_InvalidArguments_Throws() + { + Assert.Throws("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!)); + + var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new AgentWorkflowBuilderTests.DoubleEchoAgent("a1")])); + Assert.NotNull(groupChat); + Assert.Throws("agents", () => groupChat.AddParticipants(null!)); + Assert.Throws("agents", () => groupChat.AddParticipants([null!])); + Assert.Throws("agents", () => groupChat.AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("a1"), null!)); + + Assert.Throws("agents", () => new RoundRobinGroupChatManager(null!)); + } + + [Fact] + public void GroupChatManager_MaximumIterationCount_Invalid_Throws() + { + var manager = new RoundRobinGroupChatManager([new AgentWorkflowBuilderTests.DoubleEchoAgent("a1")]); + + const int DefaultMaxIterations = 40; + Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); + Assert.Throws("value", void () => manager.MaximumIterationCount = 0); + Assert.Throws("value", void () => manager.MaximumIterationCount = -1); + Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); + + manager.MaximumIterationCount = 30; + Assert.Equal(30, manager.MaximumIterationCount); + + manager.MaximumIterationCount = 1; + Assert.Equal(1, manager.MaximumIterationCount); + + manager.MaximumIterationCount = int.MaxValue; + Assert.Equal(int.MaxValue, manager.MaximumIterationCount); + } + + [Fact] + public void BuildGroupChat_WithNameAndDescription_SetsWorkflowNameAndDescription() + { + const string WorkflowName = "Test Group Chat"; + const string WorkflowDescription = "A test group chat workflow"; + + var workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) + .AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"), new AgentWorkflowBuilderTests.DoubleEchoAgent("agent2")) + .WithName(WorkflowName) + .WithDescription(WorkflowDescription) + .Build(); + + Assert.Equal(WorkflowName, workflow.Name); + Assert.Equal(WorkflowDescription, workflow.Description); + } + + [Fact] + public void BuildGroupChat_WithNameOnly_SetsWorkflowName() + { + const string WorkflowName = "Named Group Chat"; + + var workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) + .AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1")) + .WithName(WorkflowName) + .Build(); + + Assert.Equal(WorkflowName, workflow.Name); + Assert.Null(workflow.Description); + } + + [Fact] + public void BuildGroupChat_WithoutNameOrDescription_DefaultsToNull() + { + var workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 2 }) + .AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1")) + .Build(); + + Assert.Null(workflow.Name); + Assert.Null(workflow.Description); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) + { + const int NumAgents = 3; + var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) + .AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent1"), new AgentWorkflowBuilderTests.DoubleEchoAgent("agent2")) + .AddParticipants(new AgentWorkflowBuilderTests.DoubleEchoAgent("agent3")) + .Build(); + + for (int iter = 0; iter < 3; iter++) + { + const string UserInput = "abc"; + (string updateText, List? result, _, _) = await AgentWorkflowBuilderTests.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + + Assert.NotNull(result); + Assert.Equal(maxIterations + 1, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Null(result[0].AuthorName); + Assert.Equal(UserInput, result[0].Text); + + // The group-chat host broadcasts each new message (initial user input + each speaker's + // response) to every participant except the speaker that produced it. The selected + // speaker therefore sees only what's been broadcast to it since its previous turn. + string[] agentIds = ["agent1", "agent2", "agent3"]; + List[] buffers = new List[NumAgents]; + for (int a = 0; a < NumAgents; a++) + { + buffers[a] = [UserInput]; + } + + string[] texts = new string[maxIterations + 1]; + texts[0] = UserInput; + string expectedTotal = string.Empty; + for (int i = 1; i < maxIterations + 1; i++) + { + int speakerIdx = (i - 1) % NumAgents; + string id = agentIds[speakerIdx]; + string concatReceived = string.Concat(buffers[speakerIdx]); + texts[i] = $"{id}{Double(concatReceived)}"; + buffers[speakerIdx].Clear(); + for (int a = 0; a < NumAgents; a++) + { + if (a == speakerIdx) + { + continue; + } + + buffers[a].Add(texts[i]); + } + + Assert.Equal(ChatRole.Assistant, result[i].Role); + Assert.Equal(id, result[i].AuthorName); + Assert.Equal(texts[i], result[i].Text); + expectedTotal += texts[i]; + } + + Assert.Equal(expectedTotal, updateText); + Assert.Equal(UserInput + expectedTotal, string.Concat(result)); + + static string Double(string s) => s + s; + } + } + + private sealed class RecordingAgent(string name) : AIAgent + { + public List> Invocations { get; } = []; + + public override string Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new RecordingAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new RecordingAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + this.Invocations.Add(messages.Select(m => m.Text).ToList()); + + string id = Guid.NewGuid().ToString("N"); + yield return new AgentResponseUpdate(ChatRole.Assistant, name) { AuthorName = name, MessageId = id }; + } + } + + private sealed class RecordingAgentSession() : AgentSession(); + + [Fact] + public async Task BuildGroupChat_BroadcastsDeltaAndTargetsTurnTokenToSpeakerOnlyAsync() + { + var agentA = new RecordingAgent("agentA"); + var agentB = new RecordingAgent("agentB"); + var agentC = new RecordingAgent("agentC"); + + var workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 4 }) + .AddParticipants(agentA, agentB, agentC) + .Build(); + + const string UserInput = "hello"; + (_, List? result, _, _) = await AgentWorkflowBuilderTests.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + + Assert.NotNull(result); + Assert.Equal(5, result.Count); // initial user input + 4 agent turns + Assert.Collection( + result, + m => Assert.Equal(UserInput, m.Text), + m => Assert.Equal("agentA", m.Text), + m => Assert.Equal("agentB", m.Text), + m => Assert.Equal("agentC", m.Text), + m => Assert.Equal("agentA", m.Text)); + + // Each agent's TurnToken fires exactly when it is the selected speaker — invocation counts + // confirm only the chosen participant receives a TurnToken on each round. + Assert.Equal(2, agentA.Invocations.Count); + Assert.Single(agentB.Invocations); + Assert.Single(agentC.Invocations); + + // Turn 1: agentA is the first speaker. Initial broadcast went to every participant, so + // agentA's only buffered message is the user input. + Assert.Equal([UserInput], agentA.Invocations[0]); + + // Turn 2: agentB. It received the initial broadcast (user input) plus turn-1 broadcast of + // agentA's response (agentA itself is excluded as the last speaker). + Assert.Equal([UserInput, "agentA"], agentB.Invocations[0]); + + // Turn 3: agentC. It also received every broadcast so far (it has never been excluded). + Assert.Equal([UserInput, "agentA", "agentB"], agentC.Invocations[0]); + + // Turn 4: agentA again. It was excluded on turn 2's broadcast (its own response), but + // received turn-3 (agentB's response) and turn-4 (agentC's response) deltas. + Assert.Equal(["agentB", "agentC"], agentA.Invocations[1]); + } + + [Fact] + public async Task BuildGroupChat_UpdateHistoryAsync_FiltersBroadcastPayloadAsync() + { + var agentA = new RecordingAgent("agentA"); + var agentB = new RecordingAgent("agentB"); + + var workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new PrefixingGroupChatManager(agents, "[broadcast] ") { MaximumIterationCount = 2 }) + .AddParticipants(agentA, agentB) + .Build(); + + const string UserInput = "hello"; + await AgentWorkflowBuilderTests.RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + + // Turn 1: agentA's buffer contains only the initial broadcast, which UpdateHistoryAsync + // prefixed. + Assert.Equal(["[broadcast] hello"], agentA.Invocations[0]); + + // Turn 2: agentB received both the initial broadcast and agentA's response — both passed + // through UpdateHistoryAsync before being broadcast. + Assert.Equal(["[broadcast] hello", "[broadcast] agentA"], agentB.Invocations[0]); + } + + [Fact] + public async Task BuildGroupChat_CheckpointResumeMidConversation_PreservesIterationCursorAndBroadcastExclusionAsync() + { + const string UserInput = "hello"; + const int MaxIterations = 6; + + // --- Baseline: run the full conversation under checkpointing and capture every checkpoint + // plus the final transcript. The same workflow + agents are reused for the resume, + // because the runner enforces workflow-shape compatibility on ResumeStreamingAsync. --- + BaselineRunResult baseline = await RunGroupChatBaselineAsync(UserInput, MaxIterations); + + // We need at least one mid-conversation checkpoint to resume from. The baseline produces a + // checkpoint per superstep, which for MaxIterations=6 yields many; we pick a checkpoint + // captured roughly midway so the resumed run still has work to do. + Assert.True(baseline.Checkpoints.Count >= 5, + $"expected at least 5 checkpoints in the baseline, got {baseline.Checkpoints.Count}"); + + int midIndex = baseline.Checkpoints.Count / 2; + CheckpointInfo midCheckpoint = baseline.Checkpoints[midIndex]; + + // Snapshot per-agent invocation counts before the resume so we can isolate the invocations + // produced after the checkpoint is restored. + int aPreCount = baseline.AgentA.Invocations.Count; + int bPreCount = baseline.AgentB.Invocations.Count; + int cPreCount = baseline.AgentC.Invocations.Count; + + // --- Resume the same workflow from the mid-conversation checkpoint. --- + List? resumedResult = null; + await using (StreamingRun resumed = await baseline.Environment + .ResumeStreamingAsync(baseline.Workflow, midCheckpoint)) + { + await foreach (WorkflowEvent evt in resumed.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is WorkflowOutputEvent o) + { + resumedResult = o.As>(); + } + else if (evt is WorkflowErrorEvent err) + { + Assert.Fail($"Resumed workflow failed: {err.Exception}"); + } + } + } + + // (1) Iteration-count continuity: the resumed run terminates with exactly the same number + // of turns the baseline produced — proves IterationCount was rehydrated and the manager + // honored MaximumIterationCount across the boundary. + Assert.NotNull(resumedResult); + Assert.Equal(baseline.Result.Count, resumedResult!.Count); + + // (2) Next-speaker consistency: the full transcript (initial input + every speaker's turn, + // in order) matches the baseline — proves the round-robin cursor was restored. + List baselineTranscript = [.. baseline.Result.Select(m => m.Text)]; + List resumedTranscript = [.. resumedResult.Select(m => m.Text)]; + Assert.Equal(baselineTranscript, resumedTranscript); + + // (3) Broadcast exclusion holds across resume: a RecordingAgent's response text is just its + // own Name. Examine only the invocations recorded after the resume. If the host failed + // to exclude the current speaker from its post-resume broadcasts, an agent's next + // invocation buffer would contain its own previously produced response. Asserting that + // no post-resume invocation input contains the invoking agent's own name proves the + // exclusion was preserved through checkpoint+restore. + AssertPostResumeBroadcastExclusion(baseline.AgentA, aPreCount); + AssertPostResumeBroadcastExclusion(baseline.AgentB, bPreCount); + AssertPostResumeBroadcastExclusion(baseline.AgentC, cPreCount); + + // Sanity: at least one agent was actually invoked after the resume; otherwise the test + // would trivially pass even if the host stopped scheduling turns after restore. + int totalPost = baseline.AgentA.Invocations.Count - aPreCount + + (baseline.AgentB.Invocations.Count - bPreCount) + + (baseline.AgentC.Invocations.Count - cPreCount); + Assert.True(totalPost > 0, "at least one agent should be invoked after resuming from the mid-conversation checkpoint"); + + static void AssertPostResumeBroadcastExclusion(RecordingAgent agent, int preCount) + { + for (int i = preCount; i < agent.Invocations.Count; i++) + { + Assert.DoesNotContain(agent.Name, agent.Invocations[i]); + } + } + } + + private sealed record BaselineRunResult( + Workflow Workflow, + InProcessExecutionEnvironment Environment, + RecordingAgent AgentA, + RecordingAgent AgentB, + RecordingAgent AgentC, + List Result, + List Checkpoints, + CheckpointManager CheckpointManager); + + private static async Task RunGroupChatBaselineAsync(string userInput, int maxIterations) + { + var agentA = new RecordingAgent("agentA"); + var agentB = new RecordingAgent("agentB"); + var agentC = new RecordingAgent("agentC"); + + Workflow workflow = AgentWorkflowBuilder + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) + .AddParticipants(agentA, agentB, agentC) + .Build(); + + CheckpointManager checkpointMgr = CheckpointManager.CreateInMemory(); + InProcessExecutionEnvironment env = ExecutionEnvironment.InProcess_Lockstep + .ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointMgr); + + List checkpoints = []; + List? finalResult = null; + + await using (StreamingRun run = await env.OpenStreamingAsync(workflow)) + { + await run.TrySendMessageAsync(new List { new(ChatRole.User, userInput) }); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false)) + { + switch (evt) + { + case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp: + checkpoints.Add(cp); + break; + case WorkflowOutputEvent o: + finalResult = o.As>(); + break; + case WorkflowErrorEvent err: + Assert.Fail($"Baseline workflow failed: {err.Exception}"); + break; + } + } + } + + Assert.NotNull(finalResult); + return new BaselineRunResult(workflow, env, agentA, agentB, agentC, finalResult!, checkpoints, checkpointMgr); + } + + private sealed class PrefixingGroupChatManager(IReadOnlyList agents, string prefix) : RoundRobinGroupChatManager(agents) + { + protected internal override ValueTask> UpdateHistoryAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) + { + IEnumerable prefixed = + history.Select(m => new ChatMessage(m.Role, $"{prefix}{m.Text}") { AuthorName = m.AuthorName }); + + return new(prefixed); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs similarity index 73% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs index c7c231c63e..2b4d88dc9e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterAndOutputFilterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InputWaiterTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Threading; @@ -122,50 +122,3 @@ public sealed class InputWaiterTests : IDisposable await waitTask; } } - -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/OutputFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputFilterTests.cs new file mode 100644 index 0000000000..bb4a0e59bd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/OutputFilterTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +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/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs similarity index 99% rename from dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs index c2b855b8bf..102f0114a2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderTests.cs @@ -6,7 +6,7 @@ using FluentAssertions; namespace Microsoft.Agents.AI.Workflows.UnitTests; -public partial class WorkflowBuilderSmokeTests +public partial class WorkflowBuilderTests { private sealed class NoOpExecutor(string id) : Executor(id) {