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
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:
+142
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
+241
@@ -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;
|
||||
}
|
||||
}
|
||||
+201
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+440
@@ -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);
|
||||
}
|
||||
}
|
||||
+344
@@ -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();
|
||||
}
|
||||
}
|
||||
+625
@@ -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;
|
||||
}
|
||||
}
|
||||
+759
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+358
@@ -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
|
||||
}
|
||||
+1418
File diff suppressed because it is too large
Load Diff
+1852
File diff suppressed because it is too large
Load Diff
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
+501
@@ -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) },
|
||||
};
|
||||
}
|
||||
+182
@@ -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);
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+112
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+171
@@ -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);
|
||||
}
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
+201
@@ -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);
|
||||
}
|
||||
}
|
||||
+131
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user