chore: import upstream snapshot with attribution
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
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="AddConversationMessageExecutor"/>.
/// </summary>
public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageSuccessfullyAsync(AgentMessageRole role)
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageSuccessfullyAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
messageText: $"Hello from {role}");
}
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageToWorkflowAsync(AgentMessageRole role)
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageToWorkflowAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
conversationId: "WorkflowConversationId",
messageText: $"Hello from {role}");
}
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageWithMetadataAsync(AgentMessageRole role)
{
// Arrange
Dictionary<string, string> metadataValues =
new()
{
["Key1"] = "Value1",
["Key2"] = "Value2",
};
RecordDataValue metadataRecord = metadataValues.ToRecordValue();
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageWithMetadataAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
messageText: $"Hello from {role}",
metadata: metadataRecord);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
AgentMessageRoleWrapper role,
string messageText,
string? conversationId = null,
RecordDataValue? metadata = null)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
AddConversationMessage model =
this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
conversationId ?? "TestConversationId",
role,
messageText,
metadata);
AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
ChatMessage? testMessage = mockAgentProvider.TestMessages?.LastOrDefault();
Assert.NotNull(testMessage);
VerifyModel(model, action);
this.VerifyState(variableName, testMessage.ToRecord());
if (metadata is not null)
{
Assert.NotNull(testMessage.AdditionalProperties);
Assert.NotEmpty(testMessage.AdditionalProperties);
}
}
private AddConversationMessage CreateModel(
string displayName,
string messageVariable,
string conversationId,
AgentMessageRoleWrapper role,
string messageText,
RecordDataValue? metadata)
{
ObjectExpression<RecordDataValue>.Builder? metadataExpression = null;
if (metadata is not null)
{
metadataExpression = ObjectExpression<RecordDataValue>.Literal(metadata).ToBuilder();
}
AddConversationMessage.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Message = PropertyPath.Create(messageVariable),
ConversationId = StringExpression.Literal(conversationId),
Role = role,
Metadata = metadataExpression,
};
actionBuilder.Content.Add(new AddConversationMessageContent.Builder
{
Type = AgentMessageContentType.Text,
Value = TemplateLine.Parse(messageText)
});
return AssignParent<AddConversationMessage>(actionBuilder);
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ClearAllVariablesExecutor"/>.
/// </summary>
public sealed class ClearAllVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ClearGlobalScopeAsync()
{
// Arrange
this.State.Set("GlobalVar", FormulaValue.New("Old value"), VariableScopeNames.Global);
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearGlobalScopeAsync)),
VariablesToClear.AllGlobalVariables,
"GlobalVar",
VariableScopeNames.Global);
}
[Fact]
public async Task ClearWorkflowScopeAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearWorkflowScopeAsync)),
VariablesToClear.ConversationScopedVariables,
"LocalVar");
}
[Fact]
public async Task ClearUserScopeAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearUserScopeAsync)),
VariablesToClear.UserScopedVariables,
"LocalVar",
expectedValue: FormulaValue.New("Old value"));
}
[Fact]
public async Task ClearWorkflowHistoryAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearWorkflowHistoryAsync)),
VariablesToClear.ConversationHistory,
"LocalVar",
expectedValue: FormulaValue.New("Old value"));
}
private async Task ExecuteTestAsync(
string displayName,
VariablesToClear scope,
string variableName,
string variableScope = VariableScopeNames.Local,
FormulaValue? expectedValue = null)
{
// Arrange
ClearAllVariables model = this.CreateModel(
this.FormatDisplayName(displayName),
scope);
ClearAllVariablesExecutor action = new(model, this.State);
this.State.Bind();
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("NoVar");
if (expectedValue is null)
{
this.VerifyUndefined(variableName, variableScope);
}
else
{
this.VerifyState(variableName, variableScope, expectedValue);
}
}
private ClearAllVariables CreateModel(string displayName, VariablesToClear variableTarget)
{
ClearAllVariables.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variables = EnumExpression<VariablesToClearWrapper>.Literal(VariablesToClearWrapper.Get(variableTarget)),
};
return AssignParent<ClearAllVariables>(actionBuilder);
}
}
@@ -0,0 +1,241 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ConditionGroupExecutor"/>.
/// </summary>
public sealed class ConditionGroupExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void ConditionGroupThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new ConditionGroupExecutor(new ConditionGroup(), this.State));
[Fact]
public void ConditionGroupDefaultNaming()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupDefaultNaming), [false], includeElse: true, defineActionIds: false);
ConditionItem condition = model.Conditions[0];
// Act
string conditionStepId = ConditionGroupExecutor.Steps.Item(model, condition);
string elseStepId = ConditionGroupExecutor.Steps.Else(model);
// Assert
Assert.Equal($"{model.Id}_Items0", conditionStepId);
Assert.Equal(model.ElseActions.Id.Value, elseStepId);
}
[Fact]
public void ConditionGroupExplicitNaming()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupExplicitNaming), [false], includeElse: true);
ConditionItem condition = model.Conditions[0];
// Act
string conditionStepId = ConditionGroupExecutor.Steps.Item(model, condition);
string elseStepId = ConditionGroupExecutor.Steps.Else(model);
// Assert
Assert.Equal(condition.Id, conditionStepId);
Assert.Equal(model.ElseActions.Id.Value, elseStepId);
}
[Fact]
public async Task ConditionGroupFirstConditionTrueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupFirstConditionTrueAsync),
conditions: [true, false]);
}
[Fact]
public async Task ConditionGroupSecondConditionTrueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupSecondConditionTrueAsync),
conditions: [false, true]);
}
[Fact]
public async Task ConditionGroupFirstConditionNullAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupFirstConditionNullAsync),
conditions: [null, true]);
}
[Fact]
public async Task ConditionGroupElseBranchAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupElseBranchAsync),
conditions: [false, false],
includeElse: true);
}
[Fact]
public async Task ConditionGroupDoneAsync()
{
ConditionGroup model = this.CreateModel(nameof(ConditionGroupDoneAsync), [true]);
ConditionGroupExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync("condition_done_id", action.DoneAsync);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
VerifyCompletionEvent(events);
}
[Fact]
public void ConditionGroupIsMatchTrue()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsMatchTrue), [true]);
ConditionItem firstCondition = model.Conditions[0];
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Item(model, firstCondition));
// Act
bool isMatch = executor.IsMatch(firstCondition, result);
// Assert
Assert.True(isMatch);
}
[Fact]
public void ConditionGroupIsMatchFalse()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsMatchFalse), [true, false]);
ConditionItem firstCondition = model.Conditions[0];
ConditionItem secondCondition = model.Conditions[1];
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Item(model, secondCondition));
// Act
bool isMatch = executor.IsMatch(firstCondition, result);
// Assert
Assert.False(isMatch);
}
[Fact]
public void ConditionGroupIsElseTrue()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsElseTrue), [false]);
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Else(model));
// Act
bool isElse = executor.IsElse(result);
// Assert
Assert.True(isElse);
}
[Fact]
public void ConditionGroupIsElseFalse()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsElseFalse), [false]);
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, "different_step");
// Act
bool isElse = executor.IsElse(result);
// Assert
Assert.False(isElse);
}
private async Task ExecuteTestAsync(
string displayName,
bool?[] conditions,
bool includeElse = false)
{
// Arrange
ConditionGroup model = this.CreateModel(displayName, conditions, includeElse);
ConditionGroupExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
VerifyInvocationEvent(events);
VerifyIsDiscrete(action, isDiscrete: false);
}
private ConditionGroup CreateModel(
string displayName,
bool?[] conditions,
bool includeElse = false,
bool defineActionIds = true)
{
ConditionGroup.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
};
for (int index = 0; index < conditions.Length; ++index)
{
bool? condition = conditions[index];
ConditionItem.Builder conditionBuilder = new()
{
Id = defineActionIds ? $"condition_{index}" : null,
Actions = this.CreateActions(defineActionIds ? $"condition_actions_{index}" : null),
Condition = condition is null ? null : BoolExpression.Literal(condition.Value).ToBuilder(),
};
actionBuilder.Conditions.Add(conditionBuilder);
}
if (includeElse)
{
actionBuilder.ElseActions = this.CreateActions(defineActionIds ? "else_actions" : null);
}
return AssignParent<ConditionGroup>(actionBuilder);
}
private ActionScope.Builder CreateActions(string? actionScopeId)
{
ActionScope.Builder actions = [];
if (actionScopeId is not null)
{
actions.Id = new ActionId(actionScopeId);
}
actions.Actions.Add(
new SendActivity.Builder
{
Id = $"{actionScopeId ?? "action"}_send_activity",
Activity = new MessageActivityTemplate(),
});
return actions;
}
}
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CopyConversationMessagesExecutor"/>.
/// </summary>
public sealed class CopyConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CopyMessagesWithSingleStringMessageAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithSingleStringMessageAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(StringDataValue.Create("Hello, how can I help you?")),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesWithSingleRecordMessageAsync()
{
// Arrange
ChatMessage testMessage = new(ChatRole.User, "Test message content");
DataValue messageDataValue = testMessage.ToRecord().ToDataValue();
Assert.IsType<RecordDataValue>(messageDataValue);
RecordDataValue messageRecord = (RecordDataValue)messageDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithSingleRecordMessageAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(messageRecord),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesWithMultipleMessagesAsync()
{
// Arrange
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "Second message"),
new ChatMessage(ChatRole.User, "Third message")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithMultipleMessagesAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 3);
}
[Fact]
public async Task CopyMessagesWithVariableExpressionAsync()
{
// Arrange
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message from variable")
];
TableValue messagesTable = testMessages.ToTable();
this.State.Set("SourceMessages", messagesTable);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithVariableExpressionAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages")),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesToWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message to workflow conversation")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesToWorkflowConversationAsync),
conversationId: "WorkflowConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 1,
expectWorkflowEvent: true);
}
[Fact]
public async Task CopyMessagesToNonWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message to non-workflow conversation")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesToNonWorkflowConversationAsync),
conversationId: "DifferentConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 1,
expectWorkflowEvent: false);
}
[Fact]
public async Task CopyMessagesWithBlankDataValueAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithBlankDataValueAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(DataValue.Blank()),
expectedMessageCount: 0);
}
private async Task ExecuteTestAsync(
string displayName,
string conversationId,
ValueExpression messages,
int expectedMessageCount,
bool expectWorkflowEvent = false)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
mockAgentProvider.TestMessages.Clear();
CopyConversationMessages model = this.CreateModel(
this.FormatDisplayName(displayName),
conversationId,
messages);
CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
Assert.Equal(expectedMessageCount, mockAgentProvider.TestMessages.Count);
VerifyModel(model, action);
AgentResponseEvent[] responseEvents = events.OfType<AgentResponseEvent>().ToArray();
if (expectWorkflowEvent && expectedMessageCount > 0)
{
Assert.NotEmpty(responseEvents);
AgentResponseEvent responseEvent = responseEvents.First();
Assert.Equal(action.Id, responseEvent.ExecutorId);
Assert.NotNull(responseEvent.Response);
Assert.Equal(expectedMessageCount, responseEvent.Response.Messages.Count);
}
else
{
Assert.Empty(responseEvents);
}
}
private CopyConversationMessages CreateModel(
string displayName,
string conversationId,
ValueExpression messages)
{
CopyConversationMessages.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = StringExpression.Literal(conversationId),
Messages = messages
};
return AssignParent<CopyConversationMessages>(actionBuilder);
}
}
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CreateConversationExecutor "/>.
/// </summary>
public sealed class CreateConversationExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CreateNewConversationAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateNewConversationAsync),
"TestConversationId",
executionIteration: 1);
}
[Fact]
public async Task CreateMultipleConversationsAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateMultipleConversationsAsync),
"TestConversationId",
executionIteration: 4);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
int executionIteration)
{
// Arrange
// Initialize state to simulate workflow environment.
this.State.InitializeSystem();
CreateConversation model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName));
MockAgentProvider mockAgentProvider = new();
CreateConversationExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
int expectedIterationCount = executionIteration;
while (executionIteration-- > 0)
{
await this.ExecuteAsync(action);
}
// Assert
VerifyModel(model, action);
Assert.Equal(expected: expectedIterationCount, actual: mockAgentProvider.ExistingConversationIds.Count);
this.VerifyState("TestConversationId", FormulaValue.New(mockAgentProvider.ExistingConversationIds.Last()));
}
private CreateConversation CreateModel(string displayName, string conversationIdVariable)
{
CreateConversation.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = PropertyPath.Create(conversationIdVariable)
};
return AssignParent<CreateConversation>(actionBuilder);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="DefaultActionExecutor"/>.
/// </summary>
public sealed class DefaultActionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ExecuteDefaultActionAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ExecuteDefaultActionAsync)));
}
private async Task ExecuteTestAsync(string displayName)
{
// Arrange
ResetVariable model = this.CreateModel(displayName);
// Act
DefaultActionExecutor action = new(model, this.State);
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
private ResetVariable CreateModel(string displayName)
{
// Use a simple concrete action type since DialogAction.Builder is abstract
ResetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(FormatVariablePath("TestVariable")),
};
return AssignParent<ResetVariable>(actionBuilder);
}
}
@@ -0,0 +1,440 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="EditTableExecutor"/>.
/// </summary>
public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModelNullItemsVariable() =>
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() => new EditTableExecutor(new EditTable(), this.State));
[Fact]
public async Task AddItemToTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemToTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([new("id", new NumberDataValue(7))]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(7, idValue.Value);
}
[Fact]
public async Task AddItemWithMultipleFieldsAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1, name: \"First\"}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemWithMultipleFieldsAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([
new("id", new NumberDataValue(2)),
new("name", new StringDataValue("Second"))
]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(2, idValue.Value);
StringValue nameValue = Assert.IsType<StringValue>(resultRecord.GetField("name"));
Assert.Equal("Second", nameValue.Value);
}
[Fact]
public async Task AddItemToEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemToEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([new("id", new NumberDataValue(1))]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(1, idValue.Value);
}
[Fact]
public async Task RemoveItemFromTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}, {id: 7}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(RemoveItemFromTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Remove,
value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]));
// Verify the variable now contains an empty record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
// Empty record should have no fields
Assert.Empty(resultRecord.Fields);
}
[Fact]
public async Task RemoveMultipleItemsFromTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}, {id: 3}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(RemoveMultipleItemsFromTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Remove,
value: new TableDataValue([
new RecordDataValue([new("id", new NumberDataValue(1))]),
new RecordDataValue([new("id", new NumberDataValue(3))])
]));
// Verify the variable now contains an empty record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
// Empty record should have no fields
Assert.Empty(resultRecord.Fields);
}
[Fact]
public async Task ClearTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(ClearTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Clear,
value: null);
// Verify table is cleared
FormulaValue resultValue = this.State.Get("MyTable");
Assert.IsType<BlankValue>(resultValue);
}
[Fact]
public async Task ClearEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(ClearEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Clear,
value: null);
// Verify table is blank
FormulaValue resultValue = this.State.Get("MyTable");
Assert.IsType<BlankValue>(resultValue);
}
[Fact]
public async Task TakeFirstItemAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstItemAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify the variable now contains the first record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(10, idValue.Value);
}
[Fact]
public async Task TakeFirstFromEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstFromEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify table is still empty (nothing was taken, variable remains unchanged)
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Empty(resultTable.Rows);
}
[Fact]
public async Task TakeLastItemAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastItemAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify the variable now contains the last record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(30, idValue.Value);
}
[Fact]
public async Task TakeLastFromEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastFromEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify table is still empty (nothing was taken, variable remains unchanged)
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Empty(resultTable.Rows);
}
[Fact]
public async Task TakeFirstFromSingleItemTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstFromSingleItemTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify variable contains the record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(100, idValue.Value);
}
[Fact]
public async Task TakeLastFromSingleItemTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastFromSingleItemTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify variable contains the record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(100, idValue.Value);
}
[Fact]
public async Task ErrorWhenVariableIsNotTableAsync()
{
// Arrange
this.State.Set("NotATable", FormulaValue.New("This is a string, not a table"));
EditTable model = this.CreateModel(
nameof(ErrorWhenVariableIsNotTableAsync),
"NotATable",
TableChangeType.Add,
new RecordDataValue([new("id", new NumberDataValue(1))]));
// Act
EditTableExecutor action = new(model, this.State);
// Assert - Should throw an exception for non-table variable
DeclarativeActionException exception = await Assert.ThrowsAsync<DeclarativeActionException>(
async () => await this.ExecuteAsync(action));
Assert.NotNull(exception);
}
[Fact]
public async Task AddWithExpressionAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 5}]");
this.State.Set("MyTable", tableValue);
this.State.Set("NewId", FormulaValue.New(10));
EditTable model = this.CreateModel(
nameof(AddWithExpressionAsync),
"MyTable",
TableChangeType.Add,
ValueExpression.Expression("{id: Local.NewId}"));
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert - Variable should contain the newly added record
VerifyModel(model, action);
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(10, idValue.Value);
}
[Fact]
public async Task RemoveWithNonTableValueAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
this.State.Set("MyTable", tableValue);
// Try to remove using a non-table value (should not throw, just not remove anything)
EditTable model = this.CreateModel(
nameof(RemoveWithNonTableValueAsync),
"MyTable",
TableChangeType.Remove,
new RecordDataValue([new("id", new NumberDataValue(1))]));
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert - table should remain unchanged since value is not a TableDataValue
VerifyModel(model, action);
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Equal(2, resultTable.Rows.Count());
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
TableChangeType changeType,
DataValue? value)
{
// Arrange
EditTable model = this.CreateModel(displayName, variableName, changeType, value);
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
DataValue? value)
{
ValueExpression.Builder? valueExpressionBuilder = value switch
{
null => null,
_ => new ValueExpression.Builder(ValueExpression.Literal(value))
};
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
ValueExpression valueExpression)
{
ValueExpression.Builder valueExpressionBuilder = new(valueExpression);
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
ValueExpression.Builder? valueExpression)
{
EditTable.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)),
ChangeType = TableChangeTypeWrapper.Get(changeType),
Value = valueExpression,
};
return AssignParent<EditTable>(actionBuilder);
}
}
@@ -0,0 +1,344 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="EditTableV2Executor"/>.
/// </summary>
public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModelNullItemsVariable()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelNullItemsVariable)),
ItemsVariable = null,
ChangeType = new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test")))
}.Build()
}.Build();
// Act, Assert
DeclarativeModelException exception = Assert.Throws<DeclarativeModelException>(() => new EditTableV2Executor(model, this.State));
Assert.Contains("required", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InvalidModelVariableNotTableAsync()
{
// Arrange
this.State.Set("NotATable", FormulaValue.New("I am a string"));
EditTableV2 model = this.CreateModel(
nameof(InvalidModelVariableNotTableAsync),
"NotATable",
new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test")))
}.Build());
EditTableV2Executor action = new(model, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task InvalidModelAddItemOperationNullValueAsync()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelAddItemOperationNullValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new AddItemOperation.Builder
{
Value = null
}.Build()
}.Build();
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act, Assert
EditTableV2Executor action = new(model, this.State);
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task InvalidModelRemoveItemOperationNullValueAsync()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelRemoveItemOperationNullValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new RemoveItemOperation.Builder
{
Value = null
}.Build()
}.Build();
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act, Assert
EditTableV2Executor action = new(model, this.State);
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task RemoveItemOperationNonTableValueAsync()
{
// Arrange
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1);
this.State.Set("TestTable", tableValue);
// Set a string value instead of a table for removal
this.State.Set("RemoveItems", FormulaValue.New("NotATable"));
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(RemoveItemOperationNonTableValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new RemoveItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems")))
}.Build()
}.Build();
// Act
EditTableV2Executor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert: When the remove value is not a table, no removal occurs, so the table should be unchanged
FormulaValue value = this.State.Get("TestTable");
Assert.IsAssignableFrom<TableValue>(value);
TableValue resultTable = (TableValue)value;
Assert.Single(resultTable.Rows);
}
[Fact]
public async Task AddItemOperationWithSingleFieldRecordAsync()
{
// Arrange: Create an empty table with single field
RecordType recordType = RecordType.Empty().Add("Name", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(AddItemOperationWithSingleFieldRecordAsync),
variableName: "TestTable",
changeType: this.CreateAddItemOperation(new RecordDataValue.Builder
{
Properties =
{
["Name"] = new StringDataValue("John")
}
}.Build()),
verifyAction: (variableName, recordValue) =>
Assert.Equal("John", recordValue.GetField("Name").ToObject())
);
}
[Fact]
public async Task AddItemOperationWithScalarValueAsync()
{
// Arrange: Create an empty table with single field
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(AddItemOperationWithScalarValueAsync),
variableName: "TestTable",
changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")),
verifyAction: (variableName, recordValue) =>
Assert.Equal("TestValue", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task ClearItemsOperationAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<BlankValue>(
displayName: nameof(ClearItemsOperationAsync),
variableName: "TestTable",
changeType: new ClearItemsOperation.Builder().Build());
}
[Fact]
public async Task RemoveItemOperationAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<BlankValue>(
displayName: nameof(RemoveItemOperationAsync),
variableName: "TestTable",
changeType: this.CreateRemoveItemOperation("Item1"));
}
[Fact]
public async Task TakeLastItemOperationWithItemsAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(TakeLastItemOperationWithItemsAsync),
variableName: "TestTable",
changeType: new TakeLastItemOperation.Builder().Build(),
verifyAction: (variableName, recordValue) =>
Assert.Equal("Item3", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task TakeLastItemOperationEmptyTableAsync()
{
// Arrange: Create an empty table
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<TableValue>(
displayName: nameof(TakeLastItemOperationEmptyTableAsync),
variableName: "TestTable",
changeType: new TakeLastItemOperation.Builder().Build());
}
[Fact]
public async Task TakeFirstItemOperationWithItemsAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(TakeFirstItemOperationWithItemsAsync),
variableName: "TestTable",
changeType: new TakeFirstItemOperation.Builder().Build(),
verifyAction: (variableName, recordValue) =>
Assert.Equal("Item1", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task TakeFirstItemOperationEmptyTableAsync()
{
// Arrange: Create an empty table
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<TableValue>(
displayName: nameof(TakeFirstItemOperationEmptyTableAsync),
variableName: "TestTable",
changeType: new TakeFirstItemOperation.Builder().Build());
}
private async Task ExecuteTestAsync<TValue>(
string displayName,
string variableName,
EditTableOperation changeType,
Action<string, TValue>? verifyAction = null) where TValue : FormulaValue
{
// Arrange
EditTableV2 model = this.CreateModel(displayName, variableName, changeType);
EditTableV2Executor action = new(model, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue value = this.State.Get(variableName);
TValue typedValue = Assert.IsAssignableFrom<TValue>(value);
verifyAction?.Invoke(variableName, typedValue);
}
private EditTableV2 CreateModel(string displayName, string variableName, EditTableOperation changeType)
{
EditTableV2.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)),
ChangeType = changeType
};
return AssignParent<EditTableV2>(actionBuilder);
}
private AddItemOperation CreateAddItemOperation(DataValue value)
{
return new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(value))
}.Build();
}
private RemoveItemOperation CreateRemoveItemOperation(string itemValue)
{
// Create a table with the item to remove
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue recordToRemove = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New(itemValue)));
TableValue tableToRemove = FormulaValue.NewTable(recordType, recordToRemove);
// Store in state for expression evaluation
this.State.Set("RemoveItems", tableToRemove);
this.State.Bind();
return new RemoveItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems")))
}.Build();
}
}
@@ -0,0 +1,625 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ForeachExecutor"/>.
/// </summary>
public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void ForeachThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => 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<string, DataValue>("item", new NumberDataValue(1))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(2))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("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 ForeachTakeNextWithMultiFieldRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice")),
new KeyValuePair<string, DataValue>("role", new StringDataValue("Engineer"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithMultiFieldRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
}
/// <summary>
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
/// the loop value must expose the bare scalar, not the single-column wrapper record.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
FormulaValue currentValue = this.State.Get(CurrentValueName);
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
Assert.Equal(1m, currentValue.ToObject());
}
/// <summary>
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
/// they are preserved as records so the field name remains accessible inside the loop body.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
}
[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");
}
/// <summary>
/// Regression test for GH-5009: a <see cref="ForeachExecutor"/> that is re-instantiated
/// during checkpoint restore (e.g. cross-process resume after a <c>Question</c> inside the
/// loop body) must continue iterating from where it left off, not exit after the first
/// iteration.
/// </summary>
[Fact]
public async Task ForeachStateRestoredAcrossCheckpointAsync()
{
// Arrange — a 3-item source table and a freshly-bound foreach executor (instance A).
const string SourceVariableName = "SourceArray";
this.SetVariableState("CurrentValue");
this.State.Set(
SourceVariableName,
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)))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachStateRestoredAcrossCheckpointAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable(SourceVariableName)),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor instanceA = new(model, this.State);
// Drive instance A through ExecuteAsync (initializes _values/_index) and one TakeNextAsync
// so that _index advances to 1 and HasValue is true — the state at the point a Question
// inside the loop body would pause the workflow and trigger a checkpoint.
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
Assert.True(instanceA.HasValue, "Instance A should have a current item after the first TakeNextAsync.");
// Act 1 — instance A persists checkpoint state.
InMemoryWorkflowContext checkpoint = new();
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
// Act 2 — a fresh instance B (simulating cross-process resume) restores from the checkpoint.
ForeachExecutor instanceB = new(model, this.State);
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
// Assert — HasValue carries over so the routing predicate after loopId continues to take
// the "loop body" edge instead of falling through to the loop continuation.
Assert.True(instanceB.HasValue, "Restored instance should report HasValue == true at the checkpointed cursor.");
// Drive iteration 2 and 3 through instance B; both should succeed.
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 2 (value=20).");
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 3 (value=30).");
// Driving past the end exits the loop normally.
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.False(instanceB.HasValue, "Restored instance should report HasValue == false after exhausting all items.");
}
/// <summary>
/// When no checkpoint state has been written for the executor (e.g. first run), the restore
/// hook must be a no-op and leave constructor defaults in place.
/// </summary>
[Fact]
public async Task ForeachRestoreWithNoSavedStateAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
Foreach model = this.CreateModel(
displayName: nameof(ForeachRestoreWithNoSavedStateAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor executor = new(model, this.State);
InMemoryWorkflowContext emptyContext = new();
// Act — restoring against an empty context must not throw and must leave the executor
// in its constructor-default state.
await InvokeOnCheckpointRestoredAsync(executor, emptyContext);
// Assert
Assert.False(executor.HasValue);
// A subsequent TakeNextAsync (without a prior ExecuteAsync) should report no value
// because _values is still the empty constructor default.
await executor.TakeNextAsync(emptyContext, _: null, CancellationToken.None);
Assert.False(executor.HasValue);
}
/// <summary>
/// Checkpoint/restore around a foreach over an empty source must roundtrip cleanly
/// (zero-length <c>PortableValue[]</c> snapshot).
/// </summary>
[Fact]
public async Task ForeachStateSurvivesEmptyValuesAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
Foreach model = this.CreateModel(
displayName: nameof(ForeachStateSurvivesEmptyValuesAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor instanceA = new(model, this.State);
// Run ExecuteAsync (which sets _values = []) followed by one TakeNextAsync (which sets
// HasValue = false on an empty source).
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
Assert.False(instanceA.HasValue);
// Act — checkpoint and restore into a fresh instance.
InMemoryWorkflowContext checkpoint = new();
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
ForeachExecutor instanceB = new(model, this.State);
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
// Assert — restored instance must agree that the source is empty and HasValue is false.
Assert.False(instanceB.HasValue);
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.False(instanceB.HasValue);
}
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
VerifyIsDiscrete(action, isDiscrete: false);
// 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<Foreach>(actionBuilder);
}
// Reflection helpers used to invoke the `protected internal` checkpoint hooks on the executor
// base class from this test project (which is in a different assembly than Microsoft.Agents.AI.Workflows
// and is not granted InternalsVisibleTo there).
private static Task InvokeOnCheckpointingAsync(Executor executor, IWorkflowContext context) =>
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointingAsync");
private static Task InvokeOnCheckpointRestoredAsync(Executor executor, IWorkflowContext context) =>
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointRestoredAsync");
private static async Task InvokeProtectedCheckpointHookAsync(Executor executor, IWorkflowContext context, string methodName)
{
MethodInfo method = typeof(Executor).GetMethod(
methodName,
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
types: new[] { typeof(IWorkflowContext), typeof(CancellationToken) },
modifiers: null) ?? throw new InvalidOperationException($"Could not locate {methodName} on Executor.");
ValueTask invocation = (ValueTask)method.Invoke(executor, new object[] { context, CancellationToken.None })!;
await invocation;
}
/// <summary>
/// Minimal in-memory <see cref="IWorkflowContext"/> implementation used to drive the
/// checkpoint/restore overrides on <see cref="ForeachExecutor"/> directly from a unit test.
/// Records state writes in a (scope, key) dictionary and serves matching reads back. Only the
/// state-related members are exercised by the checkpoint hooks; the other members are stubbed.
/// </summary>
private sealed class InMemoryWorkflowContext : IWorkflowContext
{
private readonly Dictionary<(string? scope, string key), object?> _store = [];
public bool ConcurrentRunsEnabled => false;
public IReadOnlyDictionary<string, string>? TraceContext => null;
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
this._store[(scopeName, key)] = value;
return default;
}
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
{
return new ValueTask<T?>(typed);
}
return new ValueTask<T?>(default(T));
}
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
{
return new ValueTask<T>(typed);
}
T initial = initialStateFactory();
this._store[(scopeName, key)] = initial;
return new ValueTask<T>(initial);
}
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
HashSet<string> keys = new(
this._store.Keys
.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal))
.Select(slot => slot.key));
return new ValueTask<HashSet<string>>(keys);
}
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
foreach ((string? scope, string key) slot in this._store.Keys.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal)).ToArray())
{
this._store.Remove(slot);
}
return default;
}
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default;
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default) => default;
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default;
public ValueTask RequestHaltAsync() => default;
}
}
@@ -0,0 +1,759 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="HttpRequestExecutor"/>.
/// </summary>
public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string TestUrl = "https://api.example.com/data";
private readonly Mock<ResponseAgentProvider> _agentProvider = new(MockBehavior.Loose);
[Fact]
public void InvalidModel()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
// Act & Assert
Assert.Throws<DeclarativeModelException>(() => new HttpRequestExecutor(
new HttpRequestAction(),
mockHandler.Object,
this._agentProvider.Object,
this.State));
}
[Fact]
public void HttpRequestIsDiscreteAction()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestIsDiscreteAction),
url: TestUrl,
method: HttpMethodType.Get);
HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State);
// Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action).
VerifyIsDiscrete(action, isDiscrete: true);
}
[Fact]
public async Task HttpGetReturnsJsonObjectAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonObjectAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.IsType<RecordValue>(this.State.Get(ResponseVar), exactMatch: false);
handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl);
}
[Fact]
public async Task HttpGetReturnsPlainStringAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsPlainStringAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("not-json content"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(ResponseVar, FormulaValue.New("not-json content"));
}
[Fact]
public async Task HttpGetWithEmptyBodyYieldsBlankAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult(null));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined(ResponseVar);
}
[Fact]
public async Task HttpGetForwardsHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetForwardsHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["Authorization"] = "Bearer token",
["Accept"] = "application/json",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?["Authorization"] == "Bearer token" &&
info.Headers?["Accept"] == "application/json");
}
[Fact]
public async Task HttpPostWithJsonBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithJsonBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
jsonBody: new StringDataValue("hello"));
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Method == "POST" &&
info.BodyContentType == "application/json" &&
info.Body == "\"hello\"");
}
[Fact]
public async Task HttpPostWithRawBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithRawBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
rawBody: "raw body content",
rawContentType: "text/plain");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.BodyContentType == "text/plain" &&
info.Body == "raw body content");
}
[Fact]
public async Task HttpRequestRaisesOnErrorByDefaultAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
string longBody = new('x', 10_000);
MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - message contains status and truncation marker, bounded in length, never the full body.
Assert.Contains("500", exception.Message);
Assert.Contains("[truncated]", exception.Message);
Assert.DoesNotContain(longBody, exception.Message);
Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars.");
}
[Fact]
public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - status present, no stray "Body: ''" noise.
Assert.Contains("404", exception.Message);
Assert.DoesNotContain("Body:", exception.Message);
}
[Fact]
public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - CR/LF/TAB collapsed to spaces so the message stays on one line.
Assert.DoesNotContain("\r", exception.Message);
Assert.DoesNotContain("\n", exception.Message);
Assert.DoesNotContain("\t", exception.Message);
Assert.Contains("line1", exception.Message);
Assert.Contains("line2", exception.Message);
}
[Fact]
public async Task HttpRequestPassesTimeoutToHandlerAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 1500);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Timeout is not null &&
info.Timeout.Value == TimeSpan.FromMilliseconds(1500));
}
[Fact]
public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new OperationCanceledException());
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new InvalidOperationException("transport failure"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestStoresResponseHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
const string HeaderVar = "Headers";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestStoresResponseHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseHeadersVariable: HeaderVar);
Dictionary<string, IReadOnlyList<string>> responseHeaders = new(StringComparer.OrdinalIgnoreCase)
{
["X-Request-Id"] = ["abc-123"],
["Set-Cookie"] = ["a=1", "b=2"],
};
MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue storedHeaders = this.State.Get(HeaderVar);
Assert.IsType<RecordValue>(storedHeaders, exactMatch: false);
}
[Fact]
public async Task HttpRequestForwardsQueryParametersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsQueryParametersAsync),
url: TestUrl,
method: HttpMethodType.Get,
queryParameters: new Dictionary<string, DataValue>
{
["filter"] = StringDataValue.Create("active"),
["limit"] = NumberDataValue.Create(10),
["includeDeleted"] = BooleanDataValue.Create(false),
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.QueryParameters?.Count == 3 &&
info.QueryParameters["filter"] == "active" &&
info.QueryParameters["limit"] == "10" &&
info.QueryParameters["includeDeleted"] == "false");
}
[Fact]
public async Task HttpRequestAddsResponseToConversationAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "conv-12345";
const string ResponseBody = "response-text";
this._agentProvider
.Setup(p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>((_, message, _) => Task.FromResult(message));
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestAddsResponseToConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: ConversationId);
MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(
ConversationId,
It.Is<ChatMessage>(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HttpRequestWithoutConversationIdSkipsConversationAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestForwardsConnectionNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConnectionName = "my-connection";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsConnectionNameAsync),
url: TestUrl,
method: HttpMethodType.Get,
connectionName: ConnectionName);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.ConnectionName == ConnectionName);
}
[Fact]
public async Task HttpRequestEmptyConversationIdSkipsConversationAsync()
{
// Arrange - empty-string conversationId should be treated as unset.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "");
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestEmptyResponseBodySkipsConversationAsync()
{
// Arrange - conversationId set, but empty body should not produce a conversation message.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "conv-1");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpGetReturnsJsonArrayAsync()
{
// Arrange - exercises JsonValueKind.Array branch of ParseResponseBody.
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonArrayAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue stored = this.State.Get(ResponseVar);
Assert.IsType<TableValue>(stored, exactMatch: false);
}
[Fact]
public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync()
{
// Arrange - empty header values should be filtered out (matches GetHeaders guard).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["X-Trace"] = "trace-1",
["X-Empty"] = "",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?.ContainsKey("X-Trace") == true &&
info.Headers?.ContainsKey("X-Empty") == false);
}
[Fact]
public async Task HttpRequestZeroTimeoutNotForwardedAsync()
{
// Arrange - non-positive timeouts should not be forwarded (handler default applies).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 0);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.Timeout is null);
}
private static HttpRequestResult HttpRequestResult(
string? body,
int statusCode = 200,
bool isSuccess = true,
IReadOnlyDictionary<string, IReadOnlyList<string>>? headers = null) =>
new()
{
StatusCode = statusCode,
IsSuccessStatusCode = isSuccess,
Body = body,
Headers = headers,
};
private HttpRequestAction CreateModel(
string displayName,
string url,
HttpMethodType method,
string? responseVariable = null,
string? responseHeadersVariable = null,
IReadOnlyDictionary<string, string>? headers = null,
IReadOnlyDictionary<string, DataValue>? queryParameters = null,
string? conversationId = null,
string? connectionName = null,
DataValue? jsonBody = null,
string? rawBody = null,
string? rawContentType = null,
long? timeoutMilliseconds = null,
string? continueOnErrorStatusVariable = null,
string? continueOnErrorBodyVariable = null)
{
HttpRequestAction.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Url = new StringExpression.Builder(StringExpression.Literal(url)),
Method = new EnumExpression<HttpMethodTypeWrapper>.Builder(
EnumExpression<HttpMethodTypeWrapper>.Literal(HttpMethodTypeWrapper.Get(method))),
};
if (responseVariable is not null)
{
builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable));
}
if (responseHeadersVariable is not null)
{
builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable));
}
if (headers is not null)
{
foreach (KeyValuePair<string, string> header in headers)
{
builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value)));
}
}
if (queryParameters is not null)
{
foreach (KeyValuePair<string, DataValue> parameter in queryParameters)
{
builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value)));
}
}
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (connectionName is not null)
{
builder.Connection = new RemoteConnection.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(connectionName)),
};
}
if (jsonBody is not null)
{
builder.Body = new JsonRequestContent.Builder()
{
Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)),
};
}
else if (rawBody is not null)
{
RawRequestContent.Builder rawBuilder = new()
{
Content = new StringExpression.Builder(StringExpression.Literal(rawBody)),
};
if (rawContentType is not null)
{
rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType));
}
builder.Body = rawBuilder;
}
if (timeoutMilliseconds is not null)
{
builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value));
}
if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null)
{
ContinueOnErrorBehavior.Builder continueBuilder = new();
if (continueOnErrorStatusVariable is not null)
{
continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable));
}
if (continueOnErrorBodyVariable is not null)
{
continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable));
}
builder.ErrorHandling = continueBuilder;
}
return AssignParent<HttpRequestAction>(builder);
}
private sealed class MockHttpRequestHandler : Mock<IHttpRequestHandler>
{
private HttpRequestInfo? _lastRequest;
public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null)
{
this.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
.Returns<HttpRequestInfo, CancellationToken>((info, _) =>
{
this._lastRequest = info;
if (throwOnSend is not null)
{
throw throwOnSend;
}
return Task.FromResult(result);
});
}
public void VerifySent(Func<HttpRequestInfo, bool> predicate)
{
Assert.NotNull(this._lastRequest);
Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate.");
}
}
}
@@ -0,0 +1,358 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="InvokeAzureAgentExecutor"/>.
/// </summary>
public sealed class InvokeAzureAgentExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvokeAzureAgentThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeAzureAgentExecutor(new InvokeAzureAgent(), new CapturingAgentProvider("text"), this.State));
#region Input argument binding
[Fact]
public async Task MultipleNamedArgumentsAreAllBoundAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MultipleNamedArgumentsAreAllBoundAsync),
agentName: "BrainCombine",
arguments:
[
("a", ValueExpression.Literal(new StringDataValue("alpha"))),
("b", ValueExpression.Literal(new StringDataValue("beta"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
Assert.Equal("alpha", provider.CapturedArguments!["a"]);
Assert.Equal("beta", provider.CapturedArguments!["b"]);
}
[Fact]
public async Task RecordValuedArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
this.State.Set(
"R",
FormulaValue.NewRecordFromFields(
new NamedValue("a", FormulaValue.New("alpha")),
new NamedValue("b", FormulaValue.New("beta"))));
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(RecordValuedArgumentIsBoundAsRecordAsync),
agentName: "BrainTest",
arguments:
[
("input", ValueExpression.Variable(PropertyPath.TopicVariable("R"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
[Fact]
public async Task InlineRecordExpressionArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(InlineRecordExpressionArgumentIsBoundAsRecordAsync),
agentName: "BrainInlineRecord",
arguments:
[
("input", ValueExpression.Expression("""{ a: "alpha", b: "beta" }""")),
]);
// Act (reporter repro (b): a single argument whose value is an inline record literal)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
#endregion
#region Response object parsing
[Fact]
public async Task JsonObjectOutputAssignsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""{ "a": "alpha", "b": "beta" }""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonObjectOutputAssignsRecordAsync),
agentName: "BrainObject",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
RecordValue record = Assert.IsAssignableFrom<RecordValue>(this.State.Get("Result"));
Assert.Equal("alpha", ((StringValue)record.GetField("a")).Value);
}
[Fact]
public async Task JsonArrayOutputAssignsListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonArrayOutputAssignsListAsync),
agentName: "BrainArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Equal(2, table.Rows.Count());
}
[Fact]
public async Task EmptyJsonArrayOutputAssignsEmptyListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(EmptyJsonArrayOutputAssignsEmptyListAsync),
agentName: "BrainEmptyArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Empty(table.Rows);
}
[Fact]
public async Task JsonScalarOutputAssignsScalarAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("42");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonScalarOutputAssignsScalarAsync),
agentName: "BrainScalar",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
NumberValue number = Assert.IsAssignableFrom<NumberValue>(this.State.Get("Result"));
Assert.Equal(42d, number.Value);
}
[Fact]
public async Task MixedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha",1]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MixedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainMixedArray",
responseObjectVariable: "Result");
// Act (must not throw despite non-convertible JSON)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NestedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[[1,2],[3,4]]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NestedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainNestedArray",
responseObjectVariable: "Result");
// Act (a nested array parses but is not convertible to a workflow value — must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task PlainTextOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("hello world");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(PlainTextOutputSkipsAssignmentAsync),
agentName: "BrainText",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync),
agentName: "BrainNoOutput");
// Act & Assert (reporter repro shape — no output block; must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
}
#endregion
#region Helpers
private InvokeAzureAgent CreateModel(
string displayName,
string agentName,
IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null,
string? responseObjectVariable = null)
{
InvokeAzureAgent.Builder builder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Agent =
new AzureAgentUsage.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(agentName)),
},
};
if (arguments is not null)
{
AzureAgentInput.Builder inputBuilder = new();
foreach ((string key, ValueExpression value) in arguments)
{
inputBuilder.Arguments.Add(key, value);
}
builder.Input = inputBuilder;
}
if (responseObjectVariable is not null)
{
builder.Output =
new AzureAgentOutput.Builder
{
AutoSend = new BoolExpression.Builder(BoolExpression.Literal(false)),
ResponseObject = new InitializablePropertyPath(PropertyPath.TopicVariable(responseObjectVariable), isInitializer: false),
};
}
return AssignParent<InvokeAzureAgent>(builder);
}
/// <summary>
/// Minimal <see cref="ResponseAgentProvider"/> that returns a single configured text response and
/// captures the input arguments supplied to <see cref="InvokeAgentAsync"/>.
/// </summary>
private sealed class CapturingAgentProvider(string responseText) : ResponseAgentProvider
{
public IDictionary<string, object?>? CapturedArguments { get; private set; }
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default)
{
this.CapturedArguments = inputArguments;
return YieldAsync(responseText);
}
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N"));
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
Task.FromResult(conversationMessage);
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override async IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId,
int? limit = null,
string? after = null,
string? before = null,
bool newestFirst = false,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
private static async IAsyncEnumerable<AgentResponseUpdate> YieldAsync(string text)
{
yield return new AgentResponseUpdate(ChatRole.Assistant, text);
await Task.CompletedTask.ConfigureAwait(false);
}
}
#endregion
}
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ParseValueExecutor"/>.
/// </summary>
public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ParseRecordAsync()
{
// Arrange
RecordDataType.Builder recordBuilder =
new()
{
Properties =
{
{"key1", new PropertyInfo.Builder() { Type = DataType.String } },
}
};
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseRecordAsync)),
recordBuilder,
@"{ ""key1"": ""val1"" }",
FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1"))));
}
[Fact]
public async Task ParseTableAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseTableAsync)),
DataType.EmptyTable,
@"[""apple"",""banana"",""cat""]",
FormulaValue.NewSingleColumnTable(FormulaValue.New("apple"), FormulaValue.New("banana"), FormulaValue.New("cat")));
}
[Fact]
public async Task ParseBooleanAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseBooleanAsync)),
new BooleanDataType.Builder(),
"True",
FormulaValue.New(true));
}
[Fact]
public async Task ParseNumberAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseNumberAsync)),
new NumberDataType.Builder(),
"42",
FormulaValue.New(42));
}
[Fact]
public async Task ParseStringAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseStringAsync)),
new StringDataType.Builder(),
"Hello, World!",
FormulaValue.New("Hello, World!"));
}
private async Task ExecuteTestAsync(
string displayName,
DataType.Builder dataBuilder,
string sourceText,
FormulaValue expectedValue)
{
ParseValue model =
this.CreateModel(
displayName,
"Target",
dataBuilder,
sourceText);
// Act
ParseValueExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState("Target", expectedValue);
}
private ParseValue CreateModel(
string displayName,
string variableName,
DataType.Builder typeBuilder,
string sourceText)
{
ParseValue.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ValueType = typeBuilder,
Variable = PropertyPath.TopicVariable(variableName),
Value = new ValueExpression.Builder(ValueExpression.Literal(StringDataValue.Create(sourceText))),
};
return AssignParent<ParseValue>(actionBuilder);
}
}
@@ -0,0 +1,501 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
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.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="QuestionExecutor"/>.
/// </summary>
public sealed class QuestionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void QuestionNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string prepareStep = QuestionExecutor.Steps.Prepare(testId);
string inputStep = QuestionExecutor.Steps.Input(testId);
string captureStep = QuestionExecutor.Steps.Capture(testId);
// Assert
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Prepare)}", prepareStep);
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Input)}", inputStep);
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Capture)}", captureStep);
}
[Theory]
[InlineData(true, false)]
[InlineData("anything", false)]
[InlineData(null, true)]
public void QuestionIsComplete(object? result, bool expectIsComplete)
{
// Arrange - "Complete" result corresponds to null value
ActionExecutorResult executorResult = new(nameof(QuestionIsComplete), result);
// Act
bool isComplete = QuestionExecutor.IsComplete(executorResult);
// Assert
Assert.Equal(expectIsComplete, isComplete);
}
[Fact]
public async Task QuestionExecuteWithResultUndefinedAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithResultUndefinedAsync),
"TestVariable");
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Fact]
public async Task QuestionExecuteWithAlwaysPromptAsync()
{
// Arrange
this.State.Set("TestVariable", FormulaValue.New("existing-value"));
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithAlwaysPromptAsync),
"TestVariable",
alwaysPrompt: true);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Theory]
[InlineData(SkipQuestionMode.AlwaysSkipIfVariableHasValue)]
[InlineData(SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue)]
[InlineData(SkipQuestionMode.AlwaysAsk)]
public async Task QuestionExecuteWithSkipModeAsyncWithResultUndefinedAsync(SkipQuestionMode skipMode)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithSkipModeAsyncWithResultUndefinedAsync),
variableName: "TestVariable",
skipMode: skipMode);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Theory]
[InlineData(SkipQuestionMode.AlwaysSkipIfVariableHasValue, false)]
[InlineData(SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue, false)]
[InlineData(SkipQuestionMode.AlwaysAsk, true)]
public async Task QuestionExecuteWithSkipModeAsyncWithResultDefinedAsync(SkipQuestionMode skipMode, bool expectPrompt)
{
// Arrange
this.State.Set("TestVariable", FormulaValue.New("existing-value"));
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithSkipModeAsyncWithResultDefinedAsync),
variableName: "TestVariable",
skipMode: skipMode);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt);
}
[Fact]
public async Task QuestionPrepareResponseAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionPrepareResponseAsync),
variableName: "TestVariable",
promptText: "Provide input:");
// Act & Assert
await this.PrepareResponseTestAsync(model, expectedPrompt: "Provide input:");
}
[Fact]
public async Task QuestionCaptureResponseWithValidEntityAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithValidEntityAsync),
variableName: "TestVariable",
alwaysPrompt: true,
skipMode: SkipQuestionMode.AlwaysAsk,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "42",
expectAutoSend: true);
}
[Theory]
[InlineData(null)]
[InlineData("Invalid input, please try again.")]
public async Task QuestionCaptureResponseWithInvalidEntityAsync(string? invalidResponse)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithInvalidEntityAsync),
variableName: "TestVariable",
invalidResponseText: invalidResponse,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "not-a-number",
expectResponse: false);
}
[Theory]
[InlineData(null)]
[InlineData("Invalid input, please try again.")]
public async Task QuestionCaptureResponseWithUnrecognizedResponseAsync(string? unrecognizedResponse)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithUnrecognizedResponseAsync),
variableName: "TestVariable",
unrecognizedResponseText: unrecognizedResponse);
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: null,
expectResponse: false);
}
[Fact]
public async Task QuestionCaptureResponseWithUnsupportedPromptAsync()
{
// Arrange
Question.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(QuestionCaptureResponseWithUnsupportedPromptAsync)),
Variable = PropertyPath.Create(FormatVariablePath("TestVariable")),
Prompt = new UnknownActivityTemplateBase.Builder(),
UnrecognizedPrompt = new UnknownActivityTemplateBase.Builder(),
Entity = new StringPrebuiltEntity(),
};
Question model = actionBuilder.Build();
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: null,
expectResponse: false);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task QuestionCaptureResponseExceedingRepeatCountAsync(bool hasDefault)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseExceedingRepeatCountAsync),
variableName: "TestVariable",
repeatCount: 0,
defaultValue: hasDefault ? new NumberDataValue(0) : null,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "not-a-number",
expectResponse: false);
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendFalseAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendFalseAsync),
variableName: "TestVariable",
autoSend: new BooleanDataValue(false));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response");
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendTrueAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendTrueAsync),
variableName: "TestVariable",
autoSend: new BooleanDataValue(true));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response",
expectAutoSend: true);
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendInvalidAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendInvalidAsync),
variableName: "TestVariable",
autoSend: new NumberDataValue(33));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response");
}
[Fact]
public async Task QuestionCompleteAsync()
{
// Arrange
Question model =
this.CreateModel(
displayName: nameof(QuestionCompleteAsync),
variableName: "TestVariable");
// Act & Assert
await this.CompleteTestAsync(model);
}
private async Task ExecuteTestAsync(Question model, bool expectPrompt)
{
// Arrange
bool? sentMessage = null;
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
action,
QuestionExecutor.Steps.Capture(action.Id),
CaptureResultAsync);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
Assert.NotNull(sentMessage);
Assert.Equal(expectPrompt, sentMessage);
ValueTask CaptureResultAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
Assert.Null(sentMessage); // Should only be called once
sentMessage = message.Result is not null;
return default;
}
}
private async Task PrepareResponseTestAsync(
Question model,
string expectedPrompt)
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
string? capturedPrompt = null;
// Act
await this.ExecuteAsync(
[
action,
new DelegateActionExecutor(
QuestionExecutor.Steps.Prepare(action.Id),
this.State,
action.PrepareResponseAsync),
new DelegateActionExecutor<ExternalInputRequest>(
QuestionExecutor.Steps.Capture(action.Id),
this.State,
CaptureExternalRequestAsync)
],
isDiscrete: false);
// Assert
VerifyModel(model, action);
Assert.NotNull(capturedPrompt);
Assert.Equal(expectedPrompt, capturedPrompt);
ValueTask CaptureExternalRequestAsync(IWorkflowContext context, ExternalInputRequest request, CancellationToken cancellationToken)
{
Assert.Null(capturedPrompt);
capturedPrompt = request.AgentResponse.Text;
return default;
}
}
private async Task CaptureResponseTestAsync(
Question model,
string variableName,
string? responseText,
bool expectResponse = true,
bool expectAutoSend = false)
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("ExternalConversationId"), VariableScopeNames.System);
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((string cid, ChatMessage msg, CancellationToken ct) => msg);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
ExternalInputResponse response = responseText is not null
? new ExternalInputResponse(new ChatMessage(ChatRole.User, responseText))
: new ExternalInputResponse([]);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(
action,
QuestionExecutor.Steps.Capture(action.Id),
(context, message, cancellationToken) =>
action.CaptureResponseAsync(context, response, cancellationToken));
// Assert
VerifyModel(model, action);
if (expectResponse)
{
// Variable should be set with the extracted value
FormulaValue actualValue = this.State.Get(variableName);
Assert.Equal(responseText, actualValue.Format());
}
else
{
// Should have prompted again or sent unrecognized/invalid message
Assert.Contains(events, e => e is MessageActivityEvent);
}
if (expectAutoSend)
{
this.VerifyState(SystemScope.Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(responseText ?? string.Empty));
}
else
{
this.VerifyUndefined(SystemScope.Names.LastMessageText, VariableScopeNames.System);
}
}
private async Task CompleteTestAsync(Question model)
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(
QuestionExecutor.Steps.Input(action.Id),
action.CompleteAsync);
// Assert
VerifyModel(model, action);
VerifyCompletionEvent(events);
}
private Question CreateModel(
string displayName,
string variableName,
string promptText = "Please provide a value",
string? invalidResponseText = null,
string? unrecognizedResponseText = null,
string? defaultValueResponseText = null,
DataValue? defaultValue = null,
bool? alwaysPrompt = null,
SkipQuestionMode? skipMode = null,
int? repeatCount = null,
EntityReference? entity = null,
DataValue? autoSend = null)
{
BoolExpression.Builder? alwaysPromptExpression = null;
if (alwaysPrompt is not null)
{
alwaysPromptExpression = BoolExpression.Literal(alwaysPrompt.Value).ToBuilder();
}
IntExpression.Builder? repeatCountExpression = null;
if (repeatCount is not null)
{
repeatCountExpression = IntExpression.Literal(repeatCount.Value).ToBuilder();
}
ValueExpression.Builder? defaultValueExpression = null;
if (defaultValue is not null)
{
defaultValueExpression = ValueExpression.Literal(defaultValue).ToBuilder();
}
EnumExpression<SkipQuestionModeWrapper>.Builder? skipModeExpression = null;
if (skipMode is not null)
{
skipModeExpression = EnumExpression<SkipQuestionModeWrapper>.Literal(skipMode).ToBuilder();
}
Question.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
AlwaysPrompt = alwaysPromptExpression,
SkipQuestionMode = skipModeExpression,
Variable = PropertyPath.Create(FormatVariablePath(variableName)),
Prompt = CreateMessageActivity(promptText),
InvalidPrompt = CreateOptionalMessageActivity(invalidResponseText),
UnrecognizedPrompt = CreateOptionalMessageActivity(unrecognizedResponseText),
DefaultValue = defaultValueExpression,
DefaultValueResponse = CreateOptionalMessageActivity(defaultValueResponseText),
RepeatCount = repeatCountExpression,
Entity = entity ?? new StringPrebuiltEntity(),
};
if (autoSend is not null)
{
RecordDataValue.Builder extensionDataBuilder = new();
extensionDataBuilder.Properties.Add("autoSend", autoSend);
actionBuilder.ExtensionData = extensionDataBuilder.Build();
}
return AssignParent<Question>(actionBuilder);
}
private static MessageActivityTemplate.Builder? CreateOptionalMessageActivity(string? text) =>
text is null ? null : CreateMessageActivity(text);
private static MessageActivityTemplate.Builder CreateMessageActivity(string text) =>
new()
{
Text = { TemplateLine.Parse(text) },
};
}
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RequestExternalInputExecutor"/>.
/// </summary>
public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void RequestExternalInputNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string inputStep = RequestExternalInputExecutor.Steps.Input(testId);
string captureStep = RequestExternalInputExecutor.Steps.Capture(testId);
// Assert
Assert.Equal($"{testId}_{nameof(RequestExternalInputExecutor.Steps.Input)}", inputStep);
Assert.Equal($"{testId}_{nameof(RequestExternalInputExecutor.Steps.Capture)}", captureStep);
}
[Fact]
public async Task ExecuteRequestsExternalInputAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ExecuteRequestsExternalInputAsync),
variableName: "TestVariable");
}
[Fact]
public async Task CaptureResponseWithVariableAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithVariableAsync),
variableName: "TestVariable");
}
[Fact]
public async Task CaptureResponseWithoutVariableAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithoutVariableAsync),
variableName: null);
}
[Fact]
public async Task CaptureResponseWithMultipleMessagesAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithMultipleMessagesAsync),
variableName: "TestVariable",
messageCount: 3);
}
[Fact]
public async Task CaptureResponseWithWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithWorkflowConversationAsync),
variableName: "TestVariable",
messageCount: 2,
expectMessagesCreated: true);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAsync()
{
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAsync),
variableName: "TestVariable",
messageCount: 0);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync),
variableName: "TestVariable",
messageCount: 0,
expectMessagesCreated: false);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)
{
MockAgentProvider mockAgentProvider = new();
RequestExternalInput model = this.CreateModel(displayName, variableName);
RequestExternalInputExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
}
private async Task CaptureResponseTestAsync(
string displayName,
string? variableName = null,
int messageCount = 1,
bool expectMessagesCreated = false)
{
// Arrange
RequestExternalInput model = this.CreateModel(displayName, variableName);
MockAgentProvider mockAgentProvider = new();
RequestExternalInputExecutor action = new(model, mockAgentProvider.Object, this.State);
// Create test messages
List<ChatMessage> testMessages = [];
for (int i = 0; i < messageCount; i++)
{
testMessages.Add(new ChatMessage(ChatRole.User, $"Test message {i + 1}"));
}
ExternalInputResponse response = new(testMessages);
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
RequestExternalInputExecutor.Steps.Capture(action.Id),
(context, message, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken));
// Assert
VerifyModel(model, action);
VerifyCompletionEvent(events);
// Verify messages were created in the workflow conversation if expected
mockAgentProvider.Verify(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()), Times.Exactly(expectMessagesCreated ? messageCount : 0));
// Verify the variable was set correctly
if (variableName is not null)
{
this.VerifyState(variableName, testMessages.ToTable());
}
}
private RequestExternalInput CreateModel(string displayName, string? variablePath)
{
RequestExternalInput.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = variablePath is null ? null : (InitializablePropertyPath?)PropertyPath.Create(FormatVariablePath(variablePath)),
};
return AssignParent<RequestExternalInput>(actionBuilder);
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ResetVariableExecutor"/>.
/// </summary>
public sealed class ResetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ResetDefinedValueAsync()
{
// Arrange
this.State.Set("MyVar1", FormulaValue.New("Value #1"));
this.State.Set("MyVar2", FormulaValue.New("Value #2"));
ResetVariable model =
this.CreateModel(
this.FormatDisplayName(nameof(ResetDefinedValueAsync)),
FormatVariablePath("MyVar1"));
// Act
ResetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("MyVar1");
this.VerifyState("MyVar2", FormulaValue.New("Value #2"));
}
[Fact]
public async Task ResetUndefinedValueAsync()
{
// Arrange
this.State.Set("MyVar1", FormulaValue.New("Value #1"));
ResetVariable model =
this.CreateModel(
this.FormatDisplayName(nameof(ResetUndefinedValueAsync)),
FormatVariablePath("NoVar"));
// Act
ResetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("NoVar");
this.VerifyState("MyVar1", FormulaValue.New("Value #1"));
}
private ResetVariable CreateModel(string displayName, string variablePath)
{
ResetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(variablePath),
};
return AssignParent<ResetVariable>(actionBuilder);
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RetrieveConversationMessageExecutor"/>.
/// </summary>
public sealed class RetrieveConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task RetrieveMessageSuccessfullyAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(RetrieveMessageSuccessfullyAsync),
"TestMessage");
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
RetrieveConversationMessage model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
"TestConversationId",
"DefaultMessageId");
RetrieveConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
ChatMessage? testMessage = mockAgentProvider.TestMessages?.FirstOrDefault();
Assert.NotNull(testMessage);
VerifyModel(model, action);
this.VerifyState(variableName, testMessage.ToRecord());
}
private RetrieveConversationMessage CreateModel(
string displayName,
string messageVariable,
string conversationId,
string messageId)
{
RetrieveConversationMessage.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Message = PropertyPath.Create(messageVariable),
ConversationId = StringExpression.Literal(conversationId),
MessageId = StringExpression.Literal(messageId)
};
return AssignParent<RetrieveConversationMessage>(actionBuilder);
}
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RetrieveConversationMessagesExecutor"/>.
/// </summary>
public sealed class RetrieveConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task RetrieveAllMessagesSuccessfullyAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
nameof(RetrieveAllMessagesSuccessfullyAsync),
"TestMessages",
"TestConversationId");
}
[Fact]
public async Task RetrieveMessagesWithOptionalValuesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
nameof(RetrieveMessagesWithOptionalValuesAsync),
"TestMessages",
"TestConversationId",
limit: IntExpression.Literal(2),
after: StringExpression.Literal("11/01/2025"),
before: StringExpression.Literal("12/01/2025"),
sortOrder: EnumExpression<AgentMessageSortOrderWrapper>.Literal(AgentMessageSortOrderWrapper.Get(AgentMessageSortOrder.NewestFirst)));
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
string conversationId,
IntExpression? limit = null,
StringExpression? after = null,
StringExpression? before = null,
EnumExpression<AgentMessageSortOrderWrapper>? sortOrder = null)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
RetrieveConversationMessages model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
conversationId,
limit,
after,
before,
sortOrder);
RetrieveConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
var testMessages = mockAgentProvider.TestMessages;
Assert.NotNull(testMessages);
VerifyModel(model, action);
this.VerifyState(variableName, testMessages.ToTable());
}
private RetrieveConversationMessages CreateModel(
string displayName,
string variableName,
string conversationId,
IntExpression? limit,
StringExpression? after,
StringExpression? before,
EnumExpression<AgentMessageSortOrderWrapper>? sortOrder)
{
RetrieveConversationMessages.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Messages = PropertyPath.Create(variableName),
ConversationId = StringExpression.Literal(conversationId)
};
if (limit is not null)
{
actionBuilder.Limit = limit;
}
if (after is not null)
{
actionBuilder.MessageAfter = after;
}
if (before is not null)
{
actionBuilder.MessageBefore = before;
}
if (sortOrder is not null)
{
actionBuilder.SortOrder = sortOrder;
}
return AssignParent<RetrieveConversationMessages>(actionBuilder);
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SendActivityExecutor"/>.
/// </summary>
public sealed class SendActivityExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CaptureActivityAsync()
{
// Arrange
SendActivity model =
this.CreateModel(
this.FormatDisplayName(nameof(CaptureActivityAsync)),
"Test activity message");
// Act
SendActivityExecutor action = new(model, this.State);
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.Contains(events, e => e is MessageActivityEvent);
// The executor must also emit an AgentResponseEvent carrying the activity text
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
Assert.Equal(action.Id, agentEvent.ExecutorId);
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);
}
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
{
MessageActivityTemplate.Builder activityBuilder =
new()
{
Summary = summary,
Text = { TemplateLine.Parse(activityMessage) },
};
SendActivity.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Activity = activityBuilder.Build(),
};
return AssignParent<SendActivity>(actionBuilder);
}
}
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetMultipleVariablesExecutor"/>.
/// </summary>
public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task SetMultipleVariablesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesAsync),
assignments: [
new AssignmentCase("Variable1", new NumberDataValue(42), FormulaValue.New(42)),
new AssignmentCase("Variable2", new StringDataValue("Test"), FormulaValue.New("Test")),
new AssignmentCase("Variable3", new BooleanDataValue(true), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithExpressionsAsync()
{
// Arrange
this.State.Set("SourceNumber", FormulaValue.New(10));
this.State.Set("SourceText", FormulaValue.New("Hello"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithExpressionsAsync),
assignments: [
new AssignmentCase("CalcVariable", ValueExpression.Expression("Local.SourceNumber * 2"), FormulaValue.New(20)),
new AssignmentCase("ConcatVariable", ValueExpression.Expression(@"Concatenate(Local.SourceText, "" World"")"), FormulaValue.New("Hello World")),
new AssignmentCase("BoolVariable", ValueExpression.Expression("Local.SourceNumber > 5"), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithVariableReferencesAsync()
{
// Arrange
this.State.Set("Source1", FormulaValue.New(123));
this.State.Set("Source2", FormulaValue.New("Reference"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithVariableReferencesAsync),
assignments: [
new AssignmentCase("Target1", ValueExpression.Variable(PropertyPath.TopicVariable("Source1")), FormulaValue.New(123)),
new AssignmentCase("Target2", ValueExpression.Variable(PropertyPath.TopicVariable("Source2")), FormulaValue.New("Reference"))
]);
}
[Fact]
public async Task SetMultipleVariablesWithNullValuesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithNullValuesAsync),
assignments: [
new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()),
new AssignmentCase("NormalVar", new StringDataValue("NotNull"), FormulaValue.New("NotNull")),
new AssignmentCase("NullVar2", null, FormulaValue.NewBlank())
]);
}
[Fact]
public async Task SetMultipleVariablesWithNullVariableAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithNullVariableAsync),
assignments: [
new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()),
new AssignmentCase(null, new StringDataValue("NotNull"), FormulaValue.New("NotNull")),
new AssignmentCase("NullVar2", null, FormulaValue.NewBlank())
]);
}
[Fact]
public async Task SetMultipleVariablesUpdateExistingAsync()
{
// Arrange
this.State.Set("ExistingVar1", FormulaValue.New(999));
this.State.Set("ExistingVar2", FormulaValue.New("OldValue"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesUpdateExistingAsync),
assignments: [
new AssignmentCase("ExistingVar1", new NumberDataValue(111), FormulaValue.New(111)),
new AssignmentCase("ExistingVar2", new StringDataValue("NewValue"), FormulaValue.New("NewValue")),
new AssignmentCase("NewVar", new BooleanDataValue(false), FormulaValue.New(false))
]);
}
[Fact]
public async Task SetMultipleVariablesEmptyAssignmentsAsync()
{
// Arrange
SetMultipleVariables model = this.CreateModel(nameof(SetMultipleVariablesEmptyAssignmentsAsync), []);
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() =>
{
// Empty variables assignment should fail RequiredProperties validation.
_ = new SetMultipleVariablesExecutor(model, this.State);
});
}
private async Task ExecuteTestAsync(string displayName, AssignmentCase[] assignments)
{
// Arrange
SetMultipleVariables model = this.CreateModel(displayName, assignments);
// Act
SetMultipleVariablesExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
foreach (AssignmentCase assignment in assignments.Where(a => a.VariableName != null))
{
this.VerifyState(assignment.VariableName!, assignment.ExpectedValue);
}
}
private SetMultipleVariables CreateModel(string displayName, AssignmentCase[] assignments)
{
SetMultipleVariables.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
};
foreach (AssignmentCase assignment in assignments)
{
ValueExpression.Builder? valueExpressionBuilder = assignment.ValueExpression switch
{
null => null,
DataValue dataValue => new ValueExpression.Builder(ValueExpression.Literal(dataValue)),
ValueExpression valueExpression => new ValueExpression.Builder(valueExpression),
_ => throw new System.ArgumentException($"Unsupported value type: {assignment.ValueExpression?.GetType().Name}")
};
InitializablePropertyPath? variablePath = null;
if (assignment.VariableName != null)
{
variablePath = PropertyPath.Create(FormatVariablePath(assignment.VariableName));
}
actionBuilder.Assignments.Add(new VariableAssignment.Builder()
{
Variable = variablePath,
Value = valueExpressionBuilder,
});
}
return AssignParent<SetMultipleVariables>(actionBuilder);
}
private sealed record AssignmentCase(string? VariableName, object? ValueExpression, FormulaValue ExpectedValue);
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetTextVariableExecutor"/>.
/// </summary>
public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task SetLiteralValueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(SetLiteralValueAsync)),
"TextVar",
"New value");
}
[Fact]
public async Task UpdateExistingValueAsync()
{
// Arrange
this.State.Set("TextVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(UpdateExistingValueAsync)),
"TextVar",
"New value");
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
string textValue)
{
// Arrange
SetTextVariable model =
this.CreateModel(
displayName,
variableName,
textValue);
// Act
SetTextVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(variableName, FormulaValue.New(textValue));
}
private SetTextVariable CreateModel(string displayName, string variablePath, string textValue)
{
SetTextVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(FormatVariablePath(variablePath)),
Value = TemplateLine.Parse(textValue),
};
return AssignParent<SetTextVariable>(actionBuilder);
}
}
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetVariableExecutor"/>.
/// </summary>
public sealed class SetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModel() =>
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() => new SetVariableExecutor(new SetVariable(), this.State));
[Fact]
public async Task SetNumericValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetNumericValueAsync),
variableName: "TestVariable",
variableValue: new NumberDataValue(42),
expectedValue: FormulaValue.New(42));
[Fact]
public async Task SetStringValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetStringValueAsync),
variableName: "TestVariable",
variableValue: new StringDataValue("Text"),
expectedValue: FormulaValue.New("Text"));
[Fact]
public async Task SetBooleanValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanValueAsync),
variableName: "TestVariable",
variableValue: new BooleanDataValue(true),
expectedValue: FormulaValue.New(true));
[Fact]
public async Task SetBooleanExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("true || false"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(true));
}
[Fact]
public async Task SetNumberExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("9 - 3"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(6));
}
[Fact]
public async Task SetStringExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression(@"Concatenate(""A"", ""B"", ""C"")"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New("ABC"));
}
[Fact]
public async Task SetBooleanVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New(true));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(true));
}
[Fact]
public async Task SetNumberVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New(321));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(321));
}
[Fact]
public async Task SetStringVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New("Test"));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New("Test"));
}
[Fact]
public async Task UpdateExistingValueAsync()
{
// Arrange
this.State.Set("VarA", FormulaValue.New(33));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(UpdateExistingValueAsync),
variableName: "VarA",
variableValue: new NumberDataValue(42),
expectedValue: FormulaValue.New(42));
}
private Task ExecuteTestAsync(
string displayName,
string variableName,
DataValue variableValue,
FormulaValue expectedValue)
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Literal(variableValue));
// Act & Assert
return this.ExecuteTestAsync(displayName, variableName, expressionBuilder, expectedValue);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
ValueExpression.Builder valueExpression,
FormulaValue expectedValue)
{
// Arrange
SetVariable model =
this.CreateModel(
displayName,
FormatVariablePath(variableName),
valueExpression);
this.State.Set(variableName, FormulaValue.New(33));
// Act
SetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(variableName, expectedValue);
}
private SetVariable CreateModel(string displayName, string variablePath, ValueExpression.Builder valueExpression)
{
SetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(variablePath),
Value = valueExpression,
};
return AssignParent<SetVariable>(actionBuilder);
}
}
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
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;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Base test class for <see cref="DeclarativeActionExecutor"/> implementations.
/// </summary>
public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : WorkflowTest(output)
{
internal WorkflowFormulaState State { get; } = new(RecalcEngineFactory.Create());
protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}");
protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}";
internal Task<WorkflowEvent[]> ExecuteAsync(string actionId, DelegateAction<ActionExecutorResult> executorAction) =>
this.ExecuteAsync([new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false);
internal Task<WorkflowEvent[]> ExecuteAsync(Executor executor, string actionId, DelegateAction<ActionExecutorResult> executorAction) =>
this.ExecuteAsync([executor, new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false);
internal async Task<WorkflowEvent[]> ExecuteAsync(DeclarativeActionExecutor executor, bool isDiscrete = true)
{
VerifyIsDiscrete(executor, isDiscrete);
return await this.ExecuteAsync([executor], isDiscrete);
}
internal async Task<WorkflowEvent[]> ExecuteAsync(Executor[] executors, bool isDiscrete)
{
this.State.Bind();
TestWorkflowExecutor workflowExecutor = new();
WorkflowBuilder workflowBuilder = new(workflowExecutor);
Executor prevExecutor = workflowExecutor;
foreach (Executor executor in executors)
{
workflowBuilder.AddEdge(prevExecutor, executor);
prevExecutor = executor;
}
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflowBuilder.Build(), this.State);
WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync();
if (isDiscrete)
{
VerifyInvocationEvent(events);
VerifyCompletionEvent(events);
}
ExecutorFailedEvent[] failureEvents = events.OfType<ExecutorFailedEvent>().ToArray();
switch (failureEvents.Length)
{
case 0:
break;
case 1:
throw failureEvents[0].Data ?? new XunitException("Executor failed without exception data.");
default:
AggregateException aggregateException = new("One or more executor failures occurred.", failureEvents.Select(e => e.Data).Where(e => e is not null).Cast<Exception>());
throw aggregateException;
}
return events;
}
internal static void VerifyModel(DialogAction model, DeclarativeActionExecutor action)
{
Assert.Equal(model.Id, action.Id);
Assert.Equal(model, action.Model);
}
internal static void VerifyIsDiscrete(DeclarativeActionExecutor action, bool isDiscrete = true)
{
Assert.Equal(
isDiscrete,
action.GetType().BaseType?
.GetProperty("IsDiscreteAction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
.GetValue(action));
}
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);
protected void VerifyState(string variableName, string scopeName, FormulaValue expectedValue)
{
FormulaValue actualValue = this.State.Get(variableName, scopeName);
Assert.Equal(expectedValue.Format(), actualValue.Format());
}
protected void VerifyUndefined(string variableName, string? scopeName = null) =>
Assert.IsType<BlankValue>(this.State.Get(variableName, scopeName));
protected static TAction AssignParent<TAction>(DialogAction.Builder actionBuilder) where TAction : DialogAction
{
OnActivity.Builder activityBuilder =
new()
{
Id = new("root"),
};
activityBuilder.Actions.Add(actionBuilder);
OnActivity model = activityBuilder.Build();
return (TAction)model.Actions[0];
}
internal sealed class TestWorkflowExecutor() : Executor<WorkflowFormulaState>("test_workflow")
{
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context, CancellationToken cancellationToken) =>
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}