Files
wehub-resource-sync db620d33df
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

567 lines
22 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public partial class WorkflowBuilderTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
private sealed class SomeOtherNoOpExecutor(string id) : Executor(id)
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
[Fact]
public void Test_Validation_FailsWhenUnboundExecutors()
{
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.AddEdge(new NoOpExecutor("start"), "unbound")
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_Validation_FailsWhenUnreachableExecutors()
{
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.AddEdge(new NoOpExecutor("unreachable"), new NoOpExecutor("also-unreachable"))
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_Validation_AddEdgesOutOfOrderDoesNotImpactReachability()
{
Workflow workflow = new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.AddEdge(new NoOpExecutor("not-unreachable"), new NoOpExecutor("also-not-unreachable"))
.AddEdge("start", "not-unreachable")
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(3);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings.Should().ContainKey("not-unreachable");
workflow.ExecutorBindings.Should().ContainKey("also-not-unreachable");
workflow.ExecutorBindings.Values.Should().AllSatisfy(binding => binding.ExecutorType.Should().Be<NoOpExecutor>());
}
[Fact]
public void Test_LateBinding_Executor()
{
Workflow workflow = new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_LateImplicitBinding_Executor()
{
NoOpExecutor start = new("start");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, start)
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_RebindToDifferent_Disallowed()
{
NoOpExecutor executor1 = new("start");
SomeOtherNoOpExecutor executor2 = new("start");
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.AddEdge(executor1, executor2)
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_RebindToSameish_Allowed()
{
NoOpExecutor executor1 = new("start");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(executor1, executor1)
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_Workflow_NameAndDescription()
{
// Test with name and description
Workflow workflow1 = new WorkflowBuilder("start")
.WithName("Test Pipeline")
.WithDescription("Test workflow description")
.BindExecutor(new NoOpExecutor("start"))
.Build();
workflow1.Name.Should().Be("Test Pipeline");
workflow1.Description.Should().Be("Test workflow description");
// Test without (defaults to null)
Workflow workflow2 = new WorkflowBuilder("start2")
.BindExecutor(new NoOpExecutor("start2"))
.Build();
workflow2.Name.Should().BeNull();
workflow2.Description.Should().BeNull();
// Test with only name (no description)
Workflow workflow3 = new WorkflowBuilder("start3")
.WithName("Named Only")
.BindExecutor(new NoOpExecutor("start3"))
.Build();
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<string>(source, target)
.Build();
// Assert
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();
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<string>(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!.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]);
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<string>(source, target)
.Build();
// Assert
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();
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<string>(source, [target1, target2])
.Build();
// Assert
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]);
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
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]
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<ArgumentException>()
.WithParameterName("executors");
}
[Fact]
public void AddExternalCall_CreatesRequestPortAndRoundTripEdges()
{
// Arrange
const string PortId = "port1";
NoOpExecutor source = new("start");
// Act
Workflow workflow = new WorkflowBuilder(source.Id)
.AddExternalCall<string, int>(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);
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]
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<string>(message => message == "match", [stringTarget])
.AddCase<int>(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!.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]);
edge.FanOutEdgeData.EdgeAssigner!(2, 3).Should().Equal([1]);
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<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardMessage<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardMessage<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardMessage<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardMessage<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardMessage<string>(source, []));
}
[Fact]
public void ForwardExcept_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).ForwardExcept<string>(source, target));
Assert.Throws<ArgumentNullException>("source", () => builder.ForwardExcept<string>(null!, target));
Assert.Throws<ArgumentNullException>("target", () => builder.ForwardExcept<string>(source, (ExecutorBinding)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, (IEnumerable<ExecutorBinding>)null!));
Assert.Throws<ArgumentNullException>("targets", () => builder.ForwardExcept<string>(source, [target, null!]));
Assert.Throws<ArgumentException>("targets", () => builder.ForwardExcept<string>(source, []));
}
[Fact]
public void AddChain_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
NoOpExecutor target = new("target");
NoOpExecutor otherTarget = new("other-target");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddChain(source, [target]));
Assert.Throws<ArgumentNullException>("source", () => builder.AddChain(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, null!));
Assert.Throws<ArgumentNullException>("executors", () => builder.AddChain(source, [target, null!]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, source]));
Assert.Throws<ArgumentException>("executors", () => builder.AddChain(source, [target, otherTarget, target]));
}
[Fact]
public void AddExternalCall_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddExternalCall<string, int>(source, "port"));
Assert.Throws<ArgumentNullException>("source", () => builder.AddExternalCall<string, int>(null!, "port"));
Assert.Throws<ArgumentNullException>("portId", () => builder.AddExternalCall<string, int>(source, null!));
}
[Fact]
public void AddSwitch_InvalidArguments_Throw()
{
// Arrange
WorkflowBuilder builder = new("start");
NoOpExecutor source = new("start");
// Act/Assert
Assert.Throws<ArgumentNullException>(() => ((WorkflowBuilder)null!).AddSwitch(source, _ => { }));
Assert.Throws<ArgumentNullException>("source", () => builder.AddSwitch(null!, _ => { }));
Assert.Throws<ArgumentNullException>("configureSwitch", () => builder.AddSwitch(source, null!));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, _ => { }));
Assert.Throws<ArgumentException>("targets", () => builder.AddSwitch(source, switchBuilder => switchBuilder.AddCase<string>(_ => true, [])));
}
[Fact]
public void SwitchBuilder_InvalidArguments_Throw()
{
// Arrange
SwitchBuilder switchBuilder = new();
NoOpExecutor target = new("target");
// Act/Assert
Assert.Throws<ArgumentNullException>("predicate", () => switchBuilder.AddCase<string>(null!, [target]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.AddCase<string>(_ => true, null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.AddCase<string>(_ => true, [target, null!]));
Assert.Throws<ArgumentNullException>("executors", () => switchBuilder.WithDefault(null!));
Assert.Throws<ArgumentNullException>("executors[1]", () => switchBuilder.WithDefault([target, null!]));
}
/// <summary>
/// Gets the only edge emitted by the specified workflow source.
/// </summary>
private static Edge GetSingleEdge(Workflow workflow, string sourceId)
=> workflow.Edges[sourceId].Should().ContainSingle().Subject;
// --- Tag-aware WithOutputFrom / WithIntermediateOutputFrom tests ---
[Fact]
public void Test_WithOutputFrom_RegistersWithEmptyTagSet()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithOutputFrom(b)
.Build();
workflow.OutputExecutors.Should().ContainKey("b");
workflow.OutputExecutors["b"].Should().BeEmpty("regular outputs are untagged");
}
[Fact]
public void Test_WithIntermediateOutputFrom_AddsIntermediateTag()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithOutputFrom_MultipleExecutorsAllUntagged()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
NoOpExecutor c = new("c");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b).AddEdge(a, c)
.WithOutputFrom(b, c)
.Build();
workflow.OutputExecutors.Should().HaveCount(2);
workflow.OutputExecutors["b"].Should().BeEmpty();
workflow.OutputExecutors["c"].Should().BeEmpty();
}
[Fact]
public void Test_WithOutputFrom_ThenIntermediate_AccumulatesTags()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithOutputFrom(b)
.WithIntermediateOutputFrom([b])
.Build();
// WithOutputFrom doesn't add a tag; WithIntermediateOutputFrom adds Intermediate.
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithIntermediateOutputFrom_RepeatedDedupes()
{
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithIntermediateOutputFrom_OnlyRegistersWithoutPriorWithOutputFrom()
{
// WithIntermediateOutputFrom on its own is sufficient to register the executor as an
// output source — the call ensures the id is in the dict with the Intermediate tag.
NoOpExecutor a = new("a");
NoOpExecutor b = new("b");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, b)
.WithIntermediateOutputFrom([b])
.Build();
workflow.OutputExecutors.Should().ContainKey("b");
workflow.OutputExecutors["b"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
[Fact]
public void Test_WithOutputFrom_TracksExecutorBinding()
{
// A placeholder binding referenced via WithOutputFrom must end up bound by the time we Build.
NoOpExecutor a = new("a");
NoOpExecutor future = new("future");
Workflow workflow = new WorkflowBuilder("a")
.AddEdge(a, "future")
.WithIntermediateOutputFrom(["future"])
.BindExecutor(future)
.Build();
workflow.OutputExecutors.Should().ContainKey("future");
workflow.OutputExecutors["future"].Should().BeEquivalentTo(new[] { OutputTag.Intermediate });
}
}