From 69dcfe31eea8cedc9f2048395422c77f34351c92 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:29:54 +0000 Subject: [PATCH] .NET Workflows - Add unit tests for ForeachExecutor (Declarative Workflows) (#3835) * Initial plan * Add comprehensive unit tests for ForeachExecutor Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> * Formatting * Checkpoint * Checkpoint * Updated test capabilities for non-discrete * Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Consistency * Cleanup test * Fixed --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: crickman <66376200+crickman@users.noreply.github.com> Co-authored-by: Chris Rickman Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../CodeGen/ForeachTemplate.cs | 18 +- .../CodeGen/ForeachTemplate.tt | 9 +- .../Interpreter/WorkflowActionVisitor.cs | 2 +- .../Interpreter/WorkflowTemplateVisitor.cs | 2 +- .../ObjectModel/ForeachExecutor.cs | 37 +- .../CodeGen/ForeachTemplateTest.cs | 2 +- .../DeclarativeWorkflowTest.cs | 25 +- .../ObjectModel/ForeachExecutorTest.cs | 320 ++++++++++++++++++ .../ObjectModel/WorkflowActionExecutorTest.cs | 40 ++- .../Workflows/LoopBreak.cs | 15 +- .../Workflows/LoopContinue.cs | 15 +- .../Workflows/LoopEach.cs | 15 +- 12 files changed, 440 insertions(+), 60 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs index ba15f9d0c7..de779128f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.cs @@ -65,7 +65,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -84,9 +84,19 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen AssignVariable(this.Index, "this._index", tightFormat: true); } - this.Write("\n\n this._index++;\n }\n }\n\n public async ValueTask ResetAsy" + - "nc(IWorkflowContext context, object? _, CancellationToken cancellationToken)\n " + - " {"); + this.Write(@" + + this._index++; + } + } + + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) + {"); AssignVariable(this.Value, "UnassignedValue.Instance", tightFormat: true); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt index 77f13c5184..8a9c8d5910 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/CodeGen/ForeachTemplate.tt @@ -36,7 +36,7 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session) : ActionE this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -59,7 +59,12 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session) : ActionE } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) {<# AssignVariable(this.Value, "UnassignedValue.Instance", tightFormat: true); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index a90b1bd9c9..3ecb91ea3a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -201,7 +201,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor { // Transition to end of inner actions string endActionsId = ForeachExecutor.Steps.End(action.Id); - this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.ResetAsync), action.Id); + this.ContinueWith(new DelegateActionExecutor(endActionsId, this._workflowState, action.CompleteAsync), action.Id); // Transition to select the next item this._workflowModel.AddLink(endActionsId, loopId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs index 065f04aaac..56901762f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowTemplateVisitor.cs @@ -163,7 +163,7 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor { // Transition to end of inner actions string endActionsId = ForeachExecutor.Steps.End(action.Id); // Loop continuation - this.ContinueWith(new EmptyTemplate(endActionsId, this._rootId, $"{action.Id.FormatName()}.{nameof(ForeachExecutor.ResetAsync)}"), action.Id); + this.ContinueWith(new EmptyTemplate(endActionsId, this._rootId, $"{action.Id.FormatName()}.{nameof(ForeachExecutor.CompleteAsync)}"), action.Id); // Transition to select the next item this._workflowModel.AddLink(endActionsId, loopId); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 2b4c21c86b..258f3c413b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -37,27 +37,21 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { + Throw.IfNull(this.Model.Items, $"{nameof(this.Model)}.{nameof(this.Model.Items)}"); + this._index = 0; - if (this.Model.Items is null) + EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Items); + if (expressionResult.Value is TableDataValue tableValue) { - this._values = []; - this.HasValue = false; + this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())]; } else { - EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Items); - if (expressionResult.Value is TableDataValue tableValue) - { - this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())]; - } - else - { - this._values = [expressionResult.Value.ToFormula()]; - } + this._values = [expressionResult.Value.ToFormula()]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -79,19 +73,24 @@ internal sealed class ForeachExecutor : DeclarativeActionExecutor } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) { try { - await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value), cancellationToken).ConfigureAwait(false); - if (this.Model.Index is not null) - { - await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false); - } + await this.ResetStateAsync(context, cancellationToken).ConfigureAwait(false); } finally { await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } } + + private async Task ResetStateAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value), cancellationToken).ConfigureAwait(false); + if (this.Model.Index is not null) + { + await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs index 9db4493c01..aaafa5bfb3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/CodeGen/ForeachTemplateTest.cs @@ -54,7 +54,7 @@ public class ForeachTemplateTest(ITestOutputHelper output) : WorkflowActionTempl AssertGeneratedCode(template.Id, workflowCode); AssertAgentProvider(template.UseAgentProvider, workflowCode); AssertGeneratedMethod(nameof(ForeachExecutor.TakeNextAsync), workflowCode); - AssertGeneratedMethod(nameof(ForeachExecutor.ResetAsync), workflowCode); + AssertGeneratedMethod(nameof(ForeachExecutor.CompleteAsync), workflowCode); } private Foreach CreateModel( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 9a2a84f7e7..9b3e4bbdaa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -52,7 +52,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { await this.RunWorkflowAsync("LoopBreak.yaml"); this.AssertExecutionCount(expectedCount: 6); - this.AssertExecuted("foreach_loop"); + this.AssertExecuted("foreach_loop", isDiscrete: false); this.AssertExecuted("break_loop_now"); this.AssertExecuted("end_all"); this.AssertNotExecuted("set_variable_inner"); @@ -64,7 +64,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow { await this.RunWorkflowAsync("LoopContinue.yaml"); this.AssertExecutionCount(expectedCount: 22); - this.AssertExecuted("foreach_loop"); + this.AssertExecuted("foreach_loop", isDiscrete: false); this.AssertExecuted("continue_loop_now"); this.AssertExecuted("end_all"); this.AssertNotExecuted("set_variable_inner"); @@ -103,7 +103,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertExecuted("conditionGroup_test"); if (input % 2 == 0) { - this.AssertExecuted("conditionItem_even", isScope: true); + this.AssertExecuted("conditionItem_even", isAction: false); this.AssertExecuted("sendActivity_even"); this.AssertNotExecuted("conditionItem_odd"); this.AssertNotExecuted("sendActivity_odd"); @@ -111,7 +111,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertNotExecuted("conditionItem_even"); this.AssertNotExecuted("sendActivity_even"); @@ -131,13 +131,13 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.AssertExecuted("conditionGroup_test"); if (input % 2 == 0) { - this.AssertExecuted("sendActivity_else", isScope: true); + this.AssertExecuted("sendActivity_else", isAction: false); this.AssertNotExecuted("conditionItem_odd"); this.AssertNotExecuted("sendActivity_odd"); } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertNotExecuted("sendActivity_else"); } @@ -152,7 +152,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow await this.RunWorkflowAsync("ConditionFallThrough.yaml", input); this.AssertExecutionCount(expectedActions); this.AssertExecuted("setVariable_test"); - this.AssertExecuted("conditionGroup_test", isScope: true); + this.AssertExecuted("conditionGroup_test", isAction: false); if (input % 2 == 0) { this.AssertNotExecuted("conditionItem_odd"); @@ -160,7 +160,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow } else { - this.AssertExecuted("conditionItem_odd", isScope: true); + this.AssertExecuted("conditionItem_odd", isAction: false); this.AssertExecuted("sendActivity_odd"); this.AssertMessage("ODD"); } @@ -307,14 +307,17 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); } - private void AssertExecuted(string executorId, bool isScope = false) + private void AssertExecuted(string executorId, bool isAction = true, bool isDiscrete = true) { Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); - if (!isScope) + if (isAction) { Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); - Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); + if (isDiscrete) + { + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ActionId == executorId); + } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs new file mode 100644 index 0000000000..559ca2a33a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/ForeachExecutorTest.cs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void ForeachThrowsWhenModelInvalid() => + // Arrange, Act & Assert + Assert.Throws(() => new ForeachExecutor(new Foreach(), this.State)); + + [Fact] + public void ForeachNamingConvention() + { + // Arrange + string testId = this.CreateActionId().Value; + + // Act + string startStep = ForeachExecutor.Steps.Start(testId); + string nextStep = ForeachExecutor.Steps.Next(testId); + string endStep = ForeachExecutor.Steps.End(testId); + + // Assert + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Start)}", startStep); + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Next)}", nextStep); + Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.End)}", endStep); + } + + [Fact] + public async Task ForeachInvokedWithSingleValueAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithSingleValueAsync), + items: ValueExpression.Literal(new NumberDataValue(42)), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachInvokedWithTableValueAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithTableValueAsync), + items: ValueExpression.Literal(DataValue.EmptyTable), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachInvokedWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + TableDataValue tableValue = DataValue.TableFromRecords( + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(1))), + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(2))), + DataValue.RecordFromFields(new KeyValuePair("item", new NumberDataValue(3)))); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithIndexAsync), + items: ValueExpression.Literal(tableValue), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + [Fact] + public async Task ForeachInvokedWithExpressionAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set("SourceArray", FormulaValue.NewTable(RecordType.Empty())); + + // Act & Assert + await this.ExecuteTestAsync( + displayName: nameof(ForeachInvokedWithExpressionAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextWithIndexAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + [Fact] + public async Task ForeachTakeLastAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + this.State.Set( + "SourceArray", + FormulaValue.NewTable( + RecordType.Empty(), + FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))))); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeLastAsync), + items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachTakeNextWhenDoneAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.TakeNextTestAsync( + displayName: nameof(ForeachTakeNextWhenDoneAsync), + items: ValueExpression.Literal(DataValue.EmptyTable), + valueName: "CurrentValue", + indexName: null, + expectValue: false); + } + + [Fact] + public async Task ForeachCompletedWithoutIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue"); + + // Act & Assert + await this.CompletedTestAsync( + displayName: nameof(ForeachCompletedWithoutIndexAsync), + valueName: "CurrentValue", + indexName: null); + } + + [Fact] + public async Task ForeachCompletedWithIndexAsync() + { + // Arrange + this.SetVariableState("CurrentValue", "CurrentIndex"); + + // Act & Assert + await this.CompletedTestAsync( + displayName: nameof(ForeachCompletedWithIndexAsync), + valueName: "CurrentValue", + indexName: "CurrentIndex"); + } + + private void SetVariableState(string valueName, string? indexName = null, FormulaValue? valueState = null) + { + this.State.Set(valueName, valueState ?? FormulaValue.New("something")); + if (indexName is not null) + { + this.State.Set(indexName, FormulaValue.New(33)); + } + } + + private async Task ExecuteTestAsync( + string displayName, + ValueExpression items, + string valueName, + string? indexName, + bool expectValue = false) + { + // Arrange + Foreach model = this.CreateModel(displayName, items, valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + VerifyModel(model, action); + VerifyInvocationEvent(events); + + // IsDiscreteAction should be false for Foreach + Assert.Equal( + false, + action.GetType().BaseType? + .GetProperty("IsDiscreteAction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)? + .GetValue(action)); + + // Verify HasValue state after execution + Assert.Equal(expectValue, action.HasValue); + + // Verify value was reset at the end + this.VerifyUndefined(valueName); + + // Verify index was reset at the end if it was used + if (indexName is not null) + { + this.VerifyUndefined(indexName); + } + } + + private async Task TakeNextTestAsync( + string displayName, + ValueExpression items, + string valueName, + string? indexName, + bool expectValue = true) + { + // Arrange + Foreach model = this.CreateModel(displayName, items, valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync); + + // Assert + VerifyModel(model, action); + + // Verify HasValue state after execution + Assert.Equal(expectValue, action.HasValue); + } + + private async Task CompletedTestAsync( + string displayName, + string valueName, + string? indexName) + { + // Arrange + Foreach model = this.CreateModel(displayName, ValueExpression.Literal(DataValue.EmptyTable), valueName, indexName); + ForeachExecutor action = new(model, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(ForeachExecutor.Steps.End(action.Id), action.CompleteAsync); + + // Assert + VerifyModel(model, action); + VerifyCompletionEvent(events); + + // Verify HasValue state after completion + Assert.False(action.HasValue); + + // Verify value was reset at the end + this.VerifyUndefined(valueName); + + // Verify index was reset at the end if it was used + if (indexName is not null) + { + this.VerifyUndefined(indexName); + } + } + + private Foreach CreateModel( + string displayName, + ValueExpression items, + string valueName, + string? indexName) + { + Foreach.Builder actionBuilder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Items = items, + Value = PropertyPath.Create(FormatVariablePath(valueName)), + }; + + if (indexName is not null) + { + actionBuilder.Index = PropertyPath.Create(FormatVariablePath(indexName)); + } + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index f7c074f1f5..cff93904b8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; @@ -25,17 +26,37 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}"; - internal async Task ExecuteAsync(DeclarativeActionExecutor executor) + internal Task ExecuteAsync(string actionId, DelegateAction executorAction) => + this.ExecuteAsync(new DelegateActionExecutor(actionId, this.State, executorAction), isDiscrete: false); + + internal Task ExecuteAsync(Executor executor, string actionId, DelegateAction executorAction) => + this.ExecuteAsync([executor, new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false); + + internal Task ExecuteAsync(Executor executor, bool isDiscrete = true) => + this.ExecuteAsync([executor], isDiscrete); + + internal async Task ExecuteAsync(Executor[] executors, bool isDiscrete) { this.State.Bind(); TestWorkflowExecutor workflowExecutor = new(); WorkflowBuilder workflowBuilder = new(workflowExecutor); - workflowBuilder.AddEdge(workflowExecutor, executor); + Executor prevExecutor = workflowExecutor; + foreach (Executor executor in executors) + { + workflowBuilder.AddEdge(prevExecutor, executor); + prevExecutor = executor; + } + await using StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.State); WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); - Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); - Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); + + if (isDiscrete) + { + VerifyInvocationEvent(events); + VerifyCompletionEvent(events); + } + ExecutorFailedEvent[] failureEvents = events.OfType().ToArray(); switch (failureEvents.Length) { @@ -48,6 +69,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor throw aggregateException; } + return events; } @@ -57,15 +79,21 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor Assert.Equal(model, action.Model); } + protected static void VerifyInvocationEvent(WorkflowEvent[] events) => + Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); + + protected static void VerifyCompletionEvent(WorkflowEvent[] events) => + Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); + protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, WorkflowFormulaState.DefaultScopeName, expectedValue); - internal void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) + protected void VerifyState(string variableName, string scopeName, FormulaValue expectedValue) { FormulaValue actualValue = this.State.Get(variableName, scopeName); Assert.Equal(expectedValue.Format(), actualValue.Format()); } - internal void VerifyUndefined(string variableName, string? scopeName = null) => + protected void VerifyUndefined(string variableName, string? scopeName = null) => Assert.IsType(this.State.Get(variableName, scopeName)); protected static TAction AssignParent(DialogAction.Builder actionBuilder) where TAction : DialogAction diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs index f4ee656e0c..04459394b0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopBreak.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -160,7 +165,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -182,4 +187,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs index 474d69e7ed..d023184476 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopContinue.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -160,7 +165,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -182,4 +187,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs index 06137f222d..a467d42f33 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/LoopEach.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -81,7 +81,7 @@ public static class WorkflowProvider this._values = [evaluatedValue]; } - await this.ResetAsync(context, null, cancellationToken).ConfigureAwait(false); + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); return default; } @@ -99,7 +99,12 @@ public static class WorkflowProvider } } - public async ValueTask ResetAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken) + { + await this.ResetAsync(context, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken) { await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); @@ -158,7 +163,7 @@ public static class WorkflowProvider SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session); SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session); DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session); - DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.ResetAsync); + DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync); // Define the workflow builder WorkflowBuilder builder = new(myWorkflowRoot); @@ -178,4 +183,4 @@ public static class WorkflowProvider // Build the workflow return builder.Build(validateOrphans: false); } -} +} \ No newline at end of file