diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index e1f71e2311..da691e7d48 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -52,8 +52,8 @@ private static async Task Main() // Build the workflow by adding executors and connecting them var workflow = new WorkflowBuilder(startExecutor) - .AddFanOutEdge(startExecutor, targets: [physicist, chemist]) - .AddFanInEdge(aggregationExecutor, sources: [physicist, chemist]) + .AddFanOutEdge(startExecutor, "fan-out", targets: [physicist, chemist]) + .AddFanInEdge(aggregationExecutor, "fan-in", sources: [physicist, chemist]) .WithOutputFrom(aggregationExecutor) .Build(); diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs index 9fd4b66e70..c0fa88df75 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs @@ -62,10 +62,10 @@ public static Workflow BuildWorkflow() // Step 4: Build the concurrent workflow with fan-out/fan-in pattern return new WorkflowBuilder(splitter) - .AddFanOutEdge(splitter, targets: [.. mappers]) // Split -> many mappers - .AddFanInEdge(shuffler, sources: [.. mappers]) // All mappers -> shuffle - .AddFanOutEdge(shuffler, targets: [.. reducers]) // Shuffle -> many reducers - .AddFanInEdge(completion, sources: [.. reducers]) // All reducers -> completion + .AddFanOutEdge(splitter, "Splitting", targets: [.. mappers]) // Split -> many mappers + .AddFanInEdge(shuffler, "fan-in", sources: [.. mappers]) // All mappers -> shuffle + .AddFanOutEdge(shuffler, "Shuffling", targets: [.. reducers]) // Shuffle -> many reducers + .AddFanInEdge(completion, "fan-in", sources: [.. reducers]) // All reducers -> completion .WithOutputFrom(completion) .Build(); } diff --git a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs index 6f4cfdf38b..99c14d57c2 100644 --- a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs @@ -26,8 +26,8 @@ private static async Task Main() // Build the workflow by connecting executors sequentially var workflow = new WorkflowBuilder(fileRead) - .AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount]) - .AddFanInEdge(aggregate, sources: [wordCount, paragraphCount]) + .AddFanOutEdge(fileRead, null, targets: [wordCount, paragraphCount]) + .AddFanInEdge(aggregate, null, sources: [wordCount, paragraphCount]) .WithOutputFrom(aggregate) .Build(); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index af1dcb50d9..0ba4236db1 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -27,7 +27,7 @@ private static async Task Main() // Build the workflow by connecting executors sequentially WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + builder.AddEdge(uppercase, reverse, false, "custom label").WithOutputFrom(reverse); var workflow = builder.Build(); // Execute the workflow with input data diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs index 2119bd775b..7d61c939cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/DirectEdgeData.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class DirectEdgeData : EdgeData { - internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id) + internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label) { this.SourceId = sourceId; this.SinkId = sinkId; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs index 7771b3966e..570bc79bc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/EdgeData.cs @@ -14,10 +14,16 @@ public abstract class EdgeData /// internal abstract EdgeConnection Connection { get; } - internal EdgeData(EdgeId id) + internal EdgeData(EdgeId id, string? label = null) { this.Id = id; + this.Label = label; } internal EdgeId Id { get; } + + /// + /// An optional label for the edge, allowing for arbitrary metadata to be associated with it. + /// + public string? Label { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs index 0cb2b38378..1132fca334 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanInEdgeData.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanInEdgeData : EdgeData { - internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id) : base(id) + internal FanInEdgeData(List sourceIds, string sinkId, EdgeId id, string? label) : base(id, label) { this.SourceIds = sourceIds; this.SinkId = sinkId; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs index 9d9ddf4cea..86a940c1b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FanOutEdgeData.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows; /// internal sealed class FanOutEdgeData : EdgeData { - internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId) + internal FanOutEdgeData(string sourceId, List sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label) { this.SourceId = sourceId; this.SinkIds = sinkIds; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs index e180650935..4b6ffae650 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/SwitchBuilder.cs @@ -84,7 +84,7 @@ internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding List<(Func Predicate, HashSet OutgoingIndicies)> caseMap = this._caseMap; HashSet defaultIndicies = this._defaultIndicies; - return builder.AddFanOutEdge(source, CasePartitioner, [.. this._executors]); + return builder.AddFanOutEdge(source, CasePartitioner, null, [.. this._executors]); IEnumerable CasePartitioner(object? input, int targetCount) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs index ebf6f08ffb..34c4f17342 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs @@ -99,10 +99,26 @@ private static void EmitWorkflowDigraph(Workflow workflow, List lines, s } // Emit normal edges - foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow)) + foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow)) { - var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : ""; - lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};"); + // Build edge attributes + var attributes = new List(); + + // Add style for conditional edges + if (isConditional) + { + attributes.Add("style=dashed"); + } + + // Add label (custom label or default "conditional" for conditional edges) + if (label != null) + { + attributes.Add($"label=\"{EscapeDotLabel(label)}\""); + } + + // Combine attributes + var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : ""; + lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};"); } } @@ -133,12 +149,7 @@ private static void EmitSubWorkflowsDigraph(Workflow workflow, List line private static void EmitWorkflowMermaid(Workflow workflow, List lines, string indent, string? ns = null) { - string sanitize(string input) - { - return input; - } - - string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id; + string MapId(string id) => ns != null ? $"{ns}/{id}" : id; // Add start node var startExecutorId = workflow.StartExecutorId; @@ -175,14 +186,21 @@ string sanitize(string input) } // Emit normal edges - foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow)) + foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow)) { - if (isConditional) + if (label != null) { - lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};"); + // Regular edge with label + lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};"); + } + else if (isConditional) + { + // Conditional edge with default label (no escaping needed for literal) + lines.Add($"{indent}{MapId(src)} -->|conditional| {MapId(target)};"); } else { + // Regular edge without label lines.Add($"{indent}{MapId(src)} --> {MapId(target)};"); } } @@ -214,9 +232,9 @@ string sanitize(string input) return result; } - private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow) + private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow) { - var edges = new List<(string, string, bool)>(); + var edges = new List<(string, string, bool, string?)>(); foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x)) { if (edgeGroup.Kind == EdgeKind.FanIn) @@ -229,14 +247,15 @@ string sanitize(string input) case EdgeKind.Direct when edgeGroup.DirectEdgeData != null: var directData = edgeGroup.DirectEdgeData; var isConditional = directData.Condition != null; - edges.Add((directData.SourceId, directData.SinkId, isConditional)); + var label = directData.Label; + edges.Add((directData.SourceId, directData.SinkId, isConditional, label)); break; case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null: var fanOutData = edgeGroup.FanOutEdgeData; foreach (var sinkId in fanOutData.SinkIds) { - edges.Add((fanOutData.SourceId, sinkId, false)); + edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label)); } break; } @@ -276,5 +295,24 @@ private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(t return false; } + // Helper method to escape special characters in DOT labels + private static string EscapeDotLabel(string label) + { + return label.Replace("\"", "\\\"").Replace("\n", "\\n"); + } + + // Helper method to escape special characters in Mermaid labels + private static string EscapeMermaidLabel(string label) + { + return label + .Replace("&", "&") // Must be first to avoid double-escaping + .Replace("|", "|") // Pipe breaks Mermaid delimiter syntax + .Replace("\"", """) // Quote character + .Replace("<", "<") // Less than + .Replace(">", ">") // Greater than + .Replace("\n", "
") // Newline to HTML break + .Replace("\r", ""); // Remove carriage return + } + #endregion } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index c277a84b36..bf370dba01 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -176,11 +176,13 @@ private HashSet EnsureEdgesFor(string sourceId) /// The executor that acts as the target node of the edge. Cannot be null. /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false) - => this.AddEdge(source, target, null, idempotent); + public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false, string? label = null) + => this.AddEdge(source, target, null, idempotent, label); internal static Func? CreateConditionFunc(Func? condition) { @@ -229,13 +231,15 @@ public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, b /// The executor that acts as the source node of the edge. Cannot be null. /// The executor that acts as the target node of the edge. Cannot be null. /// An optional predicate that determines whether the edge should be followed based on the input. + /// If null, the edge is always activated when the source sends a message. /// If set to , adding the same edge multiple times will be a NoOp, /// rather than an error. - /// If null, the edge is always activated when the source sends a message. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// The current instance of . /// Thrown if an unconditional edge between the specified source and target /// executors already exists. - public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, Func? condition = null, bool idempotent = false) + public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, Func? condition = null, bool idempotent = false, string? label = null) { // Add an edge from source to target with an optional condition. // This is a low-level builder method that does not enforce any specific executor type. @@ -256,7 +260,7 @@ public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target "You cannot add another edge without a condition for the same source and target."); } - DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition)); + DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label); this.EnsureEdgesFor(source.Id).Add(new(directEdge)); @@ -273,7 +277,21 @@ public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable targets) - => this.AddFanOutEdge(source, null, targets); + => this.AddFanOutEdge(source, null, null, targets); + + /// + /// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a + /// custom partitioning function. + /// + /// If a partitioner function is provided, it will be used to distribute input across the target + /// executors. The order of targets determines their mapping in the partitioning process. + /// The source executor from which the fan-out edge originates. Cannot be null. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. + /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. + /// The current instance of . + public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, string? label = null, params IEnumerable targets) + => this.AddFanOutEdge(source, null, label, targets); internal static Func>? CreateEdgeAssignerFunc(Func>? partitioner) { @@ -302,9 +320,11 @@ public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable< /// The source executor from which the fan-out edge originates. Cannot be null. /// An optional function that determines how input is partitioned among the target executors. /// If null, messages will route to all targets. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. /// One or more target executors that will receive the fan-out edge. Cannot be null or empty. /// The current instance of . - public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, Func>? partitioner = null, params IEnumerable targets) + public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, Func>? partitioner = null, string? label = null, params IEnumerable targets) { Throw.IfNull(source); Throw.IfNull(targets); @@ -321,7 +341,8 @@ public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, Func(ExecutorBinding source, FuncThe target executor that receives input from the specified source executors. Cannot be null. /// One or more source executors that provide input to the target. Cannot be null or empty. /// The current instance of . + // Overload for backward compatibility: original signature without label public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable sources) + { + return this.AddFanInEdge(target, null, sources); + } + + /// + /// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an + /// optional trigger condition. + /// + /// This method establishes a fan-in relationship, allowing the target executor to be activated + /// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation + /// behavior. + /// The target executor that receives input from the specified source executors. Cannot be null. + /// An optional text label to describe the edge. This label will appear in workflow visualizations + /// (DOT and Mermaid formats) to document the purpose or condition of the edge. + /// One or more source executors that provide input to the target. Cannot be null or empty. + /// The current instance of . + // Overload for backward compatibility: original signature without label + public WorkflowBuilder AddFanInEdge(ExecutorBinding target, string? label = null, params IEnumerable sources) { Throw.IfNull(target); Throw.IfNull(sources); @@ -354,7 +394,8 @@ public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable> workflowEdges = []; - FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0)); + FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null); Edge fanInEdge = new(edgeData); workflowEdges["executor1"] = [fanInEdge]; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs index 4239b6ef6d..e080086818 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/EdgeRunnerTests.cs @@ -155,7 +155,7 @@ public async Task Test_FanInEdgeRunnerAsync() runContext.Executors["executor2"] = new ForwardMessageExecutor("executor2"); runContext.Executors["executor3"] = new ForwardMessageExecutor("executor3"); - FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0)); + FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), "custom label"); FanInEdgeRunner runner = new(runContext, edgeData); // Step 1: Send message from executor1, should not forward yet. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs index 014c51b3c0..8353086dea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs @@ -160,7 +160,7 @@ public async Task InProcessRun_StateShouldError_TwoExecutorsAsync() Workflow workflow = new WorkflowBuilder(forward) - .AddFanOutEdge(forward, targets: [testExecutor, testExecutor2]) + .AddFanOutEdge(forward, null, targets: [testExecutor, testExecutor2]) .Build(); Run runWithFailure = await InProcessExecution.RunAsync(workflow, new TurnToken()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index f55185a78f..686cdea308 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -118,7 +118,7 @@ public void Test_FanOutEdgeInfo_JsonRoundtrip() RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator()); } - private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId()); + private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null); private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData); [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs index 9cf460e658..1878a55868 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/RepresentationTests.cs @@ -137,17 +137,17 @@ public void Test_EdgeInfos() RunEdgeInfoMatchTest(fanOutEdgeWithAssigner); // FanIn Edges - Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId())); + Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge); - Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId())); + Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge2); - Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId())); + Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge3, expect: false); // Order matters (though for FanIn maybe it shouldn't?) - Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId())); - Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId())); + Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId(), null)); + Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId(), null)); RunEdgeInfoMatchTest(fanInEdge, fanInEdge4, expect: false); // Identity matters RunEdgeInfoMatchTest(fanInEdge, fanInEdge5, expect: false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs index 441dd28439..2a39b16376 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowVisualizerTests.cs @@ -111,8 +111,8 @@ public void Test_WorkflowViz_FanIn_EdgeGroup() // Build a connected workflow: start fans out to s1 and s2, which then fan-in to t var workflow = new WorkflowBuilder("start") - .AddFanOutEdge(start, s1, s2) - .AddFanInEdge(t, s1, s2) // AddFanInEdge(target, sources) + .AddFanOutEdge(start, null, s1, s2) + .AddFanInEdge(t, null, s1, s2) // AddFanInEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); @@ -174,7 +174,7 @@ public void Test_WorkflowViz_FanOut_Edges() var target3 = new MockExecutor("target3"); var workflow = new WorkflowBuilder("start") - .AddFanOutEdge(start, target1, target2, target3) + .AddFanOutEdge(start, null, target1, target2, target3) .Build(); var dotContent = workflow.ToDotString(); @@ -199,8 +199,8 @@ public void Test_WorkflowViz_Mixed_EdgeTypes() var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge - .AddFanOutEdge(a, b, c) // Fan-out - .AddFanInEdge(end, b, c) // Fan-in - AddFanInEdge(target, sources) + .AddFanOutEdge(a, null, b, c) // Fan-out + .AddFanInEdge(end, null, b, c) // Fan-in - AddFanInEdge(target, sources) .Build(); var dotContent = workflow.ToDotString(); @@ -307,8 +307,8 @@ public void Test_WorkflowViz_Mermaid_FanIn_EdgeGroup() var t = new ListStrTargetExecutor("t"); var workflow = new WorkflowBuilder("start") - .AddFanOutEdge(start, s1, s2) - .AddFanInEdge(t, s1, s2) + .AddFanOutEdge(start, null, s1, s2) + .AddFanInEdge(t, null, s1, s2) .Build(); var mermaidContent = workflow.ToMermaidString(); @@ -378,8 +378,8 @@ public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes() var workflow = new WorkflowBuilder("start") .AddEdge(start, a, Condition) // Conditional edge - .AddFanOutEdge(a, b, c) // Fan-out - .AddFanInEdge(end, b, c) // Fan-in + .AddFanOutEdge(a, null, b, c) // Fan-out + .AddFanInEdge(end, null, b, c) // Fan-in .Build(); var mermaidContent = workflow.ToMermaidString(); @@ -394,4 +394,60 @@ public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes() // Check fan-in (should have intermediate node) mermaidContent.Should().Contain("((fan-in))"); } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe() + { + // Test that pipe characters in labels are properly escaped + var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "High | Low Priority") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should escape pipe character + mermaidContent.Should().Contain("start -->|High | Low Priority| end"); + // Should not contain unescaped pipe that would break syntax + mermaidContent.Should().NotContain("-->|High | Low"); + } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars() + { + // Test that special characters are properly escaped + var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "Score >= 90 & < 100") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should escape special characters + mermaidContent.Should().Contain("&"); + mermaidContent.Should().Contain(">"); + mermaidContent.Should().Contain("<"); + } + + [Fact] + public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline() + { + // Test that newlines are converted to
+ var start = new MockExecutor("start"); + var end = new MockExecutor("end"); + + var workflow = new WorkflowBuilder("start") + .AddEdge(start, end, label: "Line 1\nLine 2") + .Build(); + + var mermaidContent = workflow.ToMermaidString(); + + // Should convert newline to
+ mermaidContent.Should().Contain("Line 1
Line 2"); + mermaidContent.Should().NotContain("\n"); + } }