From b35dfbdab38e2767158d859901d6ab9cb26b745c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 18:58:39 +0000 Subject: [PATCH 01/19] Add workflow builder edge tests Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/3c3d5324-cdcd-4a38-8c67-94e4e78e29c5 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderSmokeTests.cs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 2b370de99e..feb59b35dd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -157,4 +157,183 @@ public void Test_Workflow_NameAndDescription() workflow3.Name.Should().Be("Named Only"); workflow3.Description.Should().BeNull(); } + + [Fact] + public void ForwardMessage_WithSingleTarget_CreatesDirectEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardMessage(source, target) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.Direct); + edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SinkId.Should().Be(target.Id); + edge.DirectEdgeData.Condition.Should().NotBeNull(); + edge.DirectEdgeData.Condition!("message").Should().BeTrue(); + edge.DirectEdgeData.Condition!(42).Should().BeFalse(); + edge.DirectEdgeData.Condition!(null).Should().BeFalse(); + } + + [Fact] + public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target1 = new("target1"); + NoOpExecutor target2 = new("target2"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardMessage(source, [target1, target2], message => message == "match") + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]); + edge.FanOutEdgeData.EdgeAssigner!("other", 2).Should().BeEmpty(); + edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().BeEmpty(); + } + + [Fact] + public void ForwardExcept_WithSingleTarget_CreatesDirectEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardExcept(source, target) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.Direct); + edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SinkId.Should().Be(target.Id); + edge.DirectEdgeData.Condition.Should().NotBeNull(); + edge.DirectEdgeData.Condition!("message").Should().BeFalse(); + edge.DirectEdgeData.Condition!(42).Should().BeTrue(); + edge.DirectEdgeData.Condition!(null).Should().BeTrue(); + } + + [Fact] + public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor target1 = new("target1"); + NoOpExecutor target2 = new("target2"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .ForwardExcept(source, [target1, target2]) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]); + edge.FanOutEdgeData.EdgeAssigner!("message", 2).Should().BeEmpty(); + } + + [Fact] + public void AddChain_CreatesSequentialDirectEdges() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor middle = new("middle"); + NoOpExecutor end = new("end"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddChain(source, [middle, end]) + .Build(); + + // Assert + GetSingleEdge(workflow, source.Id).DirectEdgeData!.SinkId.Should().Be(middle.Id); + GetSingleEdge(workflow, middle.Id).DirectEdgeData!.SinkId.Should().Be(end.Id); + } + + [Fact] + public void AddChain_WhenExecutorRepeats_Throws() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor middle = new("middle"); + + // Act + Action act = () => new WorkflowBuilder(source.Id) + .AddChain(source, [middle, source]); + + // Assert + act.Should().Throw() + .WithParameterName("executors"); + } + + [Fact] + public void AddExternalCall_CreatesRequestPortAndRoundTripEdges() + { + // Arrange + const string PortId = "port1"; + NoOpExecutor source = new("start"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddExternalCall(source, PortId) + .Build(); + + // Assert + workflow.Ports.Should().ContainKey(PortId); + workflow.Ports[PortId].Request.Should().Be(typeof(string)); + workflow.Ports[PortId].Response.Should().Be(typeof(int)); + workflow.ExecutorBindings.Should().ContainKey(PortId); + GetSingleEdge(workflow, source.Id).DirectEdgeData!.SinkId.Should().Be(PortId); + GetSingleEdge(workflow, PortId).DirectEdgeData!.SinkId.Should().Be(source.Id); + } + + [Fact] + public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault() + { + // Arrange + NoOpExecutor source = new("start"); + NoOpExecutor stringTarget = new("string-target"); + NoOpExecutor intTarget = new("int-target"); + NoOpExecutor defaultTarget = new("default-target"); + + // Act + Workflow workflow = new WorkflowBuilder(source.Id) + .AddSwitch(source, switchBuilder => switchBuilder + .AddCase(message => message == "match", [stringTarget]) + .AddCase(message => message > 0, [intTarget]) + .WithDefault([defaultTarget])) + .Build(); + + // Assert + Edge edge = GetSingleEdge(workflow, source.Id); + edge.Kind.Should().Be(EdgeKind.FanOut); + edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]); + edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); + edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]); + edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]); + edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]); + } + + private static Edge GetSingleEdge(Workflow workflow, string sourceId) + => workflow.Edges[sourceId].Should().ContainSingle().Subject; } From d12579ec6690c297b47fb2778845130433aa189b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:06:14 +0000 Subject: [PATCH 02/19] Strengthen workflow edge helper tests Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../SwitchBuilder.cs | 4 + .../WorkflowBuilderExtensions.cs | 5 + .../WorkflowBuilderSmokeTests.cs | 121 +++++++++++++++++- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 14e6ed4f7c..286329b6ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -39,6 +39,8 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) foreach (ExecutorBinding executor in executors) { + Throw.IfNull(executor, nameof(executors)); + if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { index = this._executors.Count; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index c702cf9ece..f9c3e2056a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -52,6 +52,8 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil /// The updated instance. public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable targets, Func? condition = null) { + Throw.IfNull(builder); + Throw.IfNull(source); Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; @@ -93,6 +95,8 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build /// The updated instance with the added edges. public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, IEnumerable targets) { + Throw.IfNull(builder); + Throw.IfNull(source); Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; @@ -129,6 +133,7 @@ public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBin { Throw.IfNull(builder); Throw.IfNull(source); + Throw.IfNull(executors); HashSet seenExecutors = [source.Id]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index feb59b35dd..1ca2027446 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using FluentAssertions; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -174,6 +175,7 @@ public void ForwardMessage_WithSingleTarget_CreatesDirectEdge() Edge edge = GetSingleEdge(workflow, source.Id); edge.Kind.Should().Be(EdgeKind.Direct); edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SourceId.Should().Be(source.Id); edge.DirectEdgeData!.SinkId.Should().Be(target.Id); edge.DirectEdgeData.Condition.Should().NotBeNull(); edge.DirectEdgeData.Condition!("message").Should().BeTrue(); @@ -198,6 +200,7 @@ public void ForwardMessage_WithMultipleTargets_CreatesFanOutEdge() Edge edge = GetSingleEdge(workflow, source.Id); edge.Kind.Should().Be(EdgeKind.FanOut); edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); edge.FanOutEdgeData.EdgeAssigner!("match", 2).Should().Equal([0, 1]); @@ -221,6 +224,7 @@ public void ForwardExcept_WithSingleTarget_CreatesDirectEdge() Edge edge = GetSingleEdge(workflow, source.Id); edge.Kind.Should().Be(EdgeKind.Direct); edge.DirectEdgeData.Should().NotBeNull(); + edge.DirectEdgeData!.SourceId.Should().Be(source.Id); edge.DirectEdgeData!.SinkId.Should().Be(target.Id); edge.DirectEdgeData.Condition.Should().NotBeNull(); edge.DirectEdgeData.Condition!("message").Should().BeFalse(); @@ -245,6 +249,7 @@ public void ForwardExcept_WithMultipleTargets_CreatesFanOutEdge() Edge edge = GetSingleEdge(workflow, source.Id); edge.Kind.Should().Be(EdgeKind.FanOut); edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); edge.FanOutEdgeData!.SinkIds.Should().Equal([target1.Id, target2.Id]); edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); edge.FanOutEdgeData.EdgeAssigner!(42, 2).Should().Equal([0, 1]); @@ -265,8 +270,15 @@ public void AddChain_CreatesSequentialDirectEdges() .Build(); // Assert - GetSingleEdge(workflow, source.Id).DirectEdgeData!.SinkId.Should().Be(middle.Id); - GetSingleEdge(workflow, middle.Id).DirectEdgeData!.SinkId.Should().Be(end.Id); + Edge firstEdge = GetSingleEdge(workflow, source.Id); + firstEdge.Kind.Should().Be(EdgeKind.Direct); + firstEdge.DirectEdgeData!.SourceId.Should().Be(source.Id); + firstEdge.DirectEdgeData.SinkId.Should().Be(middle.Id); + + Edge secondEdge = GetSingleEdge(workflow, middle.Id); + secondEdge.Kind.Should().Be(EdgeKind.Direct); + secondEdge.DirectEdgeData!.SourceId.Should().Be(middle.Id); + secondEdge.DirectEdgeData.SinkId.Should().Be(end.Id); } [Fact] @@ -302,8 +314,16 @@ public void AddExternalCall_CreatesRequestPortAndRoundTripEdges() workflow.Ports[PortId].Request.Should().Be(typeof(string)); workflow.Ports[PortId].Response.Should().Be(typeof(int)); workflow.ExecutorBindings.Should().ContainKey(PortId); - GetSingleEdge(workflow, source.Id).DirectEdgeData!.SinkId.Should().Be(PortId); - GetSingleEdge(workflow, PortId).DirectEdgeData!.SinkId.Should().Be(source.Id); + + Edge requestEdge = GetSingleEdge(workflow, source.Id); + requestEdge.Kind.Should().Be(EdgeKind.Direct); + requestEdge.DirectEdgeData!.SourceId.Should().Be(source.Id); + requestEdge.DirectEdgeData.SinkId.Should().Be(PortId); + + Edge responseEdge = GetSingleEdge(workflow, PortId); + responseEdge.Kind.Should().Be(EdgeKind.Direct); + responseEdge.DirectEdgeData!.SourceId.Should().Be(PortId); + responseEdge.DirectEdgeData.SinkId.Should().Be(source.Id); } [Fact] @@ -327,6 +347,7 @@ public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault() Edge edge = GetSingleEdge(workflow, source.Id); edge.Kind.Should().Be(EdgeKind.FanOut); edge.FanOutEdgeData.Should().NotBeNull(); + edge.FanOutEdgeData!.SourceId.Should().Be(source.Id); edge.FanOutEdgeData!.SinkIds.Should().Equal([stringTarget.Id, intTarget.Id, defaultTarget.Id]); edge.FanOutEdgeData.EdgeAssigner.Should().NotBeNull(); edge.FanOutEdgeData.EdgeAssigner!("match", 3).Should().Equal([0]); @@ -334,6 +355,98 @@ public void AddSwitch_CreatesFanOutEdgeWithCasesAndDefault() edge.FanOutEdgeData.EdgeAssigner!("other", 3).Should().Equal([2]); } + [Fact] + public void ForwardMessage_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws("builder", () => ((WorkflowBuilder)null!).ForwardMessage(source, target)); + Assert.Throws("source", () => builder.ForwardMessage(null!, target)); + Assert.Throws("target", () => builder.ForwardMessage(source, (ExecutorBinding)null!)); + Assert.Throws("targets", () => builder.ForwardMessage(source, (IEnumerable)null!)); + Assert.Throws("executors", () => builder.ForwardMessage(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardMessage(source, [])); + } + + [Fact] + public void ForwardExcept_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws("builder", () => ((WorkflowBuilder)null!).ForwardExcept(source, target)); + Assert.Throws("source", () => builder.ForwardExcept(null!, target)); + Assert.Throws("target", () => builder.ForwardExcept(source, (ExecutorBinding)null!)); + Assert.Throws("targets", () => builder.ForwardExcept(source, (IEnumerable)null!)); + Assert.Throws("executors", () => builder.ForwardExcept(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardExcept(source, [])); + } + + [Fact] + public void AddChain_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddChain(source, [target])); + Assert.Throws("source", () => builder.AddChain(null!, [target])); + Assert.Throws("executors", () => builder.AddChain(source, null!)); + Assert.Throws("executors", () => builder.AddChain(source, [target, null!])); + Assert.Throws("executors", () => builder.AddChain(source, [target, source])); + } + + [Fact] + public void AddExternalCall_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + + // Act/Assert + Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddExternalCall(source, "port")); + Assert.Throws("source", () => builder.AddExternalCall(null!, "port")); + Assert.Throws("portId", () => builder.AddExternalCall(source, null!)); + } + + [Fact] + public void AddSwitch_InvalidArguments_Throw() + { + // Arrange + WorkflowBuilder builder = new("start"); + NoOpExecutor source = new("start"); + + // Act/Assert + Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddSwitch(source, _ => { })); + Assert.Throws("source", () => builder.AddSwitch(null!, _ => { })); + Assert.Throws("configureSwitch", () => builder.AddSwitch(source, null!)); + Assert.Throws("targets", () => builder.AddSwitch(source, _ => { })); + } + + [Fact] + public void SwitchBuilder_InvalidArguments_Throw() + { + // Arrange + SwitchBuilder switchBuilder = new(); + NoOpExecutor target = new("target"); + + // Act/Assert + Assert.Throws("predicate", () => switchBuilder.AddCase(null!, [target])); + Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, null!)); + Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, [target, null!])); + Assert.Throws("executors", () => switchBuilder.WithDefault(null!)); + Assert.Throws("executors", () => switchBuilder.WithDefault([target, null!])); + } + private static Edge GetSingleEdge(Workflow workflow, string sourceId) => workflow.Edges[sourceId].Should().ContainSingle().Subject; } From 096381d1c345a0fdc498ad44a964b196dca644fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:08:19 +0000 Subject: [PATCH 03/19] Normalize edge helper bad input validation Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 34 ++++++++++--------- .../WorkflowBuilderSmokeTests.cs | 4 +-- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index f9c3e2056a..b09dec91f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -25,7 +25,11 @@ public static class WorkflowBuilderExtensions /// The target executor to which messages will be forwarded. /// The updated instance. public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) - => builder.ForwardMessage(source, [target], condition: null); + { + Throw.IfNull(target); + + return builder.ForwardMessage(source, [target], condition: null); + } /// /// Adds edges to the workflow that forward messages of the specified type from the source executor to @@ -57,17 +61,14 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; + List targetList = targets.Select(target => Throw.IfNull(target, nameof(targets))).ToList(); -#if NET - if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) -#else - if (targets is ICollection { Count: 1 }) -#endif + if (targetList.Count == 1) { - return builder.AddEdge(source, targets.First(), predicate); + return builder.AddEdge(source, targetList[0], predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targetList)); // The reason we can check for "not null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. @@ -83,7 +84,11 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil /// The target executor to which messages, except those of type , will be forwarded. /// The updated instance with the added edges. public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) - => builder.ForwardExcept(source, [target]); + { + Throw.IfNull(target); + + return builder.ForwardExcept(source, [target]); + } /// /// Adds edges from the specified source to the provided executors, excluding messages of a specified type. @@ -100,17 +105,14 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; + List targetList = targets.Select(target => Throw.IfNull(target, nameof(targets))).ToList(); -#if NET - if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) -#else - if (targets is ICollection { Count: 1 }) -#endif + if (targetList.Count == 1) { - return builder.AddEdge(source, targets.First(), predicate); + return builder.AddEdge(source, targetList[0], predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targetList)); // The reason we can check for "null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 1ca2027446..a44bd9ad8b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -368,7 +368,7 @@ public void ForwardMessage_InvalidArguments_Throw() Assert.Throws("source", () => builder.ForwardMessage(null!, target)); Assert.Throws("target", () => builder.ForwardMessage(source, (ExecutorBinding)null!)); Assert.Throws("targets", () => builder.ForwardMessage(source, (IEnumerable)null!)); - Assert.Throws("executors", () => builder.ForwardMessage(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardMessage(source, [target, null!])); Assert.Throws("targets", () => builder.ForwardMessage(source, [])); } @@ -385,7 +385,7 @@ public void ForwardExcept_InvalidArguments_Throw() Assert.Throws("source", () => builder.ForwardExcept(null!, target)); Assert.Throws("target", () => builder.ForwardExcept(source, (ExecutorBinding)null!)); Assert.Throws("targets", () => builder.ForwardExcept(source, (IEnumerable)null!)); - Assert.Throws("executors", () => builder.ForwardExcept(source, [target, null!])); + Assert.Throws("targets", () => builder.ForwardExcept(source, [target, null!])); Assert.Throws("targets", () => builder.ForwardExcept(source, [])); } From 24d1effb52e618773d820ea9e3e7c6dfec2dcf72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:10:13 +0000 Subject: [PATCH 04/19] Clarify edge helper target validation Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index b09dec91f4..03131daf8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Linq; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -58,10 +57,9 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil { Throw.IfNull(builder); Throw.IfNull(source); - Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; - List targetList = targets.Select(target => Throw.IfNull(target, nameof(targets))).ToList(); + List targetList = ValidateTargets(targets); if (targetList.Count == 1) { @@ -102,10 +100,9 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build { Throw.IfNull(builder); Throw.IfNull(source); - Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; - List targetList = targets.Select(target => Throw.IfNull(target, nameof(targets))).ToList(); + List targetList = ValidateTargets(targets); if (targetList.Count == 1) { @@ -119,6 +116,25 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build static bool IsAllowedType(object? message) => message is null; } + private static List ValidateTargets(IEnumerable targets) + { + Throw.IfNull(targets); + + List targetList = []; + foreach (ExecutorBinding? target in targets) + { + if (target is null) + { + throw new ArgumentNullException(nameof(targets), "Targets collection cannot contain null elements."); + } + + targetList.Add(target); + } + + Throw.IfNullOrEmpty(targetList, nameof(targets)); + return targetList; + } + /// /// Adds a sequential chain of executors to the workflow, connecting each executor in order so that each is /// executed after the previous one. From 73d19469284da6fcb183866bc14e8da5557b4d64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:11:36 +0000 Subject: [PATCH 05/19] Use explicit target parameter names Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index 03131daf8c..37b6f26388 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -25,7 +25,7 @@ public static class WorkflowBuilderExtensions /// The updated instance. public static WorkflowBuilder ForwardMessage(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) { - Throw.IfNull(target); + Throw.IfNull(target, nameof(target)); return builder.ForwardMessage(source, [target], condition: null); } @@ -83,7 +83,7 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil /// The updated instance with the added edges. public static WorkflowBuilder ForwardExcept(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding target) { - Throw.IfNull(target); + Throw.IfNull(target, nameof(target)); return builder.ForwardExcept(source, [target]); } From 9c839ea6eb9e89508c849cc07d398914f83039a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:13:09 +0000 Subject: [PATCH 06/19] Document workflow edge test helpers Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 7 +++++++ .../WorkflowBuilderSmokeTests.cs | 3 +++ 2 files changed, 10 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index 37b6f26388..179dc78c88 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -116,6 +116,13 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build static bool IsAllowedType(object? message) => message is null; } + /// + /// Validates a target collection and returns it as a list. + /// + /// The target executor bindings to validate. + /// A validated list of target executor bindings. + /// Thrown when is null or contains a null element. + /// Thrown when is empty. private static List ValidateTargets(IEnumerable targets) { Throw.IfNull(targets); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index a44bd9ad8b..27bbb1092c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -447,6 +447,9 @@ public void SwitchBuilder_InvalidArguments_Throw() Assert.Throws("executors", () => switchBuilder.WithDefault([target, null!])); } + /// + /// Gets the only edge emitted by the specified workflow source. + /// private static Edge GetSingleEdge(Workflow workflow, string sourceId) => workflow.Edges[sourceId].Should().ContainSingle().Subject; } From 9a02dafbc36a06786cdb007d401c530eee13d00a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:14:55 +0000 Subject: [PATCH 07/19] Clarify null element validation messages Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../SwitchBuilder.cs | 18 ++++++++++++++---- .../WorkflowBuilderExtensions.cs | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 286329b6ec..c347ca84f3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -37,9 +37,13 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable indicies = []; - foreach (ExecutorBinding executor in executors) + int executorIndex = 0; + foreach (ExecutorBinding? executor in executors) { - Throw.IfNull(executor, nameof(executors)); + if (executor is null) + { + throw new ArgumentNullException(nameof(executors), $"Executors collection contains a null element at index {executorIndex}."); + } if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -49,6 +53,7 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable casePredicate = WorkflowBuilder.CreateConditionFunc(predicate)!; @@ -66,9 +71,13 @@ public SwitchBuilder WithDefault(params IEnumerable executors) { Throw.IfNull(executors); - foreach (ExecutorBinding executor in executors) + int executorIndex = 0; + foreach (ExecutorBinding? executor in executors) { - Throw.IfNull(executor, nameof(executors)); + if (executor is null) + { + throw new ArgumentNullException(nameof(executors), $"Executors collection contains a null element at index {executorIndex}."); + } if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -78,6 +87,7 @@ public SwitchBuilder WithDefault(params IEnumerable executors) } this._defaultIndicies.Add(index); + executorIndex++; } return this; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index 179dc78c88..c261036c6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -128,14 +128,16 @@ private static List ValidateTargets(IEnumerable targetList = []; + int targetIndex = 0; foreach (ExecutorBinding? target in targets) { if (target is null) { - throw new ArgumentNullException(nameof(targets), "Targets collection cannot contain null elements."); + throw new ArgumentNullException(nameof(targets), $"Targets collection contains a null element at index {targetIndex}."); } targetList.Add(target); + targetIndex++; } Throw.IfNullOrEmpty(targetList, nameof(targets)); From 5ce002d3fd82549868bcf08f41e440c43de7ec7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:16:28 +0000 Subject: [PATCH 08/19] Add repeated chain executor coverage Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 11 +++++++++-- .../WorkflowBuilderSmokeTests.cs | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index c261036c6b..e7f68e4f69 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -128,9 +128,16 @@ private static List ValidateTargets(IEnumerable targetList = []; + using IEnumerator targetEnumerator = targets.GetEnumerator(); + if (!targetEnumerator.MoveNext()) + { + throw new ArgumentException("Targets collection cannot be empty.", nameof(targets)); + } + int targetIndex = 0; - foreach (ExecutorBinding? target in targets) + do { + ExecutorBinding? target = targetEnumerator.Current; if (target is null) { throw new ArgumentNullException(nameof(targets), $"Targets collection contains a null element at index {targetIndex}."); @@ -139,8 +146,8 @@ private static List ValidateTargets(IEnumerable("builder", () => ((WorkflowBuilder)null!).AddChain(source, [target])); @@ -403,6 +404,7 @@ public void AddChain_InvalidArguments_Throw() Assert.Throws("executors", () => builder.AddChain(source, null!)); Assert.Throws("executors", () => builder.AddChain(source, [target, null!])); Assert.Throws("executors", () => builder.AddChain(source, [target, source])); + Assert.Throws("executors", () => builder.AddChain(source, [target, otherTarget, target])); } [Fact] From 824ee2111bc8c8ae3fdc0c208f5a8460303a2dce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:18:22 +0000 Subject: [PATCH 09/19] Preserve Throw helper validation style Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../SwitchBuilder.cs | 18 ++++------------- .../WorkflowBuilderExtensions.cs | 20 ++++--------------- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index c347ca84f3..286329b6ec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -37,13 +37,9 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable indicies = []; - int executorIndex = 0; - foreach (ExecutorBinding? executor in executors) + foreach (ExecutorBinding executor in executors) { - if (executor is null) - { - throw new ArgumentNullException(nameof(executors), $"Executors collection contains a null element at index {executorIndex}."); - } + Throw.IfNull(executor, nameof(executors)); if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -53,7 +49,6 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable casePredicate = WorkflowBuilder.CreateConditionFunc(predicate)!; @@ -71,13 +66,9 @@ public SwitchBuilder WithDefault(params IEnumerable executors) { Throw.IfNull(executors); - int executorIndex = 0; - foreach (ExecutorBinding? executor in executors) + foreach (ExecutorBinding executor in executors) { - if (executor is null) - { - throw new ArgumentNullException(nameof(executors), $"Executors collection contains a null element at index {executorIndex}."); - } + Throw.IfNull(executor, nameof(executors)); if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -87,7 +78,6 @@ public SwitchBuilder WithDefault(params IEnumerable executors) } this._defaultIndicies.Add(index); - executorIndex++; } return this; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index e7f68e4f69..86699f4683 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -127,27 +127,15 @@ private static List ValidateTargets(IEnumerable targetList = []; - using IEnumerator targetEnumerator = targets.GetEnumerator(); - if (!targetEnumerator.MoveNext()) + List targetList = new(); + foreach (ExecutorBinding target in targets) { - throw new ArgumentException("Targets collection cannot be empty.", nameof(targets)); - } - - int targetIndex = 0; - do - { - ExecutorBinding? target = targetEnumerator.Current; - if (target is null) - { - throw new ArgumentNullException(nameof(targets), $"Targets collection contains a null element at index {targetIndex}."); - } + Throw.IfNull(target, nameof(targets)); targetList.Add(target); - targetIndex++; } - while (targetEnumerator.MoveNext()); + Throw.IfNullOrEmpty(targetList, nameof(targets)); return targetList; } From e3c0ab021ef1f1018392160c23fa5a20e31915f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:20:09 +0000 Subject: [PATCH 10/19] Cover empty switch case targets Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderSmokeTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 4e9cb19ee3..74e911eefe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -432,6 +432,7 @@ public void AddSwitch_InvalidArguments_Throw() Assert.Throws("source", () => builder.AddSwitch(null!, _ => { })); Assert.Throws("configureSwitch", () => builder.AddSwitch(source, null!)); Assert.Throws("targets", () => builder.AddSwitch(source, _ => { })); + Assert.Throws("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase(_ => true, []))); } [Fact] From e6f194bce5e4dd5611cc5d5c37623e6539501ca4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:21:42 +0000 Subject: [PATCH 11/19] Relax builder null assertion parameter checks Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/af831ee2-0a99-4427-9ffd-a3b5022c1b3b Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderSmokeTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 74e911eefe..08f14ea541 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -364,7 +364,7 @@ public void ForwardMessage_InvalidArguments_Throw() NoOpExecutor target = new("target"); // Act/Assert - Assert.Throws("builder", () => ((WorkflowBuilder)null!).ForwardMessage(source, target)); + Assert.Throws(() => ((WorkflowBuilder)null!).ForwardMessage(source, target)); Assert.Throws("source", () => builder.ForwardMessage(null!, target)); Assert.Throws("target", () => builder.ForwardMessage(source, (ExecutorBinding)null!)); Assert.Throws("targets", () => builder.ForwardMessage(source, (IEnumerable)null!)); @@ -381,7 +381,7 @@ public void ForwardExcept_InvalidArguments_Throw() NoOpExecutor target = new("target"); // Act/Assert - Assert.Throws("builder", () => ((WorkflowBuilder)null!).ForwardExcept(source, target)); + Assert.Throws(() => ((WorkflowBuilder)null!).ForwardExcept(source, target)); Assert.Throws("source", () => builder.ForwardExcept(null!, target)); Assert.Throws("target", () => builder.ForwardExcept(source, (ExecutorBinding)null!)); Assert.Throws("targets", () => builder.ForwardExcept(source, (IEnumerable)null!)); @@ -399,7 +399,7 @@ public void AddChain_InvalidArguments_Throw() NoOpExecutor otherTarget = new("other-target"); // Act/Assert - Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddChain(source, [target])); + Assert.Throws(() => ((WorkflowBuilder)null!).AddChain(source, [target])); Assert.Throws("source", () => builder.AddChain(null!, [target])); Assert.Throws("executors", () => builder.AddChain(source, null!)); Assert.Throws("executors", () => builder.AddChain(source, [target, null!])); @@ -415,7 +415,7 @@ public void AddExternalCall_InvalidArguments_Throw() NoOpExecutor source = new("start"); // Act/Assert - Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddExternalCall(source, "port")); + Assert.Throws(() => ((WorkflowBuilder)null!).AddExternalCall(source, "port")); Assert.Throws("source", () => builder.AddExternalCall(null!, "port")); Assert.Throws("portId", () => builder.AddExternalCall(source, null!)); } @@ -428,7 +428,7 @@ public void AddSwitch_InvalidArguments_Throw() NoOpExecutor source = new("start"); // Act/Assert - Assert.Throws("builder", () => ((WorkflowBuilder)null!).AddSwitch(source, _ => { })); + Assert.Throws(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { })); Assert.Throws("source", () => builder.AddSwitch(null!, _ => { })); Assert.Throws("configureSwitch", () => builder.AddSwitch(source, null!)); Assert.Throws("targets", () => builder.AddSwitch(source, _ => { })); From 2af97e5a7fdee8c0452e78eb9b3bb59bada2e866 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:28:59 +0000 Subject: [PATCH 12/19] Inline ValidateTargets into call sites Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/cb9a6a6a-02c7-41a8-a4b4-da16ad62ef86 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index 86699f4683..ae5625edcb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -57,9 +57,18 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil { Throw.IfNull(builder); Throw.IfNull(source); + Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; - List targetList = ValidateTargets(targets); + List targetList = new(); + foreach (ExecutorBinding target in targets) + { + Throw.IfNull(target, nameof(targets)); + + targetList.Add(target); + } + + Throw.IfNullOrEmpty(targetList, nameof(targets)); if (targetList.Count == 1) { @@ -100,9 +109,18 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build { Throw.IfNull(builder); Throw.IfNull(source); + Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; - List targetList = ValidateTargets(targets); + List targetList = new(); + foreach (ExecutorBinding target in targets) + { + Throw.IfNull(target, nameof(targets)); + + targetList.Add(target); + } + + Throw.IfNullOrEmpty(targetList, nameof(targets)); if (targetList.Count == 1) { @@ -116,29 +134,6 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build static bool IsAllowedType(object? message) => message is null; } - /// - /// Validates a target collection and returns it as a list. - /// - /// The target executor bindings to validate. - /// A validated list of target executor bindings. - /// Thrown when is null or contains a null element. - /// Thrown when is empty. - private static List ValidateTargets(IEnumerable targets) - { - Throw.IfNull(targets); - - List targetList = new(); - foreach (ExecutorBinding target in targets) - { - Throw.IfNull(target, nameof(targets)); - - targetList.Add(target); - } - - Throw.IfNullOrEmpty(targetList, nameof(targets)); - return targetList; - } - /// /// Adds a sequential chain of executors to the workflow, connecting each executor in order so that each is /// executed after the previous one. From 80ca30b448efb01e7375fe04cff3dc4574b1b994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:37:08 +0000 Subject: [PATCH 13/19] Refactor ForwardExcept with TFM-specialized TryGetNonEnumeratedCount Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/b081f61f-93ce-45dc-abbd-82c465395470 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index ae5625edcb..eeeaf63bc8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -112,26 +113,19 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; - List targetList = new(); - foreach (ExecutorBinding target in targets) - { - Throw.IfNull(target, nameof(targets)); - - targetList.Add(target); - } - Throw.IfNullOrEmpty(targetList, nameof(targets)); - - if (targetList.Count == 1) + if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) { - return builder.AddEdge(source, targetList[0], predicate); + return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targetList)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget))); // The reason we can check for "null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. static bool IsAllowedType(object? message) => message is null; + + ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets)); } /// From 27018d615d3a9e16a78e0cfbcd1cc81344164a51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:43:17 +0000 Subject: [PATCH 14/19] Use TFM-specialized count check: TryGetNonEnumeratedCount for NET6+, ICollection pattern for NETFX Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/8ec28a43-e7b7-456e-8d8e-921511b4accc Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index eeeaf63bc8..a771f89878 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -114,7 +114,11 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; +#if NET6_0_OR_GREATER if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) +#else + if (targets is ICollection { Count: 1 }) +#endif { return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate); } From c8a46d6219e4fcb8fd43cf53dd5c88a03ff4efb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 19:52:22 +0000 Subject: [PATCH 15/19] Apply TFM-specialized count check to ForwardMessage as well Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/9238ea32-a3e8-4b83-9683-484ad400071f Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../WorkflowBuilderExtensions.cs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index a771f89878..41cceb25a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -61,26 +61,23 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil Throw.IfNull(targets); Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; - List targetList = new(); - foreach (ExecutorBinding target in targets) - { - Throw.IfNull(target, nameof(targets)); - - targetList.Add(target); - } - - Throw.IfNullOrEmpty(targetList, nameof(targets)); - if (targetList.Count == 1) +#if NET6_0_OR_GREATER + if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) +#else + if (targets is ICollection { Count: 1 }) +#endif { - return builder.AddEdge(source, targetList[0], predicate); + return builder.AddEdge(source, Throw.IfNull(targets.First(), nameof(targets)), predicate); } - return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targetList)); + return builder.AddSwitch(source, (switch_) => switch_.AddCase(predicate, targets.Select(ValidateTarget))); // The reason we can check for "not null" here is that CreateConditionFunc will do the correct unwrapping // logic for PortableValues. bool IsAllowedTypeAndMatchingCondition(TMessage? message) => message != null && (condition == null || condition(message)); + + ExecutorBinding ValidateTarget(ExecutorBinding target) => Throw.IfNull(target, nameof(targets)); } /// From 14b9c02c5259be08c603745798333cce75b6155a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:50:37 +0000 Subject: [PATCH 16/19] Address review feedback: simplify Throw.IfNull in SwitchBuilder per westey-m suggestion Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/299950fd-4457-47f3-a373-f65d601b7ea5 Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs | 4 ++-- .../WorkflowBuilderSmokeTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 286329b6ec..0a6b9218f7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -39,7 +39,7 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) foreach (ExecutorBinding executor in executors) { - Throw.IfNull(executor, nameof(executors)); + Throw.IfNull(executor); if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 08f14ea541..4997197445 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -445,9 +445,9 @@ public void SwitchBuilder_InvalidArguments_Throw() // Act/Assert Assert.Throws("predicate", () => switchBuilder.AddCase(null!, [target])); Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, null!)); - Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, [target, null!])); + Assert.Throws("executor", () => switchBuilder.AddCase(_ => true, [target, null!])); Assert.Throws("executors", () => switchBuilder.WithDefault(null!)); - Assert.Throws("executors", () => switchBuilder.WithDefault([target, null!])); + Assert.Throws("executor", () => switchBuilder.WithDefault([target, null!])); } /// From f94ed16bc83ab79d43dd499078371e0cd77462b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:56:02 +0000 Subject: [PATCH 17/19] Use indexed parameter name in SwitchBuilder Throw.IfNull: executors[index] Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- .../src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs | 9 +++++++-- .../WorkflowBuilderSmokeTests.cs | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 0a6b9218f7..8141be1075 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -36,10 +36,12 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable indicies = []; + int executorIndex = 0; foreach (ExecutorBinding executor in executors) { - Throw.IfNull(executor); + Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex}]"); + executorIndex++; if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { @@ -66,9 +68,12 @@ public SwitchBuilder WithDefault(params IEnumerable executors) { Throw.IfNull(executors); + int executorIndex = 0; + foreach (ExecutorBinding executor in executors) { - Throw.IfNull(executor); + Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex}]"); + executorIndex++; if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 4997197445..c2b855b8bf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -445,9 +445,9 @@ public void SwitchBuilder_InvalidArguments_Throw() // Act/Assert Assert.Throws("predicate", () => switchBuilder.AddCase(null!, [target])); Assert.Throws("executors", () => switchBuilder.AddCase(_ => true, null!)); - Assert.Throws("executor", () => switchBuilder.AddCase(_ => true, [target, null!])); + Assert.Throws("executors[1]", () => switchBuilder.AddCase(_ => true, [target, null!])); Assert.Throws("executors", () => switchBuilder.WithDefault(null!)); - Assert.Throws("executor", () => switchBuilder.WithDefault([target, null!])); + Assert.Throws("executors[1]", () => switchBuilder.WithDefault([target, null!])); } /// From a43e6d993e22cf21496dcdc740d6a81680989e95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:58:08 +0000 Subject: [PATCH 18/19] Revert #if NET6_0_OR_GREATER back to #if NET; inline executorIndex++ Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs | 6 ++---- .../WorkflowBuilderExtensions.cs | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index 8141be1075..bee9947a6b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -40,8 +40,7 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) foreach (ExecutorBinding executor in executors) { - Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex}]"); - executorIndex++; + Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]"); if (!this._executorIndicies.TryGetValue(executor.Id, out int index)) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs index 41cceb25a3..a22aa8e722 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilderExtensions.cs @@ -62,7 +62,7 @@ public static WorkflowBuilder ForwardMessage(this WorkflowBuilder buil Func predicate = WorkflowBuilder.CreateConditionFunc(IsAllowedTypeAndMatchingCondition)!; -#if NET6_0_OR_GREATER +#if NET if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) #else if (targets is ICollection { Count: 1 }) @@ -111,7 +111,7 @@ public static WorkflowBuilder ForwardExcept(this WorkflowBuilder build Func predicate = WorkflowBuilder.CreateConditionFunc((Func)IsAllowedType)!; -#if NET6_0_OR_GREATER +#if NET if (targets.TryGetNonEnumeratedCount(out int count) && count == 1) #else if (targets is ICollection { Count: 1 }) From 2392255f9f3c173a0a17a69cbbf9fc836f767a83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:58:35 +0000 Subject: [PATCH 19/19] Add comment explaining unusual Throw.IfNull use for null elements inside collection Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/c5655707-5b0b-44f3-98a9-5f3961e32cfe Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com> --- dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index bee9947a6b..66ac3e6908 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -40,6 +40,7 @@ public SwitchBuilder AddCase(Func predicate, params IEnumerable executors) foreach (ExecutorBinding executor in executors) { + // Explicit name: null element inside the collection argument. Throw.IfNull(executor, $"{nameof(executors)}[{executorIndex++}]"); if (!this._executorIndicies.TryGetValue(executor.Id, out int index))