chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
public class DeclarativeWorkflowContextTests
{
[Fact]
public void InitializeDefaultValues()
{
// Act
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Strict);
DeclarativeWorkflowOptions context = new(mockProvider.Object);
// Assert
Assert.Equal(mockProvider.Object, context.AgentProvider);
Assert.Null(context.MaximumCallDepth);
Assert.Null(context.MaximumExpressionLength);
Assert.Same(NullLoggerFactory.Instance, context.LoggerFactory);
}
[Fact]
public void InitializeExplicitValues()
{
// Arrange
TokenCredential credentials = new DefaultAzureCredential();
const int MaxCallDepth = 10;
const int MaxExpressionLength = 100;
ILoggerFactory loggerFactory = LoggerFactory.Create(builder => { });
// Act
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Strict);
DeclarativeWorkflowOptions context = new(mockProvider.Object)
{
MaximumCallDepth = MaxCallDepth,
MaximumExpressionLength = MaxExpressionLength,
LoggerFactory = loggerFactory
};
// Assert
Assert.Equal(mockProvider.Object, context.AgentProvider);
Assert.Equal(MaxCallDepth, context.MaximumCallDepth);
Assert.Equal(MaxExpressionLength, context.MaximumExpressionLength);
Assert.Same(loggerFactory, context.LoggerFactory);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Tests declarative workflow exceptions.
/// </summary>
public sealed class DeclarativeWorkflowExceptionTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Fact]
public void WorkflowExecutionException()
{
AssertDefault<DeclarativeActionException>(() => throw new DeclarativeActionException());
AssertMessage<DeclarativeActionException>((message) => throw new DeclarativeActionException(message));
AssertInner<DeclarativeActionException>((message, inner) => throw new DeclarativeActionException(message, inner));
}
[Fact]
public void WorkflowModelException()
{
AssertDefault<DeclarativeModelException>(() => throw new DeclarativeModelException());
AssertMessage<DeclarativeModelException>((message) => throw new DeclarativeModelException(message));
AssertInner<DeclarativeModelException>((message, inner) => throw new DeclarativeModelException(message, inner));
}
private static void AssertDefault<TException>(Action throwAction) where TException : Exception
{
TException exception = Assert.Throws<TException>(throwAction.Invoke);
Assert.NotEmpty(exception.Message);
Assert.Null(exception.InnerException);
}
private static void AssertMessage<TException>(Action<string> throwAction) where TException : Exception
{
const string Message = "Test exception message";
TException exception = Assert.Throws<TException>(() => throwAction.Invoke(Message));
Assert.Equal(Message, exception.Message);
Assert.Null(exception.InnerException);
}
private static void AssertInner<TException>(Action<string, Exception> throwAction) where TException : Exception
{
const string Message = "Test exception message";
NotSupportedException innerException = new("Inner exception message");
TException exception = Assert.Throws<TException>(() => throwAction.Invoke(Message, innerException));
Assert.Equal(Message, exception.Message);
Assert.Equal(innerException, exception.InnerException);
}
}
@@ -0,0 +1,163 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
public sealed partial class DeclarativeWorkflowJsonOptionsTests(ITestOutputHelper output)
: WorkflowTest(output)
{
[Theory]
[InlineData(typeof(ActionExecutorResult))]
[InlineData(typeof(ExternalInputRequest))]
[InlineData(typeof(ExternalInputResponse))]
[InlineData(typeof(UnassignedValue))]
[InlineData(typeof(List<string>))]
// Internal approval-snapshot types: catches source-gen regressions from rename/move of the nested types.
[InlineData(typeof(InvokeFunctionToolExecutor.ApprovalSnapshot))]
[InlineData(typeof(Dictionary<string, InvokeFunctionToolExecutor.ApprovalSnapshot>))]
[InlineData(typeof(InvokeMcpToolExecutor.ApprovalSnapshot))]
[InlineData(typeof(Dictionary<string, InvokeMcpToolExecutor.ApprovalSnapshot>))]
public void DefaultOptions_HasTypeInfoForRegisteredType(Type type)
{
Assert.True(
DeclarativeWorkflowJsonOptions.Default.TryGetTypeInfo(type, out _),
$"Default should resolve JsonTypeInfo for {type.FullName}.");
}
[Fact]
public void ActionExecutorResult_RoundTrip_NullResult()
{
ActionExecutorResult copy = RoundTrip(new ActionExecutorResult("exec-1"));
Assert.Equal("exec-1", copy.ExecutorId);
Assert.Null(copy.Result);
}
[Fact]
public void ExternalInputRequest_RoundTrip_WithApprovalAndFunctionCallContent()
{
ChatMessage requestMessage = new(
ChatRole.Assistant,
[
new ToolApprovalRequestContent("call1", new FunctionCallContent("call1", "do-something")),
new FunctionCallContent("call2", "do-other"),
]);
ExternalInputRequest copy = RoundTrip(new ExternalInputRequest(new AgentResponse(requestMessage)));
ChatMessage messageCopy = Assert.Single(copy.AgentResponse.Messages);
Assert.Equal(2, messageCopy.Contents.Count);
Assert.Contains(messageCopy.Contents, c => c is ToolApprovalRequestContent);
Assert.Contains(messageCopy.Contents, c => c is FunctionCallContent);
// GetInnerRequestContent prefers ToolApprovalRequestContent over FunctionCallContent.
AIContent? inner = ((IExternalRequestEnvelope)copy).GetInnerRequestContent();
Assert.IsType<ToolApprovalRequestContent>(inner);
}
[Fact]
public void ExternalInputResponse_RoundTrip()
{
ExternalInputResponse copy = RoundTrip(new ExternalInputResponse(new ChatMessage(ChatRole.User, "ok")));
ChatMessage messageCopy = Assert.Single(copy.Messages);
Assert.Equal(ChatRole.User, messageCopy.Role);
Assert.Equal("ok", messageCopy.Text);
}
[Fact]
public void UnassignedValue_RoundTrip()
{
Assert.NotNull(RoundTrip(UnassignedValue.Instance));
}
// Proves declarative types fail under a source-gen-only resolver that lacks the declarative
// context. Mirrors AOT runtime behavior in a non-AOT test project.
[Fact]
public void Serialization_WithoutDeclarativeChain_FailsOnDeclarativeType()
{
ActionExecutorResult source = new("exec-1");
JsonSerializerOptions bareOptions = new() { TypeInfoResolver = EmptyJsonContext.Default };
Assert.False(
bareOptions.TryGetTypeInfo(typeof(ActionExecutorResult), out _),
$"Test is meaningless if the bare resolver already covers {nameof(ActionExecutorResult)}.");
NotSupportedException ex = Assert.Throws<NotSupportedException>(
() => JsonSerializer.Serialize(source, bareOptions));
Assert.Contains(nameof(ActionExecutorResult), ex.Message, StringComparison.Ordinal);
}
[Fact]
public void Serialization_WithDeclarativeChain_SucceedsOnDeclarativeType()
{
string text = JsonSerializer.Serialize(new ActionExecutorResult("exec-1"), DeclarativeWorkflowJsonOptions.Default);
Assert.Contains("exec-1", text, StringComparison.Ordinal);
}
// Mirrors the documented public usage: CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default).
[Fact]
public void CheckpointManager_CreateJson_WithDefault_SmokeTest()
{
InMemoryJsonStore store = new();
CheckpointManager manager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default);
Assert.NotNull(manager);
}
// Empty context for the negative test; `string` is harmless filler required for a valid context.
[JsonSerializable(typeof(string))]
internal sealed partial class EmptyJsonContext : JsonSerializerContext;
private sealed class InMemoryJsonStore : JsonCheckpointStore
{
private readonly Dictionary<CheckpointInfo, JsonElement> _store = [];
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(
string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
CheckpointInfo key = new(sessionId, Guid.NewGuid().ToString("N"));
this._store[key] = value;
return new(key);
}
public override ValueTask<JsonElement> RetrieveCheckpointAsync(
string sessionId, CheckpointInfo key)
=> new(this._store[key]);
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(
string sessionId, CheckpointInfo? withParent = null)
{
List<CheckpointInfo> matches = [];
foreach (CheckpointInfo k in this._store.Keys)
{
if (k.SessionId == sessionId)
{
matches.Add(k);
}
}
return new(matches);
}
}
private static T RoundTrip<T>(T source) where T : notnull
{
JsonSerializerOptions options = DeclarativeWorkflowJsonOptions.Default;
string text = JsonSerializer.Serialize(source, options);
T? copy = JsonSerializer.Deserialize<T>(text, options);
Assert.NotNull(copy);
return copy!;
}
}
@@ -0,0 +1,259 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Tests for <see cref="DeclarativeWorkflowOptions"/> telemetry configuration.
/// </summary>
[Collection("DeclarativeWorkflowOptionsTest")]
public sealed class DeclarativeWorkflowOptionsTest : IDisposable
{
// These constants mirror Microsoft.Agents.AI.Workflows.Observability.ActivityNames
// which is internal and not accessible from this test project.
private const string WorkflowBuildActivityName = "workflow.build";
private const string WorkflowRunActivityName = "workflow_invoke";
// The default activity source name used by the workflow telemetry context.
private const string DefaultTelemetrySourceName = "Microsoft.Agents.AI.Workflows";
private const string SimpleWorkflowYaml = """
kind: Workflow
trigger:
kind: OnConversationStart
id: test_workflow
actions:
- kind: EndConversation
id: end_all
""";
private readonly ActivitySource _activitySource = new("TestSource");
private readonly ActivityListener _activityListener;
private readonly ConcurrentBag<Activity> _capturedActivities = [];
public DeclarativeWorkflowOptionsTest()
{
this._activityListener = new ActivityListener
{
ShouldListenTo = source =>
source.Name == DefaultTelemetrySourceName ||
source.Name == "TestSource",
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity => this._capturedActivities.Add(activity),
};
ActivitySource.AddActivityListener(this._activityListener);
}
public void Dispose()
{
this._activityListener.Dispose();
this._activitySource.Dispose();
}
[Fact]
public void ConfigureTelemetry_DefaultIsNull()
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object);
// Assert
Assert.Null(options.ConfigureTelemetry);
}
[Fact]
public void ConfigureTelemetry_CanBeSet()
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
bool callbackInvoked = false;
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
ConfigureTelemetry = opt =>
{
callbackInvoked = true;
opt.EnableSensitiveData = true;
}
};
// Assert
Assert.NotNull(options.ConfigureTelemetry);
WorkflowTelemetryOptions telemetryOptions = new();
options.ConfigureTelemetry(telemetryOptions);
Assert.True(callbackInvoked);
Assert.True(telemetryOptions.EnableSensitiveData);
}
[Fact]
public void TelemetryActivitySource_DefaultIsNull()
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object);
// Assert
Assert.Null(options.TelemetryActivitySource);
}
[Fact]
public void TelemetryActivitySource_CanBeSet()
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
// Act
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
TelemetryActivitySource = this._activitySource
};
// Assert
Assert.Same(this._activitySource, options.TelemetryActivitySource);
}
[Fact]
public async Task BuildWorkflow_WithDefaultTelemetry_AppliesTelemetryAsync()
{
// Arrange
using Activity testActivity = new Activity("DefaultTelemetryTest").Start()!;
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
ConfigureTelemetry = _ => { },
LoggerFactory = NullLoggerFactory.Instance
};
// Act
using StringReader reader = new(SimpleWorkflowYaml);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(reader, options);
await using Run run = await InProcessExecution.RunAsync(workflow, "test input");
// Assert
Activity[] capturedActivities = this._capturedActivities
.Where(a => a.RootId == testActivity.RootId && a.Source.Name == DefaultTelemetrySourceName)
.ToArray();
Assert.NotEmpty(capturedActivities);
Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal));
Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal));
}
[Fact]
public async Task BuildWorkflow_WithTelemetryActivitySource_AppliesTelemetryAsync()
{
// Arrange
using Activity testActivity = new Activity("TelemetryActivitySourceTest").Start()!;
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
TelemetryActivitySource = this._activitySource,
LoggerFactory = NullLoggerFactory.Instance
};
// Act
using StringReader reader = new(SimpleWorkflowYaml);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(reader, options);
await using Run run = await InProcessExecution.RunAsync(workflow, "test input");
// Assert
Activity[] capturedActivities = this._capturedActivities
.Where(a => a.RootId == testActivity.RootId && a.Source.Name == "TestSource")
.ToArray();
Assert.NotEmpty(capturedActivities);
Assert.All(capturedActivities, a => Assert.Equal("TestSource", a.Source.Name));
}
[Fact]
public async Task BuildWorkflow_WithConfigureTelemetry_AppliesConfigurationAsync()
{
// Arrange
using Activity testActivity = new Activity("ConfigureTelemetryTest").Start()!;
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
bool configureInvoked = false;
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
ConfigureTelemetry = opt =>
{
configureInvoked = true;
opt.EnableSensitiveData = true;
},
LoggerFactory = NullLoggerFactory.Instance
};
// Act
using StringReader reader = new(SimpleWorkflowYaml);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(reader, options);
await using Run run = await InProcessExecution.RunAsync(workflow, "test input");
// Assert
Assert.True(configureInvoked);
Activity[] capturedActivities = this._capturedActivities
.Where(a => a.RootId == testActivity.RootId && a.Source.Name == DefaultTelemetrySourceName)
.ToArray();
Assert.NotEmpty(capturedActivities);
Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal));
Assert.Contains(capturedActivities, a => a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal));
}
[Fact]
public async Task BuildWorkflow_WithoutTelemetry_DoesNotCreateActivitiesAsync()
{
// Arrange
using Activity testActivity = new Activity("NoTelemetryTest").Start()!;
Mock<ResponseAgentProvider> mockProvider = CreateMockProvider();
DeclarativeWorkflowOptions options = new(mockProvider.Object)
{
LoggerFactory = NullLoggerFactory.Instance
};
// Act
using StringReader reader = new(SimpleWorkflowYaml);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(reader, options);
await using Run run = await InProcessExecution.RunAsync(workflow, "test input");
// Assert - No workflow activities should be created when telemetry is disabled
Activity[] capturedActivities = this._capturedActivities
.Where(a => a.RootId == testActivity.RootId &&
(a.OperationName.StartsWith(WorkflowBuildActivityName, StringComparison.Ordinal) ||
a.OperationName.StartsWith(WorkflowRunActivityName, StringComparison.Ordinal)))
.ToArray();
Assert.Empty(capturedActivities);
}
private static Mock<ResponseAgentProvider> CreateMockProvider()
{
Mock<ResponseAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
mockAgentProvider
.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
mockAgentProvider
.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, "Test response")));
return mockAgentProvider;
}
}
@@ -0,0 +1,408 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
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.Extensions.AI;
using Moq;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output)
{
private List<WorkflowEvent> WorkflowEvents { get; } = [];
private Dictionary<Type, int> WorkflowEventCounts { get; set; } = [];
[Theory]
[InlineData("BadEmpty.yaml")]
[InlineData("BadId.yaml")]
[InlineData("BadKind.yaml")]
public async Task InvalidWorkflowAsync(string workflowFile)
{
await Assert.ThrowsAsync<DeclarativeModelException>(() => this.RunWorkflowAsync(workflowFile));
this.AssertNotExecuted("end_all");
}
[Fact]
public async Task LoopEachActionAsync()
{
await this.RunWorkflowAsync("LoopEach.yaml");
this.AssertExecutionCount(expectedCount: 34);
this.AssertExecuted("foreach_loop");
this.AssertExecuted("set_variable_inner");
this.AssertExecuted("send_activity_inner");
this.AssertExecuted("end_all");
}
[Fact]
public async Task LoopBreakActionAsync()
{
await this.RunWorkflowAsync("LoopBreak.yaml");
this.AssertExecutionCount(expectedCount: 6);
this.AssertExecuted("foreach_loop", isDiscrete: false);
this.AssertExecuted("break_loop_now");
this.AssertExecuted("end_all");
this.AssertNotExecuted("set_variable_inner");
this.AssertNotExecuted("send_activity_inner");
}
[Fact]
public async Task LoopContinueActionAsync()
{
await this.RunWorkflowAsync("LoopContinue.yaml");
this.AssertExecutionCount(expectedCount: 22);
this.AssertExecuted("foreach_loop", isDiscrete: false);
this.AssertExecuted("continue_loop_now");
this.AssertExecuted("end_all");
this.AssertNotExecuted("set_variable_inner");
this.AssertNotExecuted("send_activity_inner");
}
[Fact]
public async Task EndConversationActionAsync()
{
await this.RunWorkflowAsync("EndConversation.yaml");
this.AssertExecutionCount(expectedCount: 1);
this.AssertExecuted("end_all");
this.AssertNotExecuted("sendActivity_1");
}
[Fact]
public async Task GotoActionAsync()
{
await this.RunWorkflowAsync("Goto.yaml");
this.AssertExecutionCount(expectedCount: 2);
this.AssertExecuted("goto_end");
this.AssertExecuted("end_all");
this.AssertNotExecuted("sendActivity_1");
this.AssertNotExecuted("sendActivity_2");
this.AssertNotExecuted("sendActivity_3");
}
[Theory]
[InlineData(12)]
[InlineData(37)]
public async Task ConditionActionAsync(int input)
{
await this.RunWorkflowAsync("Condition.yaml", input);
this.AssertExecutionCount(expectedCount: 9);
this.AssertExecuted("setVariable_test");
this.AssertExecuted("conditionGroup_test");
if (input % 2 == 0)
{
this.AssertExecuted("conditionItem_even", isAction: false);
this.AssertExecuted("sendActivity_even");
this.AssertNotExecuted("conditionItem_odd");
this.AssertNotExecuted("sendActivity_odd");
this.AssertMessage("EVEN");
}
else
{
this.AssertExecuted("conditionItem_odd", isAction: false);
this.AssertExecuted("sendActivity_odd");
this.AssertNotExecuted("conditionItem_even");
this.AssertNotExecuted("sendActivity_even");
this.AssertMessage("ODD");
}
this.AssertExecuted("activity_final");
}
[Theory]
[InlineData(12, 7)]
[InlineData(37, 9)]
public async Task ConditionActionWithElseAsync(int input, int expectedActions)
{
await this.RunWorkflowAsync("ConditionElse.yaml", input);
this.AssertExecutionCount(expectedActions);
this.AssertExecuted("setVariable_test");
this.AssertExecuted("conditionGroup_test");
if (input % 2 == 0)
{
this.AssertExecuted("sendActivity_else", isAction: false);
this.AssertNotExecuted("conditionItem_odd");
this.AssertNotExecuted("sendActivity_odd");
}
else
{
this.AssertExecuted("conditionItem_odd", isAction: false);
this.AssertExecuted("sendActivity_odd");
this.AssertNotExecuted("sendActivity_else");
}
this.AssertExecuted("activity_final");
}
[Theory]
[InlineData(12, 4)]
[InlineData(37, 9)]
public async Task ConditionActionWithFallThroughAsync(int input, int expectedActions)
{
await this.RunWorkflowAsync("ConditionFallThrough.yaml", input);
this.AssertExecutionCount(expectedActions);
this.AssertExecuted("setVariable_test");
this.AssertExecuted("conditionGroup_test", isAction: false);
if (input % 2 == 0)
{
this.AssertNotExecuted("conditionItem_odd");
this.AssertNotExecuted("sendActivity_odd");
}
else
{
this.AssertExecuted("conditionItem_odd", isAction: false);
this.AssertExecuted("sendActivity_odd");
this.AssertMessage("ODD");
}
this.AssertExecuted("activity_final");
}
[Theory]
[InlineData("CancelWorkflow.yaml", 1, "end_all")]
[InlineData("EndConversation.yaml", 1, "end_all")]
[InlineData("EndWorkflow.yaml", 1, "end_all")]
[InlineData("EditTable.yaml", 2, "edit_var")]
[InlineData("EditTableV2.yaml", 2, "edit_var")]
[InlineData("ParseValue.yaml", 2, "parse_var")]
[InlineData("ParseValueList.yaml", 2, "parse_var")]
[InlineData("SendActivity.yaml", 2, "activity_input")]
[InlineData("SetVariable.yaml", 1, "set_var")]
[InlineData("SetTextVariable.yaml", 1, "set_text")]
[InlineData("ClearAllVariables.yaml", 1, "clear_all")]
[InlineData("ResetVariable.yaml", 2, "clear_var")]
[InlineData("MixedScopes.yaml", 2, "activity_input")]
[InlineData("CaseInsensitive.yaml", 6, "end_when_match")]
[InlineData("HttpRequest.yaml", 1, "http_request")]
public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId)
{
await this.RunWorkflowAsync(workflowFile);
this.AssertExecutionCount(expectedCount);
this.AssertExecuted(expectedId);
}
[Theory]
[InlineData(typeof(ActivateExternalTrigger.Builder))]
[InlineData(typeof(AdaptiveCardPrompt.Builder))]
[InlineData(typeof(BeginDialog.Builder))]
[InlineData(typeof(CSATQuestion.Builder))]
[InlineData(typeof(CreateSearchQuery.Builder))]
[InlineData(typeof(DeleteActivity.Builder))]
[InlineData(typeof(DisableTrigger.Builder))]
[InlineData(typeof(DisconnectedNodeContainer.Builder))]
[InlineData(typeof(EmitEvent.Builder))]
[InlineData(typeof(GetActivityMembers.Builder))]
[InlineData(typeof(GetConversationMembers.Builder))]
[InlineData(typeof(InvokeAIBuilderModelAction.Builder))]
[InlineData(typeof(InvokeConnectorAction.Builder))]
[InlineData(typeof(InvokeCustomModelAction.Builder))]
[InlineData(typeof(InvokeFlowAction.Builder))]
[InlineData(typeof(InvokeSkillAction.Builder))]
[InlineData(typeof(LogCustomTelemetryEvent.Builder))]
[InlineData(typeof(OAuthInput.Builder))]
[InlineData(typeof(RecognizeIntent.Builder))]
[InlineData(typeof(RepeatDialog.Builder))]
[InlineData(typeof(ReplaceDialog.Builder))]
[InlineData(typeof(SearchAndSummarizeContent.Builder))]
[InlineData(typeof(SearchAndSummarizeWithCustomModel.Builder))]
[InlineData(typeof(SearchKnowledgeSources.Builder))]
[InlineData(typeof(SignOutUser.Builder))]
[InlineData(typeof(TransferConversation.Builder))]
[InlineData(typeof(TransferConversationV2.Builder))]
[InlineData(typeof(UnknownDialogAction.Builder))]
[InlineData(typeof(UpdateActivity.Builder))]
[InlineData(typeof(WaitForConnectorTrigger.Builder))]
public void UnsupportedAction(Type type)
{
DialogAction.Builder? unsupportedAction = (DialogAction.Builder?)Activator.CreateInstance(type);
Assert.NotNull(unsupportedAction);
unsupportedAction.Id = "action_bad";
AdaptiveDialog.Builder dialogBuilder =
new()
{
BeginDialog =
new OnActivity.Builder()
{
Id = "anything",
Actions = [unsupportedAction]
}
};
AdaptiveDialog dialog = dialogBuilder.Build();
WorkflowFormulaState state = new(RecalcEngineFactory.Create());
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider("1");
DeclarativeWorkflowOptions options = new(mockAgentProvider.Object);
WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor<string>(WorkflowActionVisitor.Steps.Root("anything"), options, state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options);
WorkflowElementWalker walker = new(visitor);
walker.Visit(dialog);
Assert.True(visitor.HasUnsupportedActions);
}
[Theory]
[InlineData("CaseInsensitive.yaml", "end_when_match")]
[InlineData("ClearAllVariables.yaml", "clear_all")]
[InlineData("Condition.yaml", "setVariable_test")]
[InlineData("ConditionElse.yaml", "setVariable_test")]
[InlineData("EndConversation.yaml", "end_all")]
[InlineData("EndWorkflow.yaml", "end_all")]
[InlineData("EditTable.yaml", "edit_var")]
[InlineData("EditTableV2.yaml", "edit_var")]
[InlineData("Goto.yaml", "goto_end")]
[InlineData("LoopBreak.yaml", "break_loop_now")]
[InlineData("LoopContinue.yaml", "foreach_loop")]
[InlineData("LoopEach.yaml", "foreach_loop")]
[InlineData("MixedScopes.yaml", "activity_input")]
[InlineData("ParseValue.yaml", "parse_var")]
[InlineData("ParseValueList.yaml", "parse_var")]
[InlineData("ResetVariable.yaml", "clear_var")]
[InlineData("SendActivity.yaml", "activity_input")]
[InlineData("SetVariable.yaml", "set_var")]
[InlineData("SetTextVariable.yaml", "set_text")]
[InlineData("HttpRequest.yaml", "http_request")]
public async Task CancelRunAsync(string workflowPath, string expectedExecutedId)
{
// Arrange
const string WorkflowInput = "Test input message";
Workflow workflow = this.CreateWorkflow(workflowPath, WorkflowInput);
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow: workflow, input: WorkflowInput);
// Act
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
{
this.WorkflowEvents.Add(workflowEvent);
if (workflowEvent is DeclarativeActionInvokedEvent actionInvokedEvent && actionInvokedEvent.ActionId == expectedExecutedId)
{
// Cancel run after the specified declarative action is invoked.
await run.CancelRunAsync();
}
}
RunStatus currentRunStatus = await run.GetStatusAsync();
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
// Assert
Assert.Equal(expected: RunStatus.Ended, actual: currentRunStatus);
Assert.NotEmpty(this.WorkflowEventCounts);
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionInvokedEvent>(), e => e.ActionId == expectedExecutedId);
Assert.DoesNotContain(this.WorkflowEvents.OfType<DeclarativeActionCompletedEvent>(), e => e.ActionId == expectedExecutedId);
}
private void AssertExecutionCount(int expectedCount)
{
Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokedEvent)]);
Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorCompletedEvent)]);
}
private void AssertNotExecuted(string executorId)
{
Assert.DoesNotContain(this.WorkflowEvents.OfType<ExecutorInvokedEvent>(), e => e.ExecutorId == executorId);
Assert.DoesNotContain(this.WorkflowEvents.OfType<ExecutorCompletedEvent>(), e => e.ExecutorId == executorId);
}
private void AssertExecuted(string executorId, bool isAction = true, bool isDiscrete = true)
{
Assert.Contains(this.WorkflowEvents.OfType<ExecutorInvokedEvent>(), e => e.ExecutorId == executorId);
Assert.Contains(this.WorkflowEvents.OfType<ExecutorCompletedEvent>(), e => e.ExecutorId == executorId);
if (isAction)
{
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionInvokedEvent>(), e => e.ActionId == executorId);
if (isDiscrete)
{
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionCompletedEvent>(), e => e.ActionId == executorId);
}
}
}
private void AssertMessage(string message) =>
Assert.Contains(this.WorkflowEvents.OfType<MessageActivityEvent>(), e => string.Equals(e.Message.Trim(), message, StringComparison.Ordinal));
private Task RunWorkflowAsync(string workflowPath) =>
this.RunWorkflowAsync(workflowPath, "Test input message");
private async Task RunWorkflowAsync<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
{
Workflow workflow = this.CreateWorkflow(workflowPath, workflowInput);
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, workflowInput);
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
{
this.WorkflowEvents.Add(workflowEvent);
switch (workflowEvent)
{
case ExecutorInvokedEvent invokeEvent:
ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult;
this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]");
break;
case DeclarativeActionInvokedEvent actionInvokeEvent:
this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}");
break;
case DeclarativeActionCompletedEvent actionCompleteEvent:
this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}");
break;
case MessageActivityEvent activityEvent:
this.Output.WriteLine($"ACTIVITY: {activityEvent.Message}");
break;
case AgentResponseEvent messageEvent:
this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}");
break;
case ExecutorFailedEvent failureEvent:
Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent errorEvent:
throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure...");
}
}
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
}
private Workflow CreateWorkflow<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
{
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
DeclarativeWorkflowOptions workflowContext =
new(mockAgentProvider.Object)
{
LoggerFactory = this.Output,
HttpRequestHandler = CreateMockHttpRequestHandler().Object,
};
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
}
private static Mock<ResponseAgentProvider> CreateMockProvider(string input)
{
Mock<ResponseAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
mockAgentProvider.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>())).Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input)));
return mockAgentProvider;
}
private static Mock<IHttpRequestHandler> CreateMockHttpRequestHandler()
{
Mock<IHttpRequestHandler> mockHandler = new(MockBehavior.Loose);
mockHandler
.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(new HttpRequestResult
{
StatusCode = 200,
IsSuccessStatusCode = true,
Body = "{\"ok\":true}",
}));
return mockHandler;
}
}
@@ -0,0 +1,510 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Unit tests for <see cref="DefaultHttpRequestHandler"/>.
/// </summary>
public sealed class DefaultHttpRequestHandlerTests
{
private static readonly string[] s_setCookieValues = ["a=1", "b=2"];
private const string TestUrl = "https://api.example.test/resource";
#region Constructor Tests
[Fact]
public async Task ConstructorWithNoParametersCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new();
// Assert
handler.Should().NotBeNull();
}
[Fact]
public async Task ConstructorWithNullProviderCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new(httpClientProvider: null);
// Assert
handler.Should().NotBeNull();
}
[Fact]
public void ConstructorWithNullHttpClientThrows()
{
// Act
Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
}));
using HttpClient suppliedClient = new(messageHandler);
await using DefaultHttpRequestHandler handler = new(suppliedClient);
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert - the supplied HttpClient's underlying handler saw the request
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl);
result.Body.Should().Be("ok");
}
[Fact]
public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
using HttpClient suppliedClient = new(messageHandler);
// Act
DefaultHttpRequestHandler handler = new(suppliedClient);
await handler.DisposeAsync();
// Assert - supplied client remains usable (not disposed)
Func<Task> act = async () => await suppliedClient.GetAsync(new Uri(TestUrl));
await act.Should().NotThrowAsync<ObjectDisposedException>();
}
#endregion
#region Argument Validation Tests
[Fact]
public async Task SendAsyncWithNullRequestThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
// Act
Func<Task> act = async () => await handler.SendAsync(null!);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SendAsyncWithEmptyUrlThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "GET", Url = "" };
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task SendAsyncWithEmptyMethodThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "", Url = TestUrl };
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
#endregion
#region Send Behavior Tests
[Fact]
public async Task SendAsyncUsesProvidedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get);
messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl);
result.StatusCode.Should().Be(200);
result.IsSuccessStatusCode.Should().BeTrue();
result.Body.Should().Be("hello");
}
[Fact]
public async Task SendAsyncMapsAllKnownMethodsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" })
{
HttpRequestInfo request = new() { Method = method, Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Method.Method.Should().Be(method);
}
}
[Fact]
public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert - fallback path should apply the same Trim/ToUpperInvariant normalization.
messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM");
}
[Fact]
public async Task SendAsyncAppliesBodyAndContentTypeAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "{\"hello\":\"world\"}",
BodyContentType = "application/json",
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}");
messageHandler.LastRequestContentType.Should().Be("application/json");
}
[Fact]
public async Task SendAsyncAppliesRequestHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret",
["Accept"] = "application/json",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret");
messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json");
}
[Fact]
public async Task SendAsyncRoutesContentHeadersToBodyAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "raw",
BodyContentType = "text/plain",
Headers = new Dictionary<string, string>
{
["Content-Language"] = "en-US",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US");
}
[Fact]
public async Task SendAsyncCapturesResponseHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
{
#pragma warning disable CA2025
HttpResponseMessage response = new(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
};
response.Headers.Add("X-Request-Id", "request-1");
response.Headers.Add("Set-Cookie", s_setCookieValues);
return Task.FromResult(response);
#pragma warning restore CA2025
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.Headers.Should().NotBeNull();
result.Headers!.Should().ContainKey("X-Request-Id");
result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues);
// Content headers also flattened in.
result.Headers!.Should().ContainKey("Content-Type");
}
[Fact]
public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.IsSuccessStatusCode.Should().BeFalse();
result.StatusCode.Should().Be(400);
result.Body.Should().Be("bad request");
}
[Fact]
public async Task SendAsyncTimeoutCancelsRequestAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new(async (req, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Timeout = TimeSpan.FromMilliseconds(50),
};
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync()
{
// Arrange
int providerCallCount = 0;
await using DefaultHttpRequestHandler handler = new((_, _) =>
{
providerCallCount++;
return Task.FromResult<HttpClient?>(null);
});
HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" };
// Act - owned client will attempt real network and fail, but provider path should have been consulted first.
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<Exception>();
providerCallCount.Should().Be(1);
}
#endregion
#region DisposeAsync
[Fact]
public async Task DisposeAsyncCompletesAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
Func<Task> act = async () => await handler.DisposeAsync();
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
await handler.DisposeAsync();
Func<Task> second = async () => await handler.DisposeAsync();
// Assert
await second.Should().NotThrowAsync();
}
#endregion
#region Query Parameters and Connection Tests
[Fact]
public async Task QueryParametersAreAppendedToUrlAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl,
QueryParameters = new Dictionary<string, string>
{
["filter"] = "active items",
["ids"] = "1,2,3",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest.Should().NotBeNull();
string? query = fake.LastRequest!.RequestUri!.Query;
query.Should().Contain("filter=active%20items");
query.Should().Contain("ids=1%2C2%2C3");
}
[Fact]
public async Task QueryParametersPreserveExistingQueryStringAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl + "?existing=yes",
QueryParameters = new Dictionary<string, string>
{
["added"] = "true",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true");
}
#endregion
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responseFactory;
public TestHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseFactory)
{
this._responseFactory = responseFactory;
}
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
public string? LastRequestContentType { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequest = request;
if (request.Content is not null)
{
#if NET
this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType;
}
return await this._responseFactory(request, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Entities;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Entities;
/// <summary>
/// Tests for <see cref="EntityExtractionResult"/>.
/// </summary>
public sealed class EntityExtractionResultTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Fact]
public void ConstructorWithErrorMessage()
{
// Arrange
const string ErrorMessage = "Test error message";
// Act
EntityExtractionResult result = new(ErrorMessage);
// Assert
Assert.Null(result.Value);
Assert.Equal(ErrorMessage, result.ErrorMessage);
Assert.False(result.IsValid);
}
[Fact]
public void ConstructorWithNullValue()
{
// Arrange
FormulaValue? value = null;
// Act
EntityExtractionResult result = new(value);
// Assert
Assert.Null(result.Value);
Assert.Null(result.ErrorMessage);
Assert.False(result.IsValid);
}
[Fact]
public void ConstructorWithNumberValue()
{
// Arrange
FormulaValue value = FormulaValue.New(double.MaxValue);
// Act
EntityExtractionResult result = new(value);
// Assert
NumberValue numberValue = Assert.IsType<NumberValue>(result.Value);
Assert.Equal(double.MaxValue, numberValue.Value);
}
[Fact]
public void ConstructorWithBlankValue_IsValid()
{
// Arrange
FormulaValue value = FormulaValue.NewBlank();
// Act
EntityExtractionResult result = new(value);
// Assert
Assert.Equal(value, result.Value);
Assert.True(result.IsValid);
}
}
@@ -0,0 +1,758 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.Declarative.Entities;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Entities;
/// <summary>
/// Tests for <see cref="EntityExtractor"/>.
/// </summary>
public sealed class EntityExtractorTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Fact]
public void Parse_NullEntity_WithNonEmptyValue_ReturnsStringValue()
{
// Arrange
EntityReference? entity = null;
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, "test value");
// Assert
Assert.True(result.IsValid);
Assert.NotNull(result.Value);
StringValue stringValue = Assert.IsType<StringValue>(result.Value);
Assert.Equal("test value", stringValue.Value);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void Parse_NullEntity_WithEmptyValue_ReturnsBlankValue(string value)
{
// Arrange
EntityReference? entity = null;
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<BlankValue>(result.Value);
}
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("True", true)]
[InlineData("False", false)]
[InlineData("TRUE", true)]
[InlineData("FALSE", false)]
public void Parse_BooleanEntity_ValidValue_ReturnsBoolean(string value, bool expected)
{
// Arrange
EntityReference entity = CreateBooleanEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(expected, (result.Value as BooleanValue)?.Value);
}
[Theory]
[InlineData("invalid")]
[InlineData("123")]
[InlineData("yes")]
public void Parse_BooleanEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateBooleanEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid boolean value", result.ErrorMessage);
}
[Theory]
[InlineData("2023-12-25")]
[InlineData("12/25/2023")]
[InlineData("2023-12-25 10:30:00")]
public void Parse_DateEntity_ValidValue_ReturnsDate(string value)
{
// Arrange
EntityReference entity = CreateDateEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<DateTimeValue>(result.Value);
}
[Theory]
[InlineData("invalid date")]
[InlineData("not-a-date")]
public void Parse_DateEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateDateEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid date value", result.ErrorMessage);
}
[Theory]
[InlineData("2023-12-25 10:30:00")]
[InlineData("12/25/2023 10:30:00 AM")]
public void Parse_DateTimeEntity_ValidValue_ReturnsDateTime(string value)
{
// Arrange
EntityReference entity = CreateDateTimeEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<DateTimeValue>(result.Value);
}
[Theory]
[InlineData("invalid datetime")]
public void Parse_DateTimeEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateDateTimeEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid date-time value", result.ErrorMessage);
}
[Theory]
[InlineData("2023-12-25 10:30:00")]
[InlineData("12/25/2023 10:30:00")]
public void Parse_DateTimeNoTimeZoneEntity_ValidValue_ReturnsDateTime(string value)
{
// Arrange
EntityReference entity = CreateDateTimeNoTimeZoneEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
DateTimeValue dateTimeValue = Assert.IsType<DateTimeValue>(result.Value);
DateTime dateTime = dateTimeValue.GetConvertedValue(null);
Assert.Equal(DateTime.Parse(value), dateTime);
}
[Theory]
[InlineData("01:30:00")]
[InlineData("1:30:00")]
[InlineData("10.12:30:45")]
public void Parse_DurationEntity_ValidValue_ReturnsDuration(string value)
{
// Arrange
EntityReference entity = CreateDurationEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<TimeValue>(result.Value);
}
[Theory]
[InlineData("invalid duration")]
[InlineData("not a timespan")]
public void Parse_DurationEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateDurationEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid duration value", result.ErrorMessage);
}
[Theory]
[InlineData("test@example.com")]
[InlineData("user.name@domain.co.uk")]
public void Parse_EmailEntity_ValidValue_ReturnsEmail(string value)
{
// Arrange
EntityReference entity = CreateEmailEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("invalid email")]
[InlineData("@example.com")]
[InlineData("test@")]
public void Parse_EmailEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateEmailEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid email value", result.ErrorMessage);
}
[Theory]
[InlineData("123")]
[InlineData("456.78")]
[InlineData("-123.45")]
[InlineData("1,234.56")]
public void Parse_NumberEntity_ValidValue_ReturnsNumber(string value)
{
// Arrange
EntityReference entity = CreateNumberEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<NumberValue>(result.Value);
}
[Theory]
[InlineData("not a number")]
[InlineData("abc")]
public void Parse_NumberEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateNumberEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid double value", result.ErrorMessage);
}
[Theory]
[InlineData("25 years")]
[InlineData("30 years old")]
[InlineData("45")]
public void Parse_AgeEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateAgeEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not an age")]
public void Parse_AgeEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateAgeEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid age value", result.ErrorMessage);
}
[Theory]
[InlineData("$100")]
[InlineData("100 dollars")]
[InlineData("123.45")]
public void Parse_MoneyEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateMoneyEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not money")]
public void Parse_MoneyEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateMoneyEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid money value", result.ErrorMessage);
}
[Theory]
[InlineData("50%")]
[InlineData("75 percent")]
[InlineData("99.5")]
public void Parse_PercentageEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreatePercentageEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not a percentage")]
public void Parse_PercentageEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreatePercentageEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid percentage value", result.ErrorMessage);
}
[Theory]
[InlineData("60 mph")]
[InlineData("100 km/h")]
[InlineData("25.5")]
public void Parse_SpeedEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateSpeedEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not a speed")]
public void Parse_SpeedEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateSpeedEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid speed value", result.ErrorMessage);
}
[Theory]
[InlineData("72°F")]
[InlineData("20°C")]
[InlineData("98.6")]
public void Parse_TemperatureEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateTemperatureEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not a temperature")]
public void Parse_TemperatureEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateTemperatureEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid temperature value", result.ErrorMessage);
}
[Theory]
[InlineData("150 lbs")]
[InlineData("70 kg")]
[InlineData("180.5")]
public void Parse_WeightEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateWeightEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.IsType<StringValue>(result.Value);
}
[Theory]
[InlineData("not a weight")]
public void Parse_WeightEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateWeightEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid weight value", result.ErrorMessage);
}
[Theory]
[InlineData("https://www.example.com", "https://www.example.com/")]
[InlineData("http://test.com/path", "http://test.com/path")]
[InlineData("ftp://files.example.com", "ftp://files.example.com/")]
public void Parse_URLEntity_ValidValue_ReturnsURL(string value, string expected)
{
// Arrange
EntityReference entity = CreateURLEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(expected, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("not a url")]
[InlineData("invalid url")]
public void Parse_URLEntity_InvalidValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateURLEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Contains("Invalid double value", result.ErrorMessage);
}
[Theory]
[InlineData("Seattle")]
[InlineData("New York")]
public void Parse_CityEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateCityEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Parse_CityEntity_EmptyValue_ReturnsError(string value)
{
// Arrange
EntityReference entity = CreateCityEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.False(result.IsValid);
Assert.Equal("Empty value", result.ErrorMessage);
}
[Theory]
[InlineData("Washington")]
[InlineData("California")]
public void Parse_StateEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateStateEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("USA")]
[InlineData("United Kingdom")]
public void Parse_CountryOrRegionEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateCountryOrRegionEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("Europe")]
[InlineData("Asia")]
public void Parse_ContinentEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateContinentEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("123 Main Street")]
[InlineData("456 Oak Avenue")]
public void Parse_StreetAddressEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateStreetAddressEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("+1-555-1234")]
[InlineData("(555) 123-4567")]
public void Parse_PhoneNumberEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreatePhoneNumberEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("red")]
[InlineData("blue")]
public void Parse_ColorEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateColorEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("English")]
[InlineData("Spanish")]
public void Parse_LanguageEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateLanguageEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("Conference")]
[InlineData("Meeting")]
public void Parse_EventEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateEventEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("Starbucks")]
[InlineData("Museum")]
public void Parse_PointOfInterestEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreatePointOfInterestEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
[Theory]
[InlineData("test string")]
[InlineData("any text")]
public void Parse_StringEntity_ValidValue_ReturnsString(string value)
{
// Arrange
EntityReference entity = CreateStringEntity();
// Act
EntityExtractionResult result = EntityExtractor.Parse(entity, value);
// Assert
Assert.True(result.IsValid);
Assert.Equal(value, (result.Value as StringValue)?.Value);
}
private static BooleanPrebuiltEntity CreateBooleanEntity() =>
new BooleanPrebuiltEntity.Builder().Build();
private static DatePrebuiltEntity CreateDateEntity() =>
new DatePrebuiltEntity.Builder().Build();
private static DateTimePrebuiltEntity CreateDateTimeEntity() =>
new DateTimePrebuiltEntity.Builder().Build();
private static DateTimeNoTimeZonePrebuiltEntity CreateDateTimeNoTimeZoneEntity() =>
new DateTimeNoTimeZonePrebuiltEntity.Builder().Build();
private static DurationPrebuiltEntity CreateDurationEntity() =>
new DurationPrebuiltEntity.Builder().Build();
private static EmailPrebuiltEntity CreateEmailEntity() =>
new EmailPrebuiltEntity.Builder().Build();
private static NumberPrebuiltEntity CreateNumberEntity() =>
new NumberPrebuiltEntity.Builder().Build();
private static AgePrebuiltEntity CreateAgeEntity() =>
new AgePrebuiltEntity.Builder().Build();
private static MoneyPrebuiltEntity CreateMoneyEntity() =>
new MoneyPrebuiltEntity.Builder().Build();
private static PercentagePrebuiltEntity CreatePercentageEntity() =>
new PercentagePrebuiltEntity.Builder().Build();
private static SpeedPrebuiltEntity CreateSpeedEntity() =>
new SpeedPrebuiltEntity.Builder().Build();
private static TemperaturePrebuiltEntity CreateTemperatureEntity() =>
new TemperaturePrebuiltEntity.Builder().Build();
private static WeightPrebuiltEntity CreateWeightEntity() =>
new WeightPrebuiltEntity.Builder().Build();
private static URLPrebuiltEntity CreateURLEntity() =>
new URLPrebuiltEntity.Builder().Build();
private static CityPrebuiltEntity CreateCityEntity() =>
new CityPrebuiltEntity.Builder().Build();
private static StatePrebuiltEntity CreateStateEntity() =>
new StatePrebuiltEntity.Builder().Build();
private static CountryOrRegionPrebuiltEntity CreateCountryOrRegionEntity() =>
new CountryOrRegionPrebuiltEntity.Builder().Build();
private static ContinentPrebuiltEntity CreateContinentEntity() =>
new ContinentPrebuiltEntity.Builder().Build();
private static StreetAddressPrebuiltEntity CreateStreetAddressEntity() =>
new StreetAddressPrebuiltEntity.Builder().Build();
private static PhoneNumberPrebuiltEntity CreatePhoneNumberEntity() =>
new PhoneNumberPrebuiltEntity.Builder().Build();
private static ColorPrebuiltEntity CreateColorEntity() =>
new ColorPrebuiltEntity.Builder().Build();
private static LanguagePrebuiltEntity CreateLanguageEntity() =>
new LanguagePrebuiltEntity.Builder().Build();
private static EventPrebuiltEntity CreateEventEntity() =>
new EventPrebuiltEntity.Builder().Build();
private static PointOfInterestPrebuiltEntity CreatePointOfInterestEntity() =>
new PointOfInterestPrebuiltEntity.Builder().Build();
private static StringPrebuiltEntity CreateStringEntity() =>
new StringPrebuiltEntity.Builder().Build();
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Base class for event tests.
/// </summary>
public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output)
{
protected static TEvent VerifyEventSerialization<TEvent>(TEvent source)
{
string? text = JsonSerializer.Serialize(source, AIJsonUtilities.DefaultOptions);
Assert.NotNull(text);
TEvent? copy = JsonSerializer.Deserialize<TEvent>(text, AIJsonUtilities.DefaultOptions);
Assert.NotNull(copy);
return copy;
}
protected static void AssertMessage(ChatMessage source, ChatMessage copy)
{
Assert.Equal(source.Role, copy.Role);
Assert.Equal(source.Text, copy.Text);
Assert.Equal(source.Contents.Count, copy.Contents.Count);
}
protected static TContent AssertContent<TContent>(ChatMessage message) where TContent : AIContent
{
TContent[] contents = message.Contents.OfType<TContent>().ToArray();
return Assert.Single(contents);
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="ExternalInputRequest"/> class
/// </summary>
public sealed class ExternalInputRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationWithText()
{
// Arrange
ExternalInputRequest source = new(new AgentResponse(new ChatMessage(ChatRole.User, "Wassup?")));
// Act
ExternalInputRequest copy = VerifyEventSerialization(source);
// Assert
ChatMessage messageCopy = Assert.Single(source.AgentResponse.Messages);
AssertMessage(messageCopy, copy.AgentResponse.Messages[0]);
}
[Fact]
public void VerifySerializationWithRequests()
{
// Arrange
ExternalInputRequest source =
new(new AgentResponse(
new ChatMessage(
ChatRole.Assistant,
[
new ToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")),
new ToolApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")),
new FunctionCallContent("call3", "myfunc"),
new TextContent("Heya"),
])));
// Act
ExternalInputRequest copy = VerifyEventSerialization(source);
// Assert
ChatMessage messageCopy = Assert.Single(source.AgentResponse.Messages);
Assert.Equal(messageCopy.Contents.Count, copy.AgentResponse.Messages[0].Contents.Count);
List<ToolApprovalRequestContent> approvalRequests = messageCopy.Contents.OfType<ToolApprovalRequestContent>().ToList();
Assert.Equal(2, approvalRequests.Count);
ToolApprovalRequestContent mcpRequest = approvalRequests[0];
Assert.Equal("call1", mcpRequest.RequestId);
ToolApprovalRequestContent functionRequest = approvalRequests[1];
Assert.Equal("call2", functionRequest.RequestId);
FunctionCallContent functionCall = AssertContent<FunctionCallContent>(messageCopy);
Assert.Equal("call3", functionCall.CallId);
TextContent textContent = AssertContent<TextContent>(messageCopy);
Assert.Equal("Heya", textContent.Text);
}
}
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Verify <see cref="ExternalInputResponse"/> class
/// </summary>
public sealed class ExternalInputResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerializationEmpty()
{
// Arrange
ExternalInputResponse source = new(new ChatMessage(ChatRole.User, "Wassup?"));
// Act
ExternalInputResponse copy = VerifyEventSerialization(source);
// Assert
ChatMessage messageCopy = Assert.Single(source.Messages);
AssertMessage(messageCopy, copy.Messages[0]);
}
[Fact]
public void VerifySerializationWithResponses()
{
// Arrange
ExternalInputResponse source =
new(new ChatMessage(
ChatRole.Assistant,
[
new ToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true),
new ToolApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true),
new FunctionResultContent("call3", 33),
new TextContent("Heya"),
]));
// Act
ExternalInputResponse copy = VerifyEventSerialization(source);
// Assert
ChatMessage responseMessage = Assert.Single(source.Messages);
Assert.Equal(responseMessage.Contents.Count, copy.Messages[0].Contents.Count);
List<ToolApprovalResponseContent> approvalResponses = responseMessage.Contents.OfType<ToolApprovalResponseContent>().ToList();
Assert.Equal(2, approvalResponses.Count);
ToolApprovalResponseContent mcpApproval = approvalResponses[0];
Assert.Equal("call1", mcpApproval.RequestId);
ToolApprovalResponseContent functionApproval = approvalResponses[1];
Assert.Equal("call2", functionApproval.RequestId);
FunctionResultContent functionResult = AssertContent<FunctionResultContent>(responseMessage);
Assert.Equal("call3", functionResult.CallId);
TextContent textContent = AssertContent<TextContent>(responseMessage);
Assert.Equal("Heya", textContent.Text);
}
}
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
/// <summary>
/// Tests for <see cref="AgentProviderExtensions.InvokeAgentAsync"/>.
/// </summary>
public sealed class AgentProviderExtensionsTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string WorkflowConversationId = "workflow-conv-id";
private const string AgentName = "test-agent";
[Fact]
public Task AutoSendFalseOnWorkflowConversationSuppressesResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: WorkflowConversationId, expectResponseEvents: false);
[Fact]
public Task AutoSendTrueOnWorkflowConversationEmitsResponseEventsAsync() =>
this.RunAsync(autoSend: true, conversationId: WorkflowConversationId, expectResponseEvents: true);
[Fact]
public Task AutoSendFalseOnExternalConversationSuppressesResponseEventsAsync() =>
this.RunAsync(autoSend: false, conversationId: "other-conv-id", expectResponseEvents: false);
[Fact]
public Task AutoSendTrueOnExternalConversationEmitsResponseEventsAndCopiesMessagesAsync() =>
this.RunAsync(
autoSend: true,
conversationId: "other-conv-id",
expectResponseEvents: true,
expectCrossConversationCopy: true);
private async Task RunAsync(
bool autoSend,
string conversationId,
bool expectResponseEvents,
bool expectCrossConversationCopy = false)
{
// Arrange: seed the workflow conversation id so IsWorkflowConversation can recognize it.
this.State.Set(
SystemScope.Names.ConversationId,
FormulaValue.New(WorkflowConversationId),
VariableScopeNames.System);
MockAgentProvider mockProvider = new();
AgentResponseUpdate[] updates =
[
new(ChatRole.Assistant, "hello "),
new(ChatRole.Assistant, "world"),
];
mockProvider
.Setup(p => p.InvokeAgentAsync(
AgentName,
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<IEnumerable<ChatMessage>?>(),
It.IsAny<IDictionary<string, object?>?>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(updates));
List<(string ConversationId, ChatMessage Message)> copiedMessages = [];
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>(
(convId, msg, _) =>
{
copiedMessages.Add((convId, msg));
return Task.FromResult(msg);
});
string actionId = this.CreateActionId().Value;
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
actionId,
async (IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) =>
{
await mockProvider.Object.InvokeAgentAsync(
actionId,
context,
AgentName,
conversationId,
autoSend,
cancellationToken: cancellationToken).ConfigureAwait(false);
});
// Assert
int updateEventCount = events.OfType<AgentResponseUpdateEvent>().Count();
int responseEventCount = events.OfType<AgentResponseEvent>().Count();
if (expectResponseEvents)
{
Assert.Equal(updates.Length, updateEventCount);
Assert.Equal(1, responseEventCount);
}
else
{
Assert.Equal(0, updateEventCount);
Assert.Equal(0, responseEventCount);
}
if (expectCrossConversationCopy)
{
Assert.NotEmpty(copiedMessages);
Assert.All(copiedMessages, c => Assert.Equal(WorkflowConversationId, c.ConversationId));
}
else
{
Assert.Empty(copiedMessages);
}
}
private static async IAsyncEnumerable<AgentResponseUpdate> ToAsyncEnumerableAsync(IEnumerable<AgentResponseUpdate> updates)
{
foreach (AgentResponseUpdate update in updates)
{
yield return update;
}
await Task.CompletedTask;
}
}
@@ -0,0 +1,933 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
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.Extensions;
public sealed class ChatMessageExtensionsTests
{
[Fact]
public void ToRecordWithSimpleTextMessage()
{
// Arrange
ChatMessage message = new(ChatRole.User, "Hello World");
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
Assert.Contains(result.Fields, f => f.Name == TypeSchema.Message.Fields.Role);
Assert.Contains(result.Fields, f => f.Name == TypeSchema.Message.Fields.Text);
FormulaValue roleField = result.GetField(TypeSchema.Message.Fields.Role);
StringValue roleValue = Assert.IsType<StringValue>(roleField);
Assert.Equal(ChatRole.User.Value, roleValue.Value);
}
[Fact]
public void ToRecordWithAssistantMessage()
{
// Arrange
ChatMessage message = new(ChatRole.Assistant, "I can help you");
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
Assert.Contains(result.Fields, f => f.Name == TypeSchema.Message.Fields.Role);
FormulaValue roleField = result.GetField(TypeSchema.Message.Fields.Role);
StringValue roleValue = Assert.IsType<StringValue>(roleField);
Assert.Equal(ChatRole.Assistant.Value, roleValue.Value);
}
[Fact]
public void ToRecordIncludesAllStandardFields()
{
// Arrange
ChatMessage message = new(ChatRole.User, "Test")
{
MessageId = "msg-123"
};
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result.GetField(TypeSchema.Discriminator));
Assert.NotNull(result.GetField(TypeSchema.Message.Fields.Id));
Assert.NotNull(result.GetField(TypeSchema.Message.Fields.Role));
Assert.NotNull(result.GetField(TypeSchema.Message.Fields.Content));
Assert.NotNull(result.GetField(TypeSchema.Message.Fields.Text));
Assert.NotNull(result.GetField(TypeSchema.Message.Fields.Metadata));
}
[Fact]
public void ToTableWithMultipleMessages()
{
// Arrange
IEnumerable<ChatMessage> messages =
[
new(ChatRole.User, "First message"),
new(ChatRole.Assistant, "Second message"),
new(ChatRole.User, "Third message")
];
// Act
TableValue result = messages.ToTable();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Rows.Count());
}
[Fact]
public void ToTableWithEmptyMessages()
{
// Arrange
IEnumerable<ChatMessage> messages = [];
// Act
TableValue result = messages.ToTable();
// Assert
Assert.NotNull(result);
Assert.Empty(result.Rows);
}
[Fact]
public void ToChatMessagesWithNull()
{
// Arrange
DataValue? value = null;
// Act
IEnumerable<ChatMessage>? result = value.ToChatMessages();
// Assert
Assert.Null(result);
}
[Fact]
public void ToChatMessagesWithBlankDataValue()
{
// Arrange
DataValue value = DataValue.Blank();
// Act
IEnumerable<ChatMessage>? result = value.ToChatMessages();
// Assert
Assert.Null(result);
}
[Fact]
public void ToChatMessagesWithStringDataValue()
{
// Arrange
DataValue value = StringDataValue.Create("Hello");
// Act
IEnumerable<ChatMessage>? result = value.ToChatMessages();
// Assert
Assert.NotNull(result);
ChatMessage message = Assert.Single(result);
Assert.Equal(ChatRole.User, message.Role);
Assert.Equal("Hello", message.Text);
}
[Fact]
public void ToChatMessagesWithRecordDataValue()
{
// Arrange
ChatMessage source = new(ChatRole.User, "Test");
DataValue record = source.ToRecord().ToDataValue();
// Act
IEnumerable<ChatMessage>? result = record.ToChatMessages();
// Assert
Assert.NotNull(result);
ChatMessage message = Assert.Single(result);
Assert.Equal(source.Role, message.Role);
Assert.Equal(source.Text, message.Text);
}
[Fact]
public void ToChatMessagesWithTableDataValue()
{
// Arrange
ChatMessage[] source = [new(ChatRole.User, "Test")];
DataValue table = source.ToTable().ToDataValue();
// Act
IEnumerable<ChatMessage>? result = table.ToChatMessages();
// Assert
Assert.NotNull(result);
ChatMessage message = Assert.Single(result);
Assert.Equal(source[0].Role, message.Role);
Assert.Equal(source[0].Text, message.Text);
}
[Fact]
public void ToChatMessagesWithTableOfDataValue()
{
// Arrange
TableDataValue table = DataValue.TableFromValues([new StringDataValue("test")]);
// Act
IEnumerable<ChatMessage>? result = table.ToChatMessages();
// Assert
Assert.NotNull(result);
ChatMessage message = Assert.Single(result);
Assert.Equal(ChatRole.User, message.Role);
Assert.Equal("test", message.Text);
}
[Fact]
public void ToChatMessagesWithUnsupportedValue()
{
// Arrange
BooleanDataValue booleanValue = new(true);
// Act
IEnumerable<ChatMessage>? messages = booleanValue.ToChatMessages();
// Assert
Assert.Null(messages);
}
[Fact]
public void ToChatMessageFromStringDataValue()
{
// Arrange
StringDataValue value = StringDataValue.Create("Test message");
// Act
ChatMessage result = value.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.User, result.Role);
Assert.Equal("Test message", result.Text);
}
[Fact]
public void ToChatMessageFromDataValueRecord()
{
// Arrange
ChatMessage source = new(ChatRole.User, "Test");
DataValue record = source.ToRecord().ToDataValue();
// Act
ChatMessage? result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.User, result.Role);
Assert.Equal("Test", result.Text);
}
[Fact]
public void ToChatMessageFromDataValueString()
{
// Arrange
DataValue value = StringDataValue.Create("Test message");
// Act
ChatMessage? result = value.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.User, result.Role);
Assert.Equal("Test message", result.Text);
}
[Fact]
public void ToChatMessageFromBlankDataValue()
{
// Arrange
DataValue value = DataValue.Blank();
// Act
ChatMessage? result = value.ToChatMessage();
// Assert
Assert.Null(result);
}
[Fact]
public void ToChatMessageFromUnsupportedValue()
{
// Arrange
DataValue value = BooleanDataValue.Create(true);
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => value.ToChatMessage());
}
[Fact]
public void ToChatMessageFromRecordDataValue()
{
// Arrange
// Note: Use "Agent" not "Assistant" - AgentMessageRole.Agent maps to ChatRole.Assistant
RecordDataValue record = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Role, StringDataValue.Create("Agent")),
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Content, DataValue.EmptyTable));
// Act
ChatMessage result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.Assistant, result.Role);
}
[Fact]
public void ToChatMessageWithImpliedRole()
{
// Arrange
RecordValue source =
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(string.Empty)),
new NamedValue(
TypeSchema.Message.Fields.Content,
FormulaValue.NewTable(
TypeSchema.MessageContent.RecordType,
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.MessageContent.Fields.Type, TypeSchema.MessageContent.ContentTypes.Text.ToFormula()),
new NamedValue(TypeSchema.MessageContent.Fields.Value, FormulaValue.New("Test"))))));
RecordDataValue record = source.ToRecord();
// Act
ChatMessage? result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.User, result.Role);
Assert.Equal("Test", result.Text);
}
[Fact]
public void ToChatMessageWithImageUrlContentType()
{
// Arrange
ChatMessage source = new(ChatRole.User, [AgentMessageContentType.ImageUrl.ToContent("https://example.com/image.jpg")!]);
DataValue record = source.ToRecord().ToDataValue();
// Act
ChatMessage? result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
AIContent content = Assert.Single(result.Contents);
Assert.IsType<UriContent>(content);
}
[Fact]
public void ToChatMessageWithWithImageDataContentType()
{
// Arrange
ChatMessage source = new(ChatRole.User, [AgentMessageContentType.ImageUrl.ToContent("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA")!]);
DataValue record = source.ToRecord().ToDataValue();
// Act
ChatMessage? result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
AIContent content = Assert.Single(result.Contents);
Assert.IsType<DataContent>(content);
}
[Fact]
public void ToChatMessageWithWithImageFileContentType()
{
// Arrange
ChatMessage source = new(ChatRole.User, [AgentMessageContentType.ImageFile.ToContent("file-id-123")!]);
DataValue record = source.ToRecord().ToDataValue();
// Act
ChatMessage? result = record.ToChatMessage();
// Assert
Assert.NotNull(result);
AIContent content = Assert.Single(result.Contents);
Assert.IsType<HostedFileContent>(content);
}
[Fact]
public void ToChatMessageWithUnsupportedContent()
{
// Arrange
ChatMessage source = new(ChatRole.User, "Test");
RecordDataValue record = source.ToRecord().ToRecord();
DataValue contentValue = record.Properties[TypeSchema.Message.Fields.Content];
TableDataValue contentValues = Assert.IsType<TableDataValue>(contentValue, exactMatch: false);
RecordDataValue badContent = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>(TypeSchema.MessageContent.Fields.Type, StringDataValue.Create(TypeSchema.MessageContent.ContentTypes.Text)),
new KeyValuePair<string, DataValue>(TypeSchema.MessageContent.Fields.Value, BooleanDataValue.Create(true)));
contentValues.Values.Add(badContent);
// Act
ChatMessage message = record.ToChatMessage();
// Assert
Assert.Single(message.Contents);
Assert.Equal("Test", message.Text);
}
[Fact]
public void ToChatMessageWithEmptyContent()
{
// Arrange
ChatMessage source = new(ChatRole.User, "Test");
source.Contents.Add(new TextContent(string.Empty));
RecordDataValue record = source.ToRecord().ToRecord();
// Act
ChatMessage message = record.ToChatMessage();
// Assert
Assert.Single(message.Contents);
Assert.Equal("Test", message.Text);
}
[Fact]
public void ToMetadataWithNull()
{
// Arrange
RecordDataValue? metadata = null;
// Act
AdditionalPropertiesDictionary? result = metadata.ToMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToMetadataWithProperties()
{
// Arrange
RecordDataValue metadata = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("key1", StringDataValue.Create("value1")),
new KeyValuePair<string, DataValue>("key2", NumberDataValue.Create(42)));
// Act
AdditionalPropertiesDictionary? result = metadata.ToMetadata();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Equal("value1", result["key1"]);
Assert.Equal(42m, result["key2"]);
}
[Fact]
public void ToChatRoleFromAgentMessageRole()
{
// Act & Assert
Assert.Equal(ChatRole.Assistant, AgentMessageRole.Agent.ToChatRole());
Assert.Equal(ChatRole.User, AgentMessageRole.User.ToChatRole());
Assert.Equal(ChatRole.User, ((AgentMessageRole)99).ToChatRole());
Assert.Equal(ChatRole.User, ((AgentMessageRole?)null).ToChatRole());
}
[Fact]
public void AgentMessageContentTypeToContentMissing()
{
// Act & Assert
Assert.Null(AgentMessageContentType.Text.ToContent(string.Empty));
Assert.Null(AgentMessageContentType.Text.ToContent(null));
}
[Fact]
public void AgentMessageContentTypeToContentText()
{
// Arrange & Act
AIContent? result = AgentMessageContentType.Text.ToContent("Sample text");
// Assert
Assert.NotNull(result);
TextContent textContent = Assert.IsType<TextContent>(result);
Assert.Equal("Sample text", textContent.Text);
}
[Fact]
public void ToContentWithImageUrlContentType()
{
// Arrange & Act
AIContent? result = AgentMessageContentType.ImageUrl.ToContent("https://example.com/image.jpg");
// Assert
Assert.NotNull(result);
UriContent uriContent = Assert.IsType<UriContent>(result);
Assert.Equal("https://example.com/image.jpg", uriContent.Uri.ToString());
}
[Fact]
public void ToContentWithImageUrlContentTypeDataUri()
{
// Arrange & Act
AIContent? result = AgentMessageContentType.ImageUrl.ToContent("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA");
// Assert
Assert.NotNull(result);
DataContent dataContent = Assert.IsType<DataContent>(result);
Assert.False(dataContent.Data.IsEmpty);
}
[Fact]
public void ToContentWithImageFileContentType()
{
// Arrange & Act
AIContent? result = AgentMessageContentType.ImageFile.ToContent("file-id-123");
// Assert
Assert.NotNull(result);
HostedFileContent fileContent = Assert.IsType<HostedFileContent>(result);
Assert.Equal("file-id-123", fileContent.FileId);
}
[Fact]
public void ToChatMessageFromFunctionResultContents()
{
// Arrange
IEnumerable<FunctionResultContent> functionResults =
[
new(callId: "call1", result: "Result 1"),
new(callId: "call2", result: "Result 2")
];
// Act
ChatMessage result = functionResults.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.Tool, result.Role);
Assert.Equal(2, result.Contents.Count);
}
[Fact]
public void ToChatMessagesFromTableDataValueWithStrings()
{
// Arrange
TableDataValue table =
DataValue.TableFromValues(
[
StringDataValue.Create("Message 1"),
StringDataValue.Create("Message 2")
]);
// Act
IEnumerable<ChatMessage> result = table.ToChatMessages();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count());
Assert.All(result, msg => Assert.Equal(ChatRole.User, msg.Role));
}
[Fact]
public void ToChatMessagesFromTableDataValueWithRecords()
{
// Arrange
RecordDataValue record1 = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Role, StringDataValue.Create("User")),
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Content, DataValue.EmptyTable));
RecordDataValue record2 = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Role, StringDataValue.Create("Assistant")),
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Content, DataValue.EmptyTable));
TableDataValue table = DataValue.TableFromRecords(record1, record2);
// Act
IEnumerable<ChatMessage> result = table.ToChatMessages();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count());
}
[Fact]
public void ToChatMessagesFromTableDataValueWithSingleColumnRecords()
{
// Arrange
RecordDataValue innerRecord = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Role, StringDataValue.Create("User")),
new KeyValuePair<string, DataValue>(TypeSchema.Message.Fields.Content, DataValue.EmptyTable));
RecordDataValue wrappedRecord = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("Value", innerRecord));
TableDataValue table = DataValue.TableFromRecords(wrappedRecord);
// Act
IEnumerable<ChatMessage> result = table.ToChatMessages();
// Assert
Assert.NotNull(result);
ChatMessage message = Assert.Single(result);
Assert.Equal(ChatRole.User, message.Role);
}
[Fact]
public void ToRecordWithMessageContainingMultipleContentItems()
{
// Arrange
ChatMessage message =
new(ChatRole.User,
[
new TextContent("First part"),
new TextContent("Second part")
]);
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
FormulaValue contentField = result.GetField(TypeSchema.Message.Fields.Content);
TableValue contentTable = Assert.IsType<TableValue>(contentField, exactMatch: false);
Assert.Equal(2, contentTable.Rows.Count());
}
[Fact]
public void ToRecordWithMessageContainingUriContent()
{
// Arrange
ChatMessage message =
new(ChatRole.User,
[
new UriContent("https://example.com/image.jpg", "image/*")
]);
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
FormulaValue contentField = result.GetField(TypeSchema.Message.Fields.Content);
TableValue contentTable = Assert.IsType<TableValue>(contentField, exactMatch: false);
Assert.Single(contentTable.Rows);
}
[Fact]
public void ToRecordWithMessageContainingHostedFileContent()
{
// Arrange
ChatMessage message =
new(ChatRole.User,
[
new HostedFileContent("file-123")
]);
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
FormulaValue contentField = result.GetField(TypeSchema.Message.Fields.Content);
TableValue contentTable = Assert.IsType<TableValue>(contentField, exactMatch: false);
Assert.Single(contentTable.Rows);
}
[Fact]
public void ToRecordWithMessageContainingMetadata()
{
// Arrange
ChatMessage message = new(ChatRole.User, "Test message")
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
["custom_key"] = "custom_value",
["count"] = 5
}
};
// Act
RecordValue result = message.ToRecord();
// Assert
Assert.NotNull(result);
FormulaValue metadataField = result.GetField(TypeSchema.Message.Fields.Metadata);
RecordValue metadataRecord = Assert.IsType<RecordValue>(metadataField, exactMatch: false);
Assert.Equal(2, metadataRecord.Fields.Count());
}
[Fact]
public void RoundTripChatMessageAsRecord()
{
// Arrange
ChatMessage message =
new(ChatRole.User,
[
new TextContent("Test message"),
new UriContent("https://example.com/image.jpg", "image/jpeg"),
new HostedFileContent("file_123abc"),
new DataContent(new byte[] { 1, 2, 3, 4, 5 }, "application/pdf"),
])
{
MessageId = "msg-001"
};
// Act
RecordValue result = message.ToRecord();
DataValue resultValue = result.ToDataValue();
ChatMessage? messageCopy = resultValue.ToChatMessage();
// Assert
Assert.NotNull(messageCopy);
Assert.Equal(message.Role, messageCopy.Role);
Assert.Equal(message.MessageId, messageCopy.MessageId);
Assert.Equal(message.Contents.Count, messageCopy.Contents.Count);
foreach (AIContent contentCopy in messageCopy.Contents)
{
AIContent sourceContent = Assert.Single(message.Contents, c => c.GetType() == contentCopy.GetType());
AssertAIContentEquivalent(sourceContent, contentCopy);
}
}
[Fact]
public void RoundTripChatMessageAsTable()
{
// Arrange
ChatMessage message =
new(ChatRole.User,
[
new TextContent("Test message"),
new UriContent("https://example.com/image.jpg", "image/jpeg"),
new HostedFileContent("file_123abc"),
new DataContent(new byte[] { 1, 2, 3, 4, 5 }, "application/pdf"),
])
{
MessageId = "msg-001"
};
IEnumerable<ChatMessage> messages = [message];
// Act
TableValue result = messages.ToTable();
TableDataValue resultValue = result.ToTable();
ChatMessage[] messagesCopy = resultValue.ToChatMessages().ToArray();
// Assert
Assert.NotNull(messagesCopy);
ChatMessage messageCopy = Assert.Single(messagesCopy);
Assert.Equal(message.Role, messageCopy.Role);
Assert.Equal(message.MessageId, messageCopy.MessageId);
Assert.Equal(message.Contents.Count, messageCopy.Contents.Count);
foreach (AIContent contentCopy in messageCopy.Contents)
{
AIContent sourceContent = Assert.Single(message.Contents, c => c.GetType() == contentCopy.GetType());
AssertAIContentEquivalent(sourceContent, contentCopy);
}
}
/// <summary>
/// Compares two AIContent instances for equivalence without using Assert.Equivalent,
/// which fails on .NET Framework 4.7.2 due to ReadOnlySpan.GetHashCode() not being supported.
/// </summary>
private static void AssertAIContentEquivalent(AIContent expected, AIContent actual)
{
Assert.Equal(expected.GetType(), actual.GetType());
switch (expected)
{
case TextContent expectedText:
TextContent actualText = Assert.IsType<TextContent>(actual);
Assert.Equal(expectedText.Text, actualText.Text);
break;
case UriContent expectedUri:
UriContent actualUri = Assert.IsType<UriContent>(actual);
Assert.Equal(expectedUri.Uri, actualUri.Uri);
Assert.Equal(expectedUri.MediaType, actualUri.MediaType);
break;
case HostedFileContent expectedFile:
HostedFileContent actualFile = Assert.IsType<HostedFileContent>(actual);
Assert.Equal(expectedFile.FileId, actualFile.FileId);
break;
case DataContent expectedData:
DataContent actualData = Assert.IsType<DataContent>(actual);
Assert.Equal(expectedData.MediaType, actualData.MediaType);
Assert.Equal(expectedData.Data.ToArray(), actualData.Data.ToArray());
break;
default:
Assert.Fail($"Unexpected AIContent type: {expected.GetType().Name}");
break;
}
}
[Fact]
public void MergeForLastMessageReturnsInputWhenInputMessageIsNull()
{
// Arrange
ChatMessage input = new(ChatRole.User, "hello") { MessageId = "local" };
// Act
ChatMessage result = input.MergeForLastMessage(null);
// Assert
Assert.Same(input, result);
}
[Fact]
public void MergeForLastMessageReturnsSameInstanceAsRoundTripped()
{
// Arrange: returning the round-tripped instance keeps the merge forward-compatible
// with future ChatMessage properties (e.g., new metadata fields) without explicit copies.
ChatMessage input = new(ChatRole.User, "original");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Same(roundTripped, result);
}
[Fact]
public void MergeForLastMessagePrefersOriginalTextOverRoundTrippedText()
{
// Arrange
ChatMessage input = new(ChatRole.User, "original text");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server-id", result.MessageId);
Assert.Equal("original text", result.Text);
TextContent text = Assert.IsType<TextContent>(Assert.Single(result.Contents));
Assert.Equal("original text", text.Text);
}
[Fact]
public void MergeForLastMessageReplacesTextInPlaceAndKeepsServerMedia()
{
// Arrange
HostedFileContent serverRef = new("file-abc");
ChatMessage input = new(ChatRole.User, [new TextContent("look at this:"), new DataContent("data:image/jpeg;base64,QUJD", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped"), serverRef]) { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: server's text slot is replaced with original text; server's media reference is preserved.
Assert.Equal("server-id", result.MessageId);
Assert.Collection(result.Contents,
c => Assert.Equal("look at this:", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(serverRef, c));
}
[Fact]
public void MergeForLastMessageAppendsOriginalTextWhenRoundTripHasNoTextSlot()
{
// Arrange: round-tripped message has only media (no text slot to replace).
HostedFileContent serverRef = new("file-1");
ChatMessage input = new(ChatRole.User, [new TextContent("middle"), new DataContent("data:image/jpeg;base64,QUE=", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: media kept; original text appended at end.
Assert.Collection(result.Contents,
c => Assert.Same(serverRef, c),
c => Assert.Equal("middle", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageReplacesMultipleTextSlotsInOrder()
{
// Arrange: input has two text items; round-tripped has two text slots interleaved with media.
HostedFileContent firstRef = new("file-1");
HostedFileContent secondRef = new("file-2");
ChatMessage input = new(ChatRole.User, [new TextContent("first"), new TextContent("second")]);
ChatMessage roundTripped = new(ChatRole.User, [firstRef, new TextContent("a"), secondRef, new TextContent("b")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Collection(result.Contents,
c => Assert.Same(firstRef, c),
c => Assert.Equal("first", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(secondRef, c),
c => Assert.Equal("second", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageFallsBackToInputTextWhenInputHasNoTextContent()
{
// Arrange: ChatMessage(role, "string") populates Text but no explicit TextContent
// when Contents is initially empty in some construction paths. Verify we still
// recover the original Text via input.Text.
ChatMessage input = new(ChatRole.User, "fallback text");
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("fallback text", Assert.IsType<TextContent>(Assert.Single(result.Contents)).Text);
}
[Fact]
public void MergeForLastMessagePreservesServerAuthoredProperties()
{
// Arrange: server (round-trip) is authoritative for metadata. Returning the
// round-tripped instance means any future ChatMessage property is automatically
// preserved without code changes here.
ChatMessage input = new(ChatRole.User, "hi")
{
AuthorName = "client-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["client"] = "value" },
};
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")])
{
MessageId = "server",
AuthorName = "server-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["server"] = "value" },
};
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server", result.MessageId);
Assert.Equal("server-side", result.AuthorName);
Assert.NotNull(result.AdditionalProperties);
Assert.True(result.AdditionalProperties.ContainsKey("server"));
Assert.False(result.AdditionalProperties.ContainsKey("client"));
}
[Fact]
public void MergeForLastMessageHandlesEmptyInputContents()
{
// Arrange
ChatMessage input = new(ChatRole.User, new List<AIContent>());
HostedFileContent serverRef = new("file-only");
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: nothing to splice; round-tripped returned unchanged.
Assert.Same(roundTripped, result);
Assert.Equal("file-only", Assert.IsType<HostedFileContent>(Assert.Single(result.Contents)).FileId);
}
}
@@ -0,0 +1,845 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class DataValueExtensionsTests
{
[Fact]
public void ToDataValueWithNull()
{
// Arrange
object? value = null;
// Act
DataValue result = value.ToDataValue();
// Assert
Assert.IsType<BlankDataValue>(result);
}
[Fact]
public void ToDataValueWithUnassignedValue()
{
// Arrange
object value = UnassignedValue.Instance;
// Act
DataValue result = value.ToDataValue();
// Assert
Assert.IsType<BlankDataValue>(result);
}
[Fact]
public void ToDataValueWithBooleanTrue()
{
// Arrange
const bool Value = true;
// Act
DataValue result = Value.ToDataValue();
// Assert
BooleanDataValue boolValue = Assert.IsType<BooleanDataValue>(result);
Assert.True(boolValue.Value);
}
[Fact]
public void ToDataValueWithBooleanFalse()
{
// Arrange
const bool Value = false;
// Act
DataValue result = Value.ToDataValue();
// Assert
BooleanDataValue boolValue = Assert.IsType<BooleanDataValue>(result);
Assert.False(boolValue.Value);
}
[Fact]
public void ToDataValueWithInt()
{
// Arrange
const int Value = 42;
// Act
DataValue result = Value.ToDataValue();
// Assert
NumberDataValue numberValue = Assert.IsType<NumberDataValue>(result);
Assert.Equal(42, numberValue.Value);
}
[Fact]
public void ToDataValueWithLong()
{
// Arrange
const long Value = 9876543210L;
// Act
DataValue result = Value.ToDataValue();
// Assert
NumberDataValue numberValue = Assert.IsType<NumberDataValue>(result);
Assert.Equal(9876543210L, numberValue.Value);
}
[Fact]
public void ToDataValueWithFloat()
{
// Arrange
const float Value = 3.14f;
// Act
DataValue result = Value.ToDataValue();
// Assert
FloatDataValue floatValue = Assert.IsType<FloatDataValue>(result);
Assert.Equal(3.14f, floatValue.Value, precision: 2);
}
[Fact]
public void ToDataValueWithDecimal()
{
// Arrange
const decimal Value = 123.456m;
// Act
DataValue result = Value.ToDataValue();
// Assert
NumberDataValue numberValue = Assert.IsType<NumberDataValue>(result);
Assert.Equal(123.456m, numberValue.Value);
}
[Fact]
public void ToDataValueWithDouble()
{
// Arrange
const double Value = 2.71828;
// Act
DataValue result = Value.ToDataValue();
// Assert
FloatDataValue floatValue = Assert.IsType<FloatDataValue>(result);
Assert.Equal(2.71828, floatValue.Value, precision: 5);
}
[Fact]
public void ToDataValueWithString()
{
// Arrange
const string Value = "Test String";
// Act
DataValue result = Value.ToDataValue();
// Assert
StringDataValue stringValue = Assert.IsType<StringDataValue>(result);
Assert.Equal("Test String", stringValue.Value);
}
[Fact]
public void ToDataValueWithDateTimeZeroTime()
{
// Arrange
DateTime value = new(2025, 10, 17, 0, 0, 0);
// Act
DataValue result = value.ToDataValue();
// Assert
DateDataValue dateValue = Assert.IsType<DateDataValue>(result);
Assert.Equal(new DateTime(2025, 10, 17), dateValue.Value);
}
[Fact]
public void ToDataValueWithDateTimeNonZeroTime()
{
// Arrange
DateTime value = new(2025, 10, 17, 14, 30, 45);
// Act
DataValue result = value.ToDataValue();
// Assert
DateTimeDataValue dateTimeValue = Assert.IsType<DateTimeDataValue>(result);
Assert.Equal(new DateTime(2025, 10, 17, 14, 30, 45), dateTimeValue.Value.DateTime);
}
[Fact]
public void ToDataValueWithTimeSpan()
{
// Arrange
TimeSpan value = TimeSpan.FromHours(2.5);
// Act
DataValue result = value.ToDataValue();
// Assert
TimeDataValue timeValue = Assert.IsType<TimeDataValue>(result);
Assert.Equal(TimeSpan.FromHours(2.5), timeValue.Value);
}
[Fact]
public void ToDataValueWithDataValue()
{
// Arrange
DataValue value = StringDataValue.Create("Already a DataValue");
// Act
DataValue result = value.ToDataValue();
// Assert
Assert.Same(value, result);
}
[Fact]
public void ToDataValueWithFormulaValue()
{
// Arrange
FormulaValue value = FormulaValue.New(123);
// Act
DataValue result = value.ToDataValue();
// Assert
NumberDataValue numberValue = Assert.IsType<NumberDataValue>(result);
Assert.Equal(123, numberValue.Value);
}
[Fact]
public void ToFormulaWithNull()
{
// Arrange
DataValue? value = null;
// Act
FormulaValue result = value.ToFormula();
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void ToFormulaWithBlankDataValue()
{
// Arrange
DataValue value = DataValue.Blank();
// Act
FormulaValue result = value.ToFormula();
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void ToFormulaWithBooleanDataValue()
{
// Arrange
DataValue value = BooleanDataValue.Create(true);
// Act
FormulaValue result = value.ToFormula();
// Assert
BooleanValue boolValue = Assert.IsType<BooleanValue>(result);
Assert.True(boolValue.Value);
}
[Fact]
public void ToFormulaWithNumberDataValue()
{
// Arrange
DataValue value = NumberDataValue.Create(99.5m);
// Act
FormulaValue result = value.ToFormula();
// Assert
DecimalValue decimalValue = Assert.IsType<DecimalValue>(result);
Assert.Equal(99.5m, decimalValue.Value);
}
[Fact]
public void ToFormulaWithFloatDataValue()
{
// Arrange
DataValue value = FloatDataValue.Create(1.23);
// Act
FormulaValue result = value.ToFormula();
// Assert
NumberValue numberValue = Assert.IsType<NumberValue>(result);
Assert.Equal(1.23, numberValue.Value, precision: 2);
}
[Fact]
public void ToFormulaWithStringDataValue()
{
// Arrange
DataValue value = StringDataValue.Create("Test");
// Act
FormulaValue result = value.ToFormula();
// Assert
StringValue stringValue = Assert.IsType<StringValue>(result);
Assert.Equal("Test", stringValue.Value);
}
[Fact]
public void ToFormulaWithDateTimeDataValue()
{
// Arrange
DateTime dateTime = new(2025, 10, 17, 12, 0, 0);
DataValue value = DateTimeDataValue.Create(dateTime);
// Act
FormulaValue result = value.ToFormula();
// Assert
DateTimeValue dateTimeValue = Assert.IsType<DateTimeValue>(result);
Assert.Equal(dateTime, dateTimeValue.GetConvertedValue(TimeZoneInfo.Utc));
}
[Fact]
public void ToFormulaWithDateDataValue()
{
// Arrange
DateTime date = new(2025, 10, 17);
DataValue value = DateDataValue.Create(date);
// Act
FormulaValue result = value.ToFormula();
// Assert
DateValue dateValue = Assert.IsType<DateValue>(result);
Assert.Equal(date, dateValue.GetConvertedValue(TimeZoneInfo.Utc));
}
[Fact]
public void ToFormulaWithTimeDataValue()
{
// Arrange
TimeSpan time = TimeSpan.FromHours(3);
DataValue value = TimeDataValue.Create(time);
// Act
FormulaValue result = value.ToFormula();
// Assert
TimeValue timeValue = Assert.IsType<TimeValue>(result);
Assert.Equal(time, timeValue.Value);
}
[Fact]
public void ToFormulaWithRecordDataValue()
{
// Arrange
DataValue value = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("Name", StringDataValue.Create("John")),
new KeyValuePair<string, DataValue>("Age", NumberDataValue.Create(30)));
// Act
FormulaValue result = value.ToFormula();
// Assert
RecordValue recordValue = Assert.IsType<RecordValue>(result, exactMatch: false);
Assert.Equal(2, recordValue.Fields.Count());
}
[Fact]
public void ToFormulaWithTableDataValue()
{
// Arrange
RecordDataValue record = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("Field", StringDataValue.Create("Value")));
DataValue value = DataValue.TableFromRecords(ImmutableArray.Create(record));
// Act
FormulaValue result = value.ToFormula();
// Assert
TableValue tableValue = Assert.IsType<TableValue>(result, exactMatch: false);
Assert.Single(tableValue.Rows);
}
[Fact]
public void ToFormulaTypeWithNull()
{
// Arrange
DataValue? value = null;
// Act
FormulaType result = value.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Blank, result);
}
[Fact]
public void ToFormulaTypeWithBooleanDataValue()
{
// Arrange
DataValue value = BooleanDataValue.Create(true);
// Act
FormulaType result = value.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Boolean, result);
}
[Fact]
public void ToFormulaTypeWithStringDataValue()
{
// Arrange
DataValue value = StringDataValue.Create("Test");
// Act
FormulaType result = value.ToFormulaType();
// Assert
Assert.Equal(FormulaType.String, result);
}
[Fact]
public void DataTypeToFormulaTypeWithNull()
{
// Arrange
DataType? type = null;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Blank, result);
}
[Fact]
public void DataTypeToFormulaTypeWithBooleanDataType()
{
// Arrange
DataType type = BooleanDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Boolean, result);
}
[Fact]
public void DataTypeToFormulaTypeWithNumberDataType()
{
// Arrange
DataType type = NumberDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Decimal, result);
}
[Fact]
public void DataTypeToFormulaTypeWithFloatDataType()
{
// Arrange
DataType type = FloatDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Number, result);
}
[Fact]
public void DataTypeToFormulaTypeWithStringDataType()
{
// Arrange
DataType type = StringDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.String, result);
}
[Fact]
public void DataTypeToFormulaTypeWithDateTimeDataType()
{
// Arrange
DataType type = DateTimeDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.DateTime, result);
}
[Fact]
public void DataTypeToFormulaTypeWithDateDataType()
{
// Arrange
DataType type = DateDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Date, result);
}
[Fact]
public void DataTypeToFormulaTypeWithTimeDataType()
{
// Arrange
DataType type = TimeDataType.Instance;
// Act
FormulaType result = type.ToFormulaType();
// Assert
Assert.Equal(FormulaType.Time, result);
}
[Fact]
public void ToObjectWithNull()
{
// Arrange
DataValue? value = null;
// Act
object? result = value.ToObject();
// Assert
Assert.Null(result);
}
[Fact]
public void ToObjectWithBlankDataValue()
{
// Arrange
DataValue value = DataValue.Blank();
// Act
object? result = value.ToObject();
// Assert
Assert.Null(result);
}
[Fact]
public void ToObjectWithBooleanDataValue()
{
// Arrange
DataValue value = BooleanDataValue.Create(true);
// Act
object? result = value.ToObject();
// Assert
Assert.IsType<bool>(result);
Assert.True((bool)result);
}
[Fact]
public void ToObjectWithNumberDataValue()
{
// Arrange
DataValue value = NumberDataValue.Create(42.5m);
// Act
object? result = value.ToObject();
// Assert
Assert.IsType<decimal>(result);
Assert.Equal(42.5m, (decimal)result);
}
[Fact]
public void ToObjectWithStringDataValue()
{
// Arrange
DataValue value = StringDataValue.Create("Hello");
// Act
object? result = value.ToObject();
// Assert
Assert.IsType<string>(result);
Assert.Equal("Hello", (string)result);
}
[Fact]
public void ToClrTypeWithBooleanDataType()
{
// Arrange
DataType type = BooleanDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(bool), result);
}
[Fact]
public void ToClrTypeWithNumberDataType()
{
// Arrange
DataType type = NumberDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(decimal), result);
}
[Fact]
public void ToClrTypeWithFloatDataType()
{
// Arrange
DataType type = FloatDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(double), result);
}
[Fact]
public void ToClrTypeWithStringDataType()
{
// Arrange
DataType type = StringDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(string), result);
}
[Fact]
public void ToClrTypeWithDateTimeDataType()
{
// Arrange
DataType type = DateTimeDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(DateTime), result);
}
[Fact]
public void ToClrTypeWithTimeDataType()
{
// Arrange
DataType type = TimeDataType.Instance;
// Act
Type result = type.ToClrType();
// Assert
Assert.Equal(typeof(TimeSpan), result);
}
[Fact]
public void AsListWithNull()
{
// Arrange
DataValue? value = null;
// Act
IList<string>? result = value.AsList<string>();
// Assert
Assert.Null(result);
}
[Fact]
public void AsListWithBlankDataValue()
{
// Arrange
DataValue value = DataValue.Blank();
// Act
IList<string>? result = value.AsList<string>();
// Assert
Assert.Null(result);
}
[Fact]
public void NewBlankWithNullDataType()
{
// Arrange
DataType? type = null;
// Act
FormulaValue result = type.NewBlank();
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void NewBlankWithBooleanDataType()
{
// Arrange
DataType type = BooleanDataType.Instance;
// Act
FormulaValue result = type.NewBlank();
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void ToRecordValueWithRecordDataValue()
{
// Arrange
RecordDataValue recordDataValue = DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("Field1", StringDataValue.Create("Value1")),
new KeyValuePair<string, DataValue>("Field2", NumberDataValue.Create(123)));
// Act
RecordValue result = recordDataValue.ToRecordValue();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Fields.Count());
Assert.NotNull(result.GetField("Field1"));
Assert.NotNull(result.GetField("Field2"));
}
[Fact]
public void ToRecordTypeWithRecordDataType()
{
// Arrange
RecordDataType recordDataType = new RecordDataType.Builder
{
Properties =
{
["Name"] = new PropertyInfo.Builder
{
Type = StringDataType.Instance
}.Build(),
["Count"] = new PropertyInfo.Builder
{
Type = NumberDataType.Instance
}.Build()
}
}.Build();
// Act
RecordType result = recordDataType.ToRecordType();
// Assert
Assert.NotNull(result);
IEnumerable<NamedFormulaType> fieldTypes = result.GetFieldTypes();
List<NamedFormulaType> fieldTypesList = fieldTypes.ToList();
Assert.Equal(2, fieldTypesList.Count);
IEnumerable<string> fieldNames = fieldTypesList.Select(f => f.Name.Value);
Assert.Contains("Name", fieldNames);
Assert.Contains("Count", fieldNames);
NamedFormulaType nameField = fieldTypesList.First(f => f.Name.Value == "Name");
NamedFormulaType countField = fieldTypesList.First(f => f.Name.Value == "Count");
Assert.Equal(FormulaType.String, nameField.Type);
Assert.Equal(FormulaType.Decimal, countField.Type);
}
[Fact]
public void ToRecordValueWithDictionary()
{
// Arrange
IDictionary dictionary = new Dictionary<string, object>
{
["Key1"] = "Value1",
["Key2"] = 42
};
// Act
RecordDataValue result = dictionary.ToRecordValue();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Properties.Count);
Assert.True(result.Properties.ContainsKey("Key1"));
Assert.True(result.Properties.ContainsKey("Key2"));
}
[Fact]
public void ToTableValueWithEmptyEnumerable()
{
// Arrange
IEnumerable enumerable = Array.Empty<object>();
// Act
TableDataValue result = enumerable.ToTableValue();
// Assert
Assert.NotNull(result);
Assert.Empty(result.Values);
}
[Fact]
public void ToTableValueWithDictionaryEnumerable()
{
// Arrange
IEnumerable enumerable = new List<IDictionary>
{
new Dictionary<string, object> { ["Name"] = "Alice", ["Age"] = 30 },
new Dictionary<string, object> { ["Name"] = "Bob", ["Age"] = 25 }
};
// Act
TableDataValue result = enumerable.ToTableValue();
// Assert
Assert.NotNull(result);
}
[Fact]
public void ToTableValueWithPrimitiveEnumerable()
{
// Arrange
IEnumerable enumerable = new List<int> { 1, 2, 3 };
// Act
TableDataValue result = enumerable.ToTableValue();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Values.Length);
}
}
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.PowerFx;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class DeclarativeWorkflowOptionsExtensionsTests
{
[Fact]
public void NullContext_UsesDefaultMaximumExpressionLength()
{
// Arrange
DeclarativeWorkflowOptions? options = null;
// Act
RecalcEngine engine = options.CreateRecalcEngine();
// Assert
Assert.NotNull(engine);
Assert.Equal(10000, engine.Config.MaximumExpressionLength);
}
[Fact]
public void OptionsWithoutLimits_UsesDefaults()
{
// Arrange
DeclarativeWorkflowOptions options = CreateOptions();
// Act
RecalcEngine engine = options.CreateRecalcEngine();
// Assert
Assert.NotNull(engine);
Assert.Equal(10000, engine.Config.MaximumExpressionLength);
Assert.True(engine.Config.MaxCallDepth >= 0);
}
[Fact]
public void OptionsWithBothLimits()
{
// Arrange
const int ExpectedLength = 5000;
const int ExpectedDepth = 12;
DeclarativeWorkflowOptions context = CreateOptions(ExpectedLength, ExpectedDepth);
// Act
RecalcEngine engine = context.CreateRecalcEngine();
// Assert
Assert.Equal(ExpectedLength, engine.Config.MaximumExpressionLength);
Assert.Equal(ExpectedDepth, engine.Config.MaxCallDepth);
}
// Factory for creating options and mock provider
private static DeclarativeWorkflowOptions CreateOptions(
int? maximumExpressionLength = null,
int? maximumCallDepth = null)
{
Mock<ResponseAgentProvider> providerMock = new(MockBehavior.Strict);
return
new(providerMock.Object)
{
MaximumExpressionLength = maximumExpressionLength,
MaximumCallDepth = maximumCallDepth
};
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
/// <summary>
/// Tests for <see cref="DialogBaseExtensions"/>.
/// </summary>
public sealed class DialogBaseExtensionsTests
{
[Fact]
public void WrapWithBotCreatesValidBotDefinition()
{
// Arrange
AdaptiveDialog dialog = new AdaptiveDialog.Builder()
{
BeginDialog = new OnActivity.Builder()
{
Id = "test_dialog",
},
}.Build();
// Assert
Assert.False(dialog.HasSchemaName);
// Act
AdaptiveDialog wrappedDialog = dialog.WrapWithBot();
// Assert
VerifyWrappedDialog(wrappedDialog);
// Act & Assert
VerifyWrappedDialog(wrappedDialog.WrapWithBot());
}
private static void VerifyWrappedDialog(AdaptiveDialog wrappedDialog)
{
Assert.NotNull(wrappedDialog);
Assert.NotNull(wrappedDialog.BeginDialog);
Assert.Equal("test_dialog", wrappedDialog.BeginDialog.Id);
Assert.True(wrappedDialog.HasSchemaName);
}
}
@@ -0,0 +1,217 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class ExpandoObjectExtensionsTests
{
[Fact]
public void ToRecordTypeWithEmptyExpandoObject()
{
// Arrange
ExpandoObject expando = new();
// Act
RecordType recordType = expando.ToRecordType();
// Assert
Assert.NotNull(recordType);
Assert.Empty(recordType.GetFieldTypes());
}
[Fact]
public void ToRecordTypeWithStringProperty()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Name = "John Doe";
// Act
RecordType recordType = ((ExpandoObject)expando).ToRecordType();
// Assert
Assert.NotNull(recordType);
IEnumerable<NamedFormulaType> fieldTypes = recordType.GetFieldTypes();
Assert.Single(fieldTypes);
NamedFormulaType field = fieldTypes.First();
Assert.Equal("Name", field.Name.Value);
Assert.Equal(FormulaType.String, field.Type);
}
[Fact]
public void ToRecordTypeWithMultipleProperties()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Name = "Alice";
expando.Age = 30;
expando.IsActive = true;
// Act
RecordType recordType = ((ExpandoObject)expando).ToRecordType();
// Assert
Assert.NotNull(recordType);
IEnumerable<NamedFormulaType> fieldTypes = recordType.GetFieldTypes();
Assert.Equal(3, fieldTypes.Count());
IEnumerable<string> fieldNames = fieldTypes.Select(f => f.Name.Value);
Assert.Contains("Name", fieldNames);
Assert.Contains("Age", fieldNames);
Assert.Contains("IsActive", fieldNames);
}
[Fact]
public void ToRecordTypeWithNullProperty()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Name = "Test";
expando.NullValue = null;
// Act
RecordType recordType = ((ExpandoObject)expando).ToRecordType();
// Assert
Assert.NotNull(recordType);
IEnumerable<NamedFormulaType> fieldTypes = recordType.GetFieldTypes();
Assert.Equal(2, fieldTypes.Count());
IEnumerable<string> fieldNames = fieldTypes.Select(f => f.Name.Value);
Assert.Contains("Name", fieldNames);
Assert.Contains("NullValue", fieldNames);
}
[Fact]
public void ToRecordWithEmptyExpandoObject()
{
// Arrange
ExpandoObject expando = new();
// Act
RecordValue recordValue = expando.ToRecord();
// Assert
Assert.NotNull(recordValue);
Assert.Empty(recordValue.Fields);
}
[Fact]
public void ToRecordWithStringProperty()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Message = "Hello World";
// Act
RecordValue recordValue = ((ExpandoObject)expando).ToRecord();
// Assert
Assert.NotNull(recordValue);
Assert.Single(recordValue.Fields);
NamedValue field = recordValue.Fields.First();
Assert.Equal("Message", field.Name);
StringValue stringValue = Assert.IsType<StringValue>(field.Value);
Assert.Equal("Hello World", stringValue.Value);
}
[Fact]
public void ToRecordWithMultiplePropertiesOfDifferentTypes()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Name = "Bob";
expando.Count = 42;
expando.Active = true;
// Act
RecordValue recordValue = ((ExpandoObject)expando).ToRecord();
// Assert
Assert.NotNull(recordValue);
Assert.Equal(3, recordValue.Fields.Count());
FormulaValue nameField = recordValue.GetField("Name");
StringValue nameValue = Assert.IsType<StringValue>(nameField);
Assert.Equal("Bob", nameValue.Value);
FormulaValue countField = recordValue.GetField("Count");
DecimalValue countValue = Assert.IsType<DecimalValue>(countField);
Assert.Equal(42, countValue.Value);
FormulaValue activeField = recordValue.GetField("Active");
BooleanValue activeValue = Assert.IsType<BooleanValue>(activeField);
Assert.True(activeValue.Value);
}
[Fact]
public void ToRecordWithNestedExpandoObject()
{
// Arrange
dynamic nested = new ExpandoObject();
nested.InnerValue = "Inner";
dynamic expando = new ExpandoObject();
expando.Outer = "Outer";
expando.Nested = nested;
// Act
RecordValue recordValue = ((ExpandoObject)expando).ToRecord();
// Assert
Assert.NotNull(recordValue);
Assert.Equal(2, recordValue.Fields.Count());
Assert.NotNull(recordValue.GetField("Outer"));
FormulaValue nestedField = recordValue.GetField("Nested");
Assert.NotNull(nestedField);
RecordValue nestedRecord = Assert.IsType<RecordValue>(nestedField, exactMatch: false);
Assert.Single(nestedRecord.Fields);
}
[Fact]
public void ToRecordWithNullProperty()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.Name = "Test";
expando.NullValue = null;
// Act
RecordValue recordValue = ((ExpandoObject)expando).ToRecord();
// Assert
Assert.NotNull(recordValue);
Assert.Equal(2, recordValue.Fields.Count());
FormulaValue nullField = recordValue.GetField("NullValue");
Assert.IsType<BlankValue>(nullField);
}
[Fact]
public void ToRecordTypeAndToRecordAreConsistent()
{
// Arrange
dynamic expando = new ExpandoObject();
expando.StringField = "Value";
expando.IntField = 123;
expando.BoolField = false;
// Act
RecordType recordType = ((ExpandoObject)expando).ToRecordType();
RecordValue recordValue = ((ExpandoObject)expando).ToRecord();
// Assert
List<NamedFormulaType> fieldTypesList = recordType.GetFieldTypes().ToList();
Assert.Equal(fieldTypesList.Count, recordValue.Fields.Count());
foreach (NamedFormulaType fieldType in fieldTypesList)
{
Assert.Contains(recordValue.Fields, f => f.Name == fieldType.Name.Value);
}
}
}
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public class FormulaValueExtensionsTests
{
[Fact]
public void BooleanValue()
{
BooleanValue formulaValue = FormulaValue.New(true);
DataValue dataValue = formulaValue.ToDataValue();
BooleanDataValue typedValue = Assert.IsType<BooleanDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
BooleanValue formulaCopy = Assert.IsType<BooleanValue>(dataValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.Value);
Assert.Equal(bool.TrueString, formulaValue.Format());
}
[Fact]
public void StringValues()
{
StringValue formulaValue = FormulaValue.New("test value");
Assert.Equal(StringDataType.Instance, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
StringDataValue typedValue = Assert.IsType<StringDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
StringValue formulaCopy = Assert.IsType<StringValue>(typedValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.Value);
Assert.Equal(formulaValue.Value, formulaValue.Format());
}
[Fact]
public void DecimalValues()
{
DecimalValue formulaValue = FormulaValue.New(45.3m);
Assert.Equal(NumberDataType.Instance, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
NumberDataValue typedValue = Assert.IsType<NumberDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
DecimalValue formulaCopy = Assert.IsType<DecimalValue>(typedValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.Value);
Assert.Equal("45.3", formulaValue.Format());
}
[Fact]
public void NumberValues()
{
NumberValue formulaValue = FormulaValue.New(3.1415926535897);
Assert.Equal(FloatDataType.Instance, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
FloatDataValue typedValue = Assert.IsType<FloatDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
NumberValue formulaCopy = Assert.IsType<NumberValue>(typedValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.Value);
Assert.Equal("3.1415926535897", formulaValue.Format());
}
[Fact]
public void BlankValues()
{
BlankValue formulaValue = FormulaValue.NewBlank();
Assert.Equal(DataType.Blank, formulaValue.GetDataType());
Assert.IsType<BlankDataValue>(formulaValue.ToDataValue());
Assert.Equal(string.Empty, formulaValue.Format());
}
[Fact]
public void VoidValues()
{
VoidValue formulaValue = FormulaValue.NewVoid();
Assert.Equal(DataType.Unspecified, formulaValue.GetDataType());
Assert.IsType<BlankDataValue>(formulaValue.ToDataValue());
}
[Fact]
public void DateValues()
{
DateTime timestamp = DateTime.UtcNow.Date;
DateValue formulaValue = FormulaValue.NewDateOnly(timestamp);
Assert.Equal(DataType.Date, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
DateDataValue typedValue = Assert.IsType<DateDataValue>(dataValue);
Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value);
DateValue formulaCopy = Assert.IsType<DateValue>(dataValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc));
Assert.Equal($"{timestamp}", formulaValue.Format());
}
[Fact]
public void DateTimeValues()
{
DateTime timestamp = DateTime.UtcNow;
DateTimeValue formulaValue = FormulaValue.New(timestamp);
Assert.Equal(DataType.DateTime, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
DateTimeDataValue typedValue = Assert.IsType<DateTimeDataValue>(dataValue);
Assert.Equal(formulaValue.GetConvertedValue(TimeZoneInfo.Utc), typedValue.Value);
DateTimeValue formulaCopy = Assert.IsType<DateTimeValue>(typedValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.GetConvertedValue(TimeZoneInfo.Utc));
Assert.Equal($"{timestamp}", formulaValue.Format());
}
[Fact]
public void TimeValues()
{
TimeValue formulaValue = FormulaValue.New(TimeSpan.Parse("10:35"));
Assert.Equal(DataType.Time, formulaValue.GetDataType());
DataValue dataValue = formulaValue.ToDataValue();
TimeDataValue typedValue = Assert.IsType<TimeDataValue>(dataValue);
Assert.Equal(formulaValue.Value, typedValue.Value);
TimeValue formulaCopy = Assert.IsType<TimeValue>(typedValue.ToFormula());
Assert.Equal(typedValue.Value, formulaCopy.Value);
Assert.Equal("10:35:00", formulaValue.Format());
}
[Fact]
public void RecordValues()
{
RecordValue formulaValue = FormulaValue.NewRecordFromFields(
new NamedValue("FieldA", FormulaValue.New("Value1")),
new NamedValue("FieldB", FormulaValue.New("Value2")),
new NamedValue("FieldC", FormulaValue.New("Value3")));
Assert.Equal(DataType.EmptyRecord, formulaValue.GetDataType());
RecordDataValue dataValue = formulaValue.ToRecord();
Assert.Equal(formulaValue.Fields.Count(), dataValue.Properties.Count);
foreach (KeyValuePair<string, DataValue> property in dataValue.Properties)
{
Assert.Contains(property.Key, formulaValue.Fields.Select(field => field.Name));
}
RecordValue formulaCopy = Assert.IsType<RecordValue>(dataValue.ToFormula(), exactMatch: false);
Assert.Equal(formulaCopy.Fields.Count(), dataValue.Properties.Count);
foreach (NamedValue field in formulaCopy.Fields)
{
Assert.Contains(field.Name, dataValue.Properties.Keys);
}
Assert.Equal(
"""
{
"FieldA": "Value1",
"FieldB": "Value2",
"FieldC": "Value3"
}
""",
formulaValue.Format().Replace(Environment.NewLine, "\n"));
Dictionary<string, int> source =
new()
{
["FieldA"] = 1,
["FieldB"] = 2,
["FieldC"] = 3
};
FormulaValue formula = source.ToFormula();
Assert.IsType<RecordValue>(formula, exactMatch: false);
}
[Fact]
public void TableValues()
{
RecordValue recordValue = FormulaValue.NewRecordFromFields(
new NamedValue("FieldA", FormulaValue.New("Value1")),
new NamedValue("FieldB", FormulaValue.New("Value2")),
new NamedValue("FieldC", FormulaValue.New("Value3")));
TableValue formulaValue = FormulaValue.NewTable(recordValue.Type, [recordValue]);
TableDataValue dataValue = formulaValue.ToTable();
Assert.Equal(formulaValue.Rows.Count(), dataValue.Values.Length);
TableValue formulaCopy = Assert.IsType<TableValue>(dataValue.ToFormula(), exactMatch: false);
Assert.Equal(formulaCopy.Rows.Count(), dataValue.Values.Length);
Assert.Equal(
"""
[
{
"FieldA": "Value1",
"FieldB": "Value2",
"FieldC": "Value3"
}
]
""",
formulaValue.Format().Replace(Environment.NewLine, "\n"));
}
}
@@ -0,0 +1,764 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class JsonDocumentExtensionsTests
{
[Fact]
public void ParseRecord_Object_PrimitiveFields_Succeeds()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string)),
("numberInt", typeof(int)),
("numberLong", typeof(long)),
("numberDecimal", typeof(decimal)),
("numberDouble", typeof(double)),
("flag", typeof(bool)),
("date", typeof(DateTime)),
("time", typeof(TimeSpan))
]);
DateTime expectedDateTime = new(2024, 10, 01, 12, 34, 56, DateTimeKind.Utc);
TimeSpan expectedTimeSpan = new(12, 34, 56);
JsonDocument document = JsonDocument.Parse(
"""
{
"text": "hello",
"numberInt": 7,
"numberLong": 9223372036854775807,
"numberDecimal": 12.5,
"numberDouble": 3.99E99,
"flag": true,
"date": "2024-10-01T12:34:56Z",
"time": "12:34:56"
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Equal("hello", result["text"]);
Assert.Equal(7, result["numberInt"]);
Assert.Equal(9223372036854775807L, result["numberLong"]);
Assert.Equal(12.5m, result["numberDecimal"]);
Assert.Equal(3.99E99, result["numberDouble"]);
Assert.Equal(true, result["flag"]);
Assert.Equal(expectedDateTime, result["date"]);
Assert.Equal(expectedTimeSpan, result["time"]);
}
[Fact]
public void ParseRecord_Object_NoSchema_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
{
"text": "hello",
"numberInt": 7,
"numberLong": 9223372036854775807,
"numberDecimal": 12.5,
"numberDouble": 3.99E99,
"flag": true,
"date": "2024-10-01T12:34:56Z",
"time": "12:34:56"
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
// Assert
Assert.Equal("hello", result["text"]);
Assert.Equal(7, result["numberInt"]);
Assert.Equal(9223372036854775807L, result["numberLong"]);
Assert.Equal(12.5m, result["numberDecimal"]);
Assert.Equal(3.99E99, result["numberDouble"]);
Assert.Equal(true, result["flag"]);
Assert.Equal("2024-10-01T12:34:56Z", result["date"]);
Assert.Equal("12:34:56", result["time"]);
}
[Fact]
public void ParseRecord_Object_NestedRecord_Succeeds()
{
// Arrange
VariableType innerRecord =
VariableType.Record(
[
("innerText", typeof(string)),
("innerNumber", typeof(int))
]);
VariableType outerRecord =
VariableType.Record(
[
("outerText", typeof(string)),
("nested", innerRecord)
]);
JsonDocument document = JsonDocument.Parse(
"""
{
"outerText": "outer",
"nested": {
"innerText": "inner",
"innerNumber": 42
}
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(outerRecord);
// Assert
Assert.Equal("outer", result["outerText"]);
Dictionary<string, object?> nested = (Dictionary<string, object?>)result["nested"]!;
Assert.NotNull(nested);
Assert.True(nested.ContainsKey("innerText"));
Assert.Equal("inner", nested["innerText"]);
Assert.Equal(42, nested["innerNumber"]);
}
[Fact]
public void ParseRecord_NullRoot_ReturnsEmpty()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string))
]);
JsonDocument document = JsonDocument.Parse("null");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Empty(result);
}
[Fact]
public void ParseRecord_ArrayWithSingleRecord_Succeeds()
{
// Arrange
VariableType listType =
VariableType.List(
[
("name", typeof(string)),
("value", typeof(int))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{
"name": "item",
"value": 5
}
]
""");
// Act
List<object?> result = document.ParseList(listType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(result[0]);
Assert.Equal("item", element["name"]);
Assert.Equal(5, element["value"]);
}
[Fact]
public void ParseRecord_ArrayWithMultipleRecords_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("id", typeof(int))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{ "id": 1 },
{ "id": 2 }
]
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_InvalidTargetType_Throws()
{
// Arrange
VariableType notARecord = typeof(string);
JsonDocument document = JsonDocument.Parse(
"""
{ "x": 1 }
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(notARecord));
}
[Fact]
public void ParseRecord_InvalidRootKind_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string))
]);
JsonDocument document = JsonDocument.Parse(@"""not-an-object""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_UnsupportedPropertyType_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("unsupported", typeof(Guid))
]);
JsonDocument document = JsonDocument.Parse(
"""
{ "unsupported": "C2556C11-210E-4BB6-BF18-4A8968CB45A8" }
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_MissingRequiredProperty_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("required", typeof(bool))
]);
JsonDocument document = JsonDocument.Parse("{}");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_MissingNullableProperty_Succeeds()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("required", typeof(string))
]);
JsonDocument document = JsonDocument.Parse("{}");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(result);
Assert.Null(element["required"]);
}
[Fact]
public void ParseList_NullRoot_ReturnsEmpty()
{
// Arrange
JsonDocument document = JsonDocument.Parse("null");
// Act
List<object?> result = document.ParseList(typeof(int[]));
// Assert
Assert.Empty(result);
}
[Fact]
public void ParseList_Array_Primitives_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,2,3]");
// Act
List<object?> result = document.ParseList(typeof(int[]));
// Assert
Assert.Equal(3, result.Count);
Assert.Equal(1, result[0]);
Assert.Equal(2, result[1]);
Assert.Equal(3, result[2]);
}
[Fact]
public void ParseList_PrimitiveRoot_WrappedAsSingleElement_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse("7");
// Act
List<object?> result = document.ParseList(typeof(int));
// Assert
Assert.Single(result);
Assert.Equal(7, result[0]);
}
[Fact]
public void ParseList_Array_Records_Succeeds()
{
// Arrange
VariableType listType =
VariableType.List(
[
("id", typeof(int)),
("name", typeof(string))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" }
]
""");
// Act
List<object?> result = document.ParseList(listType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = (Dictionary<string, object?>)result[0]!;
Dictionary<string, object?> second = (Dictionary<string, object?>)result[1]!;
Assert.NotNull(first);
Assert.Equal(1, first["id"]);
Assert.Equal("a", first["name"]);
Assert.NotNull(second);
Assert.Equal(2, second["id"]);
Assert.Equal("b", second["name"]);
}
[Fact]
public void ParseList_InvalidTargetType_Throws()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,2]");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int)));
}
[Fact]
public void ParseList_Array_MixedTypes_Throws()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,\"two\",3]");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int[])));
}
/// <summary>
/// Regression test for #4195: When a JSON object contains an array of objects
/// and is parsed with <c>VariableType.RecordType</c> (no schema), the nested
/// object properties must be preserved. Before the fix, DetermineElementType()
/// created an empty-schema VariableType, causing ParseRecord to take the
/// ParseSchema path (zero fields) and return empty dictionaries.
/// </summary>
[Fact]
public void ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
{
"items": [
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" },
{ "name": "Carol", "role": "PM" }
]
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
// Assert
Assert.True(result.ContainsKey("items"));
List<object?> items = Assert.IsType<List<object?>>(result["items"]);
Assert.Equal(3, items.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(items[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(items[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
Dictionary<string, object?> third = Assert.IsType<Dictionary<string, object?>>(items[2]);
Assert.Equal("Carol", third["name"]);
Assert.Equal("PM", third["role"]);
}
/// <summary>
/// Regression test for #4195: When a JSON array of objects is parsed directly
/// via <c>ParseList</c> with <c>VariableType.ListType</c> (no schema), all
/// object properties must be preserved in each element.
/// </summary>
[Fact]
public void ParseList_ArrayOfObjects_NoSchema_PreservesProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" }
]
""");
// Act
List<object?> result = document.ParseList(VariableType.ListType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(result[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(result[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
}
[Fact]
public void GetListTypeFromJson_EmptyArray_ReturnsFallbackListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[]");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.Equal(VariableType.ListType, result.Type);
Assert.False(result.HasSchema);
}
[Fact]
public void GetListTypeFromJson_ArrayOfPrimitives_ReturnsFallbackListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1, 2, 3]");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.Equal(VariableType.ListType, result.Type);
Assert.False(result.HasSchema);
}
[Fact]
public void GetListTypeFromJson_ObjectWithStringField_InfersStringType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "name": "hello" }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("name"));
Assert.Equal(typeof(string), result.Schema["name"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNumberField_InfersDecimalType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "value": 42 }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("value"));
Assert.Equal(typeof(decimal), result.Schema["value"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithBooleanTrueField_InfersBoolType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "flag": true }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("flag"));
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithBooleanFalseField_InfersBoolType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "flag": false }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("flag"));
Assert.Equal(typeof(bool), result.Schema["flag"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNestedObjectField_InfersRecordType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "child": { "inner": 1 } }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("child"));
Assert.Equal(VariableType.RecordType, result.Schema["child"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNestedArrayField_InfersListType()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "items": [1, 2, 3] }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("items"));
Assert.Equal(VariableType.ListType, result.Schema["items"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithNullField_InfersStringTypeDefault()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{ "missing": null }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("missing"));
Assert.Equal(typeof(string), result.Schema["missing"].Type);
}
[Fact]
public void GetListTypeFromJson_SkipsNonObjectElements_InfersFromFirstObject()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[1, "text", { "id": 99 }]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.True(result.Schema!.ContainsKey("id"));
Assert.Equal(typeof(decimal), result.Schema["id"].Type);
}
[Fact]
public void GetListTypeFromJson_ObjectWithAllFieldTypes_InfersCorrectTypes()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[{
"text": "hello",
"count": 5,
"enabled": true,
"disabled": false,
"nested": { "x": 1 },
"list": [1, 2],
"empty": null
}]
""");
// Act
VariableType result = document.RootElement.GetListTypeFromJson();
// Assert
Assert.True(result.HasSchema);
Assert.Equal(7, result.Schema!.Count);
Assert.Equal(typeof(string), result.Schema["text"].Type);
Assert.Equal(typeof(decimal), result.Schema["count"].Type);
Assert.Equal(typeof(bool), result.Schema["enabled"].Type);
Assert.Equal(typeof(bool), result.Schema["disabled"].Type);
Assert.Equal(VariableType.RecordType, result.Schema["nested"].Type);
Assert.Equal(VariableType.ListType, result.Schema["list"].Type);
Assert.Equal(typeof(string), result.Schema["empty"].Type);
}
[Fact]
public void ParseJsonValue_Object_ReturnsRecord()
{
// Arrange
JsonDocument document = JsonDocument.Parse("""{ "a": "alpha", "b": "beta" }""");
// Act
object? result = document.ParseJsonValue("""{ "a": "alpha", "b": "beta" }""");
// Assert
Dictionary<string, object?> record = Assert.IsType<Dictionary<string, object?>>(result);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
[Fact]
public void ParseJsonValue_Array_ReturnsList()
{
// Arrange
const string Json = """["alpha","beta"]""";
JsonDocument document = JsonDocument.Parse(Json);
// Act
object? result = document.ParseJsonValue(Json);
// Assert
List<object?> list = Assert.IsType<List<object?>>(result);
Assert.Equal(new object?[] { "alpha", "beta" }, list);
}
[Fact]
public void ParseJsonValue_EmptyArray_ReturnsEmptyList()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[]");
// Act
object? result = document.ParseJsonValue("[]");
// Assert
List<object?> list = Assert.IsType<List<object?>>(result);
Assert.Empty(list);
}
[Fact]
public void ParseJsonValue_String_ReturnsString()
{
// Arrange
JsonDocument document = JsonDocument.Parse("\"hello\"");
// Act
object? result = document.ParseJsonValue("\"hello\"");
// Assert
Assert.Equal("hello", result);
}
[Fact]
public void ParseJsonValue_Number_ReturnsNumericValue()
{
// Arrange
JsonDocument document = JsonDocument.Parse("42");
// Act
object? result = document.ParseJsonValue("42");
// Assert
Assert.Equal(42d, Assert.IsType<double>(result));
}
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
public void ParseJsonValue_Boolean_ReturnsBoolean(string json, bool expected)
{
// Arrange
JsonDocument document = JsonDocument.Parse(json);
// Act
object? result = document.ParseJsonValue(json);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void ParseJsonValue_Null_ReturnsNull()
{
// Arrange
JsonDocument document = JsonDocument.Parse("null");
// Act
object? result = document.ParseJsonValue("null");
// Assert
Assert.Null(result);
}
}
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class ObjectExtensionsTests
{
[Fact]
public void AsListWithNullInput()
{
object[]? nullList = null;
IList<string>? result = nullList.AsList<string>();
Assert.Null(result);
}
[Fact]
public void AsListWithEmptyInput()
{
IList<string>? result = Array.Empty<int>().AsList<string>();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void AsListWithSingleElement()
{
const string Value = "Test";
IList<string>? result = Value.AsList<string>();
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal(Value, result[0]);
}
[Fact]
public void AsListWithMultipleInput()
{
object[] inputs = ["33.3", "test"];
IList<string>? result = inputs.AsList<string>();
Assert.NotNull(result);
Assert.Equal(2, result.Count);
}
[Fact]
public void ConvertSame()
{
VerifyConversion(true, typeof(bool), true);
VerifyConversion(32, typeof(int), 32);
VerifyConversion("Test", typeof(string), "Test");
DateTime now = DateTime.Now;
VerifyConversion(now, typeof(DateTime), now);
VerifyConversion(now.TimeOfDay, typeof(TimeSpan), now.TimeOfDay);
}
[Fact]
public void ConvertFailure()
{
VerifyInvalid(32, VariableType.RecordType);
VerifyInvalid(true, VariableType.RecordType);
VerifyInvalid(Guid.NewGuid(), typeof(Guid));
}
[Fact]
public void ConvertToString()
{
VerifyConversion(true, typeof(string), bool.TrueString);
VerifyConversion(32, typeof(string), "32");
VerifyConversion(3.14d, typeof(string), "3.14");
DateTime now = DateTime.Now;
VerifyConversion(now, typeof(string), $"{now:o}");
VerifyConversion(now.TimeOfDay, typeof(string), $"{now.TimeOfDay:c}");
}
[Fact]
public void ConvertFromString()
{
VerifyConversion("true", typeof(bool), true);
VerifyConversion("32", typeof(int), 32);
VerifyConversion("3.14", typeof(double), 3.14D);
DateTime now = DateTime.Now;
VerifyConversion($"{now:o}", typeof(DateTime), now);
VerifyConversion($"{now.TimeOfDay:c}", typeof(TimeSpan), now.TimeOfDay);
}
[Fact]
public void ConvertJson()
{
const string Json =
"""
{
"id": "item1",
"count": 5
}
""";
Dictionary<string, object?> expected =
new()
{
{ "id", "item1"},
{ "count", 5},
};
VerifyConversion(Json, VariableType.Record(("id", typeof(string)), ("count", typeof(int))), expected);
}
private static void VerifyConversion(object? sourceValue, VariableType targetType, object? expectedValue)
{
object? actualValue = sourceValue.ConvertType(targetType);
if (expectedValue is IDictionary<string, object?> or DateTime)
{
Assert.Equivalent(expectedValue, actualValue);
}
else
{
Assert.Equal(expectedValue, actualValue);
}
}
private static void VerifyInvalid(object? sourceValue, VariableType targetType)
{
Assert.Throws<DeclarativeActionException>(() => sourceValue.ConvertType(targetType));
}
}
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class PortableValueExtensionsTests
{
[Fact]
public void InvalidType() => TestInvalidType(IPAddress.Loopback);
[Fact]
public void NullType() => TestValidType<object>(null, FormulaType.Blank);
[Fact]
public void BooleanType() => TestValidType(true, FormulaType.Boolean);
[Fact]
public void StringType() => TestValidType("Hello, World!", FormulaType.String);
[Fact]
public void IntType() => TestValidType(int.MinValue, FormulaType.Decimal);
[Fact]
public void LongType() => TestValidType(long.MaxValue, FormulaType.Decimal);
[Fact]
public void DecimalType() => TestValidType(decimal.MaxValue, FormulaType.Decimal);
[Fact]
public void FloatType() => TestValidType(float.MaxValue, FormulaType.Number);
[Fact]
public void DoubleType() => TestValidType(double.MinValue, FormulaType.Number);
[Fact]
public void DateType() => TestValidType(DateTime.UtcNow.Date, FormulaType.Date);
[Fact]
public void DateTimeType() => TestValidType(DateTime.UtcNow, FormulaType.DateTime);
[Fact]
public void TimeSpanType() => TestValidType(DateTime.UtcNow.TimeOfDay, FormulaType.Time);
[Fact]
public void ChatMessageType() => TestValidType(new ChatMessage(ChatRole.User, "input"), RecordType.Empty());
[Fact]
public void ListEmptyType()
{
TableValue convertedValue = (TableValue)TestValidType(Array.Empty<int>(), TableType.Empty());
Assert.Equal(0, convertedValue.Count());
}
[Fact]
public void ListSimpleType()
{
TableValue convertedValue = (TableValue)TestValidType(new List<int> { 1, 2, 3 }, TableType.Empty());
Assert.Equal(3, convertedValue.Count());
RecordValue firstElement = convertedValue.Rows.First().Value;
NamedValue recordElement = Assert.Single(firstElement.Fields);
Assert.Equal("Value", recordElement.Name);
DecimalValue recordValue = Assert.IsType<DecimalValue>(recordElement.Value);
Assert.Equal(1, recordValue.Value);
}
[Fact]
public void ListComplexType()
{
TableValue convertedValue = (TableValue)TestValidType(new List<ChatMessage> { new(ChatRole.User, "input"), new(ChatRole.Assistant, "output") }, TableType.Empty());
Assert.Equal(2, convertedValue.Count());
RecordValue firstElement = convertedValue.Rows.First().Value;
StringValue typeValue = Assert.IsType<StringValue>(firstElement.GetField(TypeSchema.Discriminator));
Assert.Equal(nameof(ChatMessage), typeValue.Value);
StringValue textValue = Assert.IsType<StringValue>(firstElement.GetField(TypeSchema.Message.Fields.Text));
Assert.Equal("input", textValue.Value);
}
[Fact]
public void DictionaryType()
{
RecordValue convertedValue = (RecordValue)TestValidType(new Dictionary<string, int> { { "A", 1 }, { "B", 2 } }, RecordType.Empty());
Assert.Equal(2, convertedValue.Fields.Count());
NamedValue firstElement = convertedValue.Fields.First();
Assert.Equal("A", firstElement.Name);
DecimalValue firstElementValue = Assert.IsType<DecimalValue>(firstElement.Value);
Assert.Equal(1, firstElementValue.Value);
}
[Fact]
public void ObjectType()
{
RecordValue convertedValue = (RecordValue)TestValidType(FormulaValue.NewRecordFromFields(new NamedValue("key", FormulaValue.New(3))).ToDataValue().ToObject(), RecordType.Empty());
Assert.Single(convertedValue.Fields);
NamedValue firstElement = convertedValue.Fields.First();
Assert.Equal("key", firstElement.Name);
DecimalValue firstElementValue = Assert.IsType<DecimalValue>(firstElement.Value);
Assert.Equal(3, firstElementValue.Value);
}
private static void TestInvalidType(object? sourceValue)
{
Assert.Throws<DeclarativeModelException>(() => sourceValue.AsPortable());
PortableValue portableValue = new(sourceValue ?? UnassignedValue.Instance);
Assert.Throws<DeclarativeModelException>(() => portableValue.ToFormula());
}
private static FormulaValue TestValidType<TValue>(TValue? sourceValue, FormulaType expectedType) where TValue : notnull
{
object portableObject = sourceValue.AsPortable();
Assert.IsNotType<PortableValue>(portableObject);
PortableValue portableValue = new(portableObject);
FormulaValue formulaValue = portableValue.ToFormula();
Assert.NotNull(formulaValue);
Assert.Equal(expectedType.GetType(), formulaValue.Type.GetType());
return formulaValue;
}
}
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class StringExtensionsTests
{
[Fact]
public void TrimJsonWithDelimiter()
{
// Arrange
const string Input =
"""
```json
{
"key": "value"
}
```
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(
"""
{
"key": "value"
}
""",
result);
}
[Fact]
public void TrimJsonWithPadding()
{
// Arrange
const string Input =
"""
```json
{
"key": "value"
}
```
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(
"""
{
"key": "value"
}
""",
result);
}
[Fact]
public void TrimJsonWithUnqualifiedDelimiter()
{
// Arrange
const string Input =
"""
```
{
"key": "value"
}
```
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(
"""
{
"key": "value"
}
""",
result);
}
[Fact]
public void TrimJsonWithoutDelimiter()
{
// Arrange
const string Input =
"""
{
"key": "value"
}
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(
"""
{
"key": "value"
}
""",
result);
}
[Fact]
public void TrimJsonWithoutDelimiterWithPadding()
{
// Arrange
const string Input =
"""
{
"key": "value"
}
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(
"""
{
"key": "value"
}
""",
result);
}
[Fact]
public void TrimMissingWithDelimiter()
{
// Arrange
const string Input =
"""
```json
```
""";
// Act
string result = Input.TrimJsonDelimiter();
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void TrimEmptyString()
{
// Act
string result = string.Empty.TrimJsonDelimiter();
// Assert
Assert.Equal(string.Empty, result);
}
}
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class TemplateExtensionsTests
{
[Fact]
public void FormatTemplateWithTextSegments()
{
// Arrange
RecalcEngine engine = new();
IEnumerable<TemplateLine> template =
[
new TemplateLine.Builder
{
Segments =
{
new TextSegment.Builder { Value = "Hello " },
new TextSegment.Builder { Value = "World" }
}
}.Build()
];
// Act
string result = engine.Format(template);
// Assert
Assert.Equal("Hello World", result);
}
[Fact]
public void FormatTemplateWithMultipleLines()
{
// Arrange
RecalcEngine engine = new();
IEnumerable<TemplateLine> template =
[
new TemplateLine.Builder
{
Segments =
{
new TextSegment.Builder { Value = "Line 1" }
}
}.Build(),
new TemplateLine.Builder
{
Segments =
{
new TextSegment.Builder { Value = "Line 2" }
}
}.Build()
];
// Act
string result = engine.Format(template);
// Assert
Assert.Equal("Line 1Line 2", result);
}
[Fact]
public void FormatSingleTemplateLineWithNullValue()
{
// Arrange
RecalcEngine engine = new();
TemplateLine? line = null;
// Act
string result = engine.Format(line);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void FormatSingleTemplateLineWithTextSegment()
{
// Arrange
RecalcEngine engine = new();
TemplateLine line = new TemplateLine.Builder
{
Segments =
{
new TextSegment.Builder { Value = "Test" }
}
}.Build();
// Act
string result = engine.Format(line);
// Assert
Assert.Equal("Test", result);
}
[Fact]
public void FormatTextSegmentWithNullValue()
{
// Arrange
RecalcEngine engine = new();
TextSegment segment = new TextSegment.Builder { Value = null }.Build();
// Act
string result = engine.Format(segment);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void FormatTextSegmentWithEmptyValue()
{
// Arrange
RecalcEngine engine = new();
TextSegment segment = new TextSegment.Builder { Value = "" }.Build();
// Act
string result = engine.Format(segment);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void FormatTextSegmentWithValue()
{
// Arrange
RecalcEngine engine = new();
TextSegment segment = new TextSegment.Builder { Value = "Hello World" }.Build();
// Act
string result = engine.Format(segment);
// Assert
Assert.Equal("Hello World", result);
}
}
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class TypeExtensionsTests
{
[Fact]
public void ReferenceType() => VerifyIsNullable(typeof(string));
[Fact]
public void ClassType() => VerifyIsNullable(typeof(object));
[Fact]
public void InterfaceType() => VerifyIsNullable(typeof(IDisposable));
[Fact]
public void ArrayType() => VerifyIsNullable(typeof(int[]));
[Fact]
public void NonNullableValueType() => VerifyNotNullable(typeof(int));
[Fact]
public void NonNullableStructType() => VerifyNotNullable(typeof(DateTime));
[Fact]
public void NonNullableEnumType() => VerifyNotNullable(typeof(DayOfWeek));
[Fact]
public void NullableInt() => VerifyIsNullable(typeof(int?));
[Fact]
public void NullableDateTime() => VerifyIsNullable(typeof(DateTime?));
[Fact]
public void NullableEnum() => VerifyIsNullable(typeof(DayOfWeek?));
[Fact]
public void NullableCustomStruct() => VerifyIsNullable(typeof(TestStruct?));
private static void VerifyNotNullable(Type targetType)
{
// Act
bool result = targetType.IsNullable();
// Assert
Assert.False(result);
}
private static void VerifyIsNullable(Type targetType)
{
// Act
bool result = targetType.IsNullable();
// Assert
Assert.True(result);
}
private struct TestStruct
{
public int Value { get; set; }
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Interpreter;
/// <summary>
/// Tests execution of workflow created by <see cref="WorkflowModel{TCondition}"/>.
/// </summary>
public sealed class DeclarativeWorkflowModelTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Fact]
public void GetDepthForDefault()
{
WorkflowModel<string> model = new(new TestExecutor("root"));
Assert.Equal(0, model.GetDepth(null));
}
[Fact]
public void GetDepthForMissingNode()
{
WorkflowModel<string> model = new(new TestExecutor("root"));
Assert.Throws<DeclarativeModelException>(() => model.GetDepth("missing"));
}
[Fact]
public void ConnectMissingNode()
{
TestExecutor rootExecutor = new("root");
WorkflowModel<string> model = new(rootExecutor);
model.AddLink("root", "missing");
TestWorkflowBuilder modelBuilder = new();
Assert.Throws<DeclarativeModelException>(() => model.Build(modelBuilder));
}
[Fact]
public void AddToMissingParent()
{
WorkflowModel<string> model = new(new TestExecutor("root"));
Assert.Throws<DeclarativeModelException>(() => model.AddNode(new TestExecutor("next"), "missing"));
}
[Fact]
public void LinkFromMissingSource()
{
WorkflowModel<string> model = new(new TestExecutor("root"));
Assert.Throws<DeclarativeModelException>(() => model.AddLink("missing", "anything"));
}
[Fact]
public void LocateMissingParent()
{
WorkflowModel<string> model = new(new TestExecutor("root"));
Assert.Null(model.LocateParent<TestExecutor>(null));
Assert.Throws<DeclarativeModelException>(() => model.LocateParent<TestExecutor>("missing"));
}
internal sealed class TestExecutor(string actionId) : IModeledAction
{
public string Id { get; } = actionId;
}
internal sealed class TestWorkflowBuilder : IModelBuilder<string>
{
public void Connect(IModeledAction source, IModeledAction target, string? condition = null)
{
Assert.Fail(); // Not expected to be called in this test.
}
}
}
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit;
/// <summary>
/// Tests that edge predicates correctly handle PortableValue-wrapped messages,
/// which occur after checkpoint restore (JSON round-trip).
/// </summary>
public sealed class PortableValuePredicateTests
{
#region ActionExecutorResult.ThrowIfNot
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithDirectActionExecutorResult_ReturnsResult()
{
// Arrange
ActionExecutorResult result = new("test-executor");
// Act
ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(result);
// Assert
actual.Should().BeSameAs(result);
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedActionExecutorResult_Unwraps()
{
// Arrange
ActionExecutorResult result = new("test-executor");
PortableValue wrapped = new(result);
// Act
ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(wrapped);
// Assert
actual.ExecutorId.Should().Be("test-executor");
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithNonActionExecutorResult_Throws()
{
// Arrange
object message = "not an ActionExecutorResult";
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(message));
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithNull_Throws()
{
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(null));
}
[Fact]
public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedNonResult_Throws()
{
// Arrange
PortableValue wrapped = new("not an ActionExecutorResult");
// Act & Assert
Assert.Throws<DeclarativeActionException>(() => ActionExecutorResult.ThrowIfNot(wrapped));
}
#endregion
#region InvokeAzureAgentExecutor Predicates
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithDirectExternalInputRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(request).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
PortableValue wrapped = new(request);
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresInput_WithActionExecutorResult_ReturnsFalse()
{
// Arrange
ActionExecutorResult result = new("test");
// Act & Assert
InvokeAzureAgentExecutor.RequiresInput(result).Should().BeFalse();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithDirectActionExecutorResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(result).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
PortableValue wrapped = new(result);
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeAzureAgentExecutor_RequiresNothing_WithExternalInputRequest_ReturnsFalse()
{
// Arrange
ExternalInputRequest request = new("test prompt");
// Act & Assert
InvokeAzureAgentExecutor.RequiresNothing(request).Should().BeFalse();
}
#endregion
#region InvokeMcpToolExecutor Predicates
[Fact]
public void InvokeMcpToolExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue()
{
// Arrange
ExternalInputRequest request = new("test prompt");
PortableValue wrapped = new(request);
// Act & Assert
InvokeMcpToolExecutor.RequiresInput(wrapped).Should().BeTrue();
}
[Fact]
public void InvokeMcpToolExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue()
{
// Arrange
ActionExecutorResult result = new("test");
PortableValue wrapped = new(result);
// Act & Assert
InvokeMcpToolExecutor.RequiresNothing(wrapped).Should().BeTrue();
}
#endregion
#region QuestionExecutor.IsComplete
[Fact]
public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NullResult_ReturnsTrue()
{
// Arrange - result with null Result property means "complete"
ActionExecutorResult result = new("test", result: null);
PortableValue wrapped = new(result);
// Act & Assert
QuestionExecutor.IsComplete(wrapped).Should().BeTrue();
}
[Fact]
public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NonNullResult_ReturnsFalse()
{
// Arrange - result with non-null Result property means "not complete"
ActionExecutorResult result = new("test", result: true);
PortableValue wrapped = new(result);
// Act & Assert
QuestionExecutor.IsComplete(wrapped).Should().BeFalse();
}
#endregion
}
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit;
public sealed class VariableTypeTests
{
[Fact]
public void IsValidPrimitivesReturnTrue()
{
Assert.True(VariableType.IsValid<bool>());
Assert.True(VariableType.IsValid<int>());
Assert.True(VariableType.IsValid<long>());
Assert.True(VariableType.IsValid<float>());
Assert.True(VariableType.IsValid<decimal>());
Assert.True(VariableType.IsValid<double>());
Assert.True(VariableType.IsValid<string>());
Assert.True(VariableType.IsValid<DateTime>());
Assert.True(VariableType.IsValid<TimeSpan>());
}
[Fact]
public void IsValidUnsupportedTypeReturnFalse()
{
Assert.False(VariableType.IsValid<Guid>());
Assert.False(VariableType.IsValid<Uri>());
}
[Fact]
public void IsListForListTypeReturnTrue()
{
VariableType listType = new(typeof(List<int>));
Assert.True(listType.IsList);
Assert.False(listType.IsRecord);
Assert.True(listType.IsValid());
}
[Fact]
public void IsRecordForDictionaryInterfaceReturnTrue()
{
VariableType recordType = new(typeof(IDictionary<string, object?>));
Assert.True(recordType.IsRecord);
Assert.False(recordType.IsList);
Assert.True(recordType.IsValid());
}
[Fact]
public void RecordFactoryCreatesSchema()
{
// Assuming the intended signature supports tuple params; adjust if needed.
VariableType nameType = new(typeof(string));
VariableType ageType = new(typeof(int));
// If the actual signature differs (params IEnumerable<...>), adapt test accordingly.
VariableType recordType = VariableType.Record(
[("name", nameType), ("age", ageType)]
);
Assert.True(recordType.IsRecord);
Assert.True(recordType.HasSchema);
Assert.NotNull(recordType.Schema);
Assert.Equal(2, recordType.Schema.Count);
Assert.True(recordType.Schema.ContainsKey("name"));
Assert.True(recordType.Schema.ContainsKey("age"));
Assert.Equal(typeof(string), recordType.Schema["name"].Type);
Assert.Equal(typeof(int), recordType.Schema["age"].Type);
}
[Fact]
public void EqualsPrimitiveTypeEquality()
{
VariableType t1 = new(typeof(int));
VariableType t2 = new(typeof(int));
VariableType t3 = new(typeof(string));
Assert.True(t1.Equals(t2));
Assert.True(t1.Equals(typeof(int)));
Assert.False(t1.Equals(t3));
Assert.False(t1.Equals(typeof(string)));
}
[Fact]
public void EqualsRecordEqualityIgnoresOrder()
{
VariableType strType = new(typeof(string));
VariableType intType = new(typeof(int));
VariableType recordA = VariableType.Record(
[("first", strType), ("second", intType)]
);
VariableType recordB = VariableType.Record(
[("second", intType), ("first", strType)]
);
Assert.True(recordA.Equals(recordB));
Assert.True(recordB.Equals(recordA));
}
[Fact]
public void EqualsRecordInequalityDifferentSchema()
{
VariableType strType = new(typeof(string));
VariableType intType = new(typeof(int));
VariableType recordA = VariableType.Record(
[("first", strType), ("second", intType)]
);
VariableType recordB = VariableType.Record(
[("first", strType)]
);
Assert.False(recordA.Equals(recordB));
Assert.False(recordB.Equals(recordA));
}
[Fact]
public void GetHashCodePrimitiveConsistency()
{
VariableType a = new(typeof(double));
VariableType b = new(typeof(double));
Assert.Equal(a, b);
Assert.Equal(a, typeof(double));
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void GetHashCodeRecordConsistency()
{
VariableType a = VariableType.Record(("a", typeof(string)), ("b", typeof(int)));
VariableType b = VariableType.Record(("a", typeof(string)), ("b", typeof(int)));
Assert.Equal(a, b);
Assert.NotEqual(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void HasSchemaFalseForNonRecord()
{
VariableType primitive = new(typeof(int));
Assert.False(primitive.HasSchema);
}
[Fact]
public void ImplicitOperatorFromTypeWrapsCorrectly()
{
VariableType vt = typeof(string);
Assert.Equal(typeof(string), vt.Type);
Assert.True(vt.IsValid());
}
[Fact]
public void EqualsNullAndDifferentTypes()
{
VariableType vt = new(typeof(int));
VariableType? nullType = null;
object? nullObj = null;
object different = "test";
Assert.False(vt.Equals(nullObj));
Assert.False(vt.Equals(nullType));
Assert.False(vt.Equals(different));
Assert.True(vt.Equals((object)typeof(int)));
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Workflows\*.cs" />
<None Include="Workflows\*.cs">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\*.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Workflows\*.csproj">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Mock implementation of <see cref="ResponseAgentProvider"/> for unit testing purposes.
/// </summary>
internal sealed class MockAgentProvider : Mock<ResponseAgentProvider>
{
public IList<string> ExistingConversationIds { get; } = [];
public List<ChatMessage> TestMessages { get; set; } = [];
public MockAgentProvider()
{
this.Setup(provider => provider.CreateConversationAsync(It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(this.CreateConversationId()));
List<ChatMessage> testMessages = this.CreateMessages();
this.Setup(provider => provider.GetMessageAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(testMessages.First()));
// Setup GetMessagesAsync to return test messages
this.Setup(provider => provider.GetMessagesAsync(
It.IsAny<string>(),
It.IsAny<int?>(),
It.IsAny<string?>(),
It.IsAny<string?>(),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(testMessages));
this.Setup(provider => provider.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>((conversationId, message, cancellationToken) => Task.FromResult(this.CaptureChatMessage(message)));
}
private string CreateConversationId()
{
string newConversationId = Guid.NewGuid().ToString("N");
this.ExistingConversationIds.Add(newConversationId);
return newConversationId;
}
private ChatMessage CaptureChatMessage(ChatMessage message)
{
this.TestMessages.Add(message);
return message;
}
private List<ChatMessage> CreateMessages()
{
// Create test messages
List<ChatMessage> messages = [];
const int MessageCount = 5;
for (int i = 0; i < MessageCount; i++)
{
messages.Add(new ChatMessage(ChatRole.User, $"Test message {i + 1}") { MessageId = Guid.NewGuid().ToString("N") });
}
this.TestMessages = messages;
return this.TestMessages;
}
private static async IAsyncEnumerable<ChatMessage> ToAsyncEnumerableAsync(IEnumerable<ChatMessage> messages)
{
foreach (ChatMessage message in messages)
{
yield return message;
}
await Task.CompletedTask;
}
}
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="AddConversationMessageExecutor"/>.
/// </summary>
public sealed class AddConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageSuccessfullyAsync(AgentMessageRole role)
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageSuccessfullyAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
messageText: $"Hello from {role}");
}
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageToWorkflowAsync(AgentMessageRole role)
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageToWorkflowAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
conversationId: "WorkflowConversationId",
messageText: $"Hello from {role}");
}
[Theory]
[InlineData(AgentMessageRole.User)]
[InlineData(AgentMessageRole.Agent)]
public async Task AddMessageWithMetadataAsync(AgentMessageRole role)
{
// Arrange
Dictionary<string, string> metadataValues =
new()
{
["Key1"] = "Value1",
["Key2"] = "Value2",
};
RecordDataValue metadataRecord = metadataValues.ToRecordValue();
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(AddMessageWithMetadataAsync),
variableName: "TestMessage",
role: AgentMessageRoleWrapper.Get(role),
messageText: $"Hello from {role}",
metadata: metadataRecord);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
AgentMessageRoleWrapper role,
string messageText,
string? conversationId = null,
RecordDataValue? metadata = null)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
AddConversationMessage model =
this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
conversationId ?? "TestConversationId",
role,
messageText,
metadata);
AddConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
ChatMessage? testMessage = mockAgentProvider.TestMessages?.LastOrDefault();
Assert.NotNull(testMessage);
VerifyModel(model, action);
this.VerifyState(variableName, testMessage.ToRecord());
if (metadata is not null)
{
Assert.NotNull(testMessage.AdditionalProperties);
Assert.NotEmpty(testMessage.AdditionalProperties);
}
}
private AddConversationMessage CreateModel(
string displayName,
string messageVariable,
string conversationId,
AgentMessageRoleWrapper role,
string messageText,
RecordDataValue? metadata)
{
ObjectExpression<RecordDataValue>.Builder? metadataExpression = null;
if (metadata is not null)
{
metadataExpression = ObjectExpression<RecordDataValue>.Literal(metadata).ToBuilder();
}
AddConversationMessage.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Message = PropertyPath.Create(messageVariable),
ConversationId = StringExpression.Literal(conversationId),
Role = role,
Metadata = metadataExpression,
};
actionBuilder.Content.Add(new AddConversationMessageContent.Builder
{
Type = AgentMessageContentType.Text,
Value = TemplateLine.Parse(messageText)
});
return AssignParent<AddConversationMessage>(actionBuilder);
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ClearAllVariablesExecutor"/>.
/// </summary>
public sealed class ClearAllVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ClearGlobalScopeAsync()
{
// Arrange
this.State.Set("GlobalVar", FormulaValue.New("Old value"), VariableScopeNames.Global);
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearGlobalScopeAsync)),
VariablesToClear.AllGlobalVariables,
"GlobalVar",
VariableScopeNames.Global);
}
[Fact]
public async Task ClearWorkflowScopeAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearWorkflowScopeAsync)),
VariablesToClear.ConversationScopedVariables,
"LocalVar");
}
[Fact]
public async Task ClearUserScopeAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearUserScopeAsync)),
VariablesToClear.UserScopedVariables,
"LocalVar",
expectedValue: FormulaValue.New("Old value"));
}
[Fact]
public async Task ClearWorkflowHistoryAsync()
{
// Arrange
this.State.Set("LocalVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ClearWorkflowHistoryAsync)),
VariablesToClear.ConversationHistory,
"LocalVar",
expectedValue: FormulaValue.New("Old value"));
}
private async Task ExecuteTestAsync(
string displayName,
VariablesToClear scope,
string variableName,
string variableScope = VariableScopeNames.Local,
FormulaValue? expectedValue = null)
{
// Arrange
ClearAllVariables model = this.CreateModel(
this.FormatDisplayName(displayName),
scope);
ClearAllVariablesExecutor action = new(model, this.State);
this.State.Bind();
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("NoVar");
if (expectedValue is null)
{
this.VerifyUndefined(variableName, variableScope);
}
else
{
this.VerifyState(variableName, variableScope, expectedValue);
}
}
private ClearAllVariables CreateModel(string displayName, VariablesToClear variableTarget)
{
ClearAllVariables.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variables = EnumExpression<VariablesToClearWrapper>.Literal(VariablesToClearWrapper.Get(variableTarget)),
};
return AssignParent<ClearAllVariables>(actionBuilder);
}
}
@@ -0,0 +1,241 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ConditionGroupExecutor"/>.
/// </summary>
public sealed class ConditionGroupExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void ConditionGroupThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new ConditionGroupExecutor(new ConditionGroup(), this.State));
[Fact]
public void ConditionGroupDefaultNaming()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupDefaultNaming), [false], includeElse: true, defineActionIds: false);
ConditionItem condition = model.Conditions[0];
// Act
string conditionStepId = ConditionGroupExecutor.Steps.Item(model, condition);
string elseStepId = ConditionGroupExecutor.Steps.Else(model);
// Assert
Assert.Equal($"{model.Id}_Items0", conditionStepId);
Assert.Equal(model.ElseActions.Id.Value, elseStepId);
}
[Fact]
public void ConditionGroupExplicitNaming()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupExplicitNaming), [false], includeElse: true);
ConditionItem condition = model.Conditions[0];
// Act
string conditionStepId = ConditionGroupExecutor.Steps.Item(model, condition);
string elseStepId = ConditionGroupExecutor.Steps.Else(model);
// Assert
Assert.Equal(condition.Id, conditionStepId);
Assert.Equal(model.ElseActions.Id.Value, elseStepId);
}
[Fact]
public async Task ConditionGroupFirstConditionTrueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupFirstConditionTrueAsync),
conditions: [true, false]);
}
[Fact]
public async Task ConditionGroupSecondConditionTrueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupSecondConditionTrueAsync),
conditions: [false, true]);
}
[Fact]
public async Task ConditionGroupFirstConditionNullAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupFirstConditionNullAsync),
conditions: [null, true]);
}
[Fact]
public async Task ConditionGroupElseBranchAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ConditionGroupElseBranchAsync),
conditions: [false, false],
includeElse: true);
}
[Fact]
public async Task ConditionGroupDoneAsync()
{
ConditionGroup model = this.CreateModel(nameof(ConditionGroupDoneAsync), [true]);
ConditionGroupExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync("condition_done_id", action.DoneAsync);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
VerifyCompletionEvent(events);
}
[Fact]
public void ConditionGroupIsMatchTrue()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsMatchTrue), [true]);
ConditionItem firstCondition = model.Conditions[0];
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Item(model, firstCondition));
// Act
bool isMatch = executor.IsMatch(firstCondition, result);
// Assert
Assert.True(isMatch);
}
[Fact]
public void ConditionGroupIsMatchFalse()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsMatchFalse), [true, false]);
ConditionItem firstCondition = model.Conditions[0];
ConditionItem secondCondition = model.Conditions[1];
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Item(model, secondCondition));
// Act
bool isMatch = executor.IsMatch(firstCondition, result);
// Assert
Assert.False(isMatch);
}
[Fact]
public void ConditionGroupIsElseTrue()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsElseTrue), [false]);
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, ConditionGroupExecutor.Steps.Else(model));
// Act
bool isElse = executor.IsElse(result);
// Assert
Assert.True(isElse);
}
[Fact]
public void ConditionGroupIsElseFalse()
{
// Arrange
ConditionGroup model = this.CreateModel(nameof(ConditionGroupIsElseFalse), [false]);
ConditionGroupExecutor executor = new(model, this.State);
ActionExecutorResult result = new(executor.Id, "different_step");
// Act
bool isElse = executor.IsElse(result);
// Assert
Assert.False(isElse);
}
private async Task ExecuteTestAsync(
string displayName,
bool?[] conditions,
bool includeElse = false)
{
// Arrange
ConditionGroup model = this.CreateModel(displayName, conditions, includeElse);
ConditionGroupExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
VerifyInvocationEvent(events);
VerifyIsDiscrete(action, isDiscrete: false);
}
private ConditionGroup CreateModel(
string displayName,
bool?[] conditions,
bool includeElse = false,
bool defineActionIds = true)
{
ConditionGroup.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
};
for (int index = 0; index < conditions.Length; ++index)
{
bool? condition = conditions[index];
ConditionItem.Builder conditionBuilder = new()
{
Id = defineActionIds ? $"condition_{index}" : null,
Actions = this.CreateActions(defineActionIds ? $"condition_actions_{index}" : null),
Condition = condition is null ? null : BoolExpression.Literal(condition.Value).ToBuilder(),
};
actionBuilder.Conditions.Add(conditionBuilder);
}
if (includeElse)
{
actionBuilder.ElseActions = this.CreateActions(defineActionIds ? "else_actions" : null);
}
return AssignParent<ConditionGroup>(actionBuilder);
}
private ActionScope.Builder CreateActions(string? actionScopeId)
{
ActionScope.Builder actions = [];
if (actionScopeId is not null)
{
actions.Id = new ActionId(actionScopeId);
}
actions.Actions.Add(
new SendActivity.Builder
{
Id = $"{actionScopeId ?? "action"}_send_activity",
Activity = new MessageActivityTemplate(),
});
return actions;
}
}
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CopyConversationMessagesExecutor"/>.
/// </summary>
public sealed class CopyConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CopyMessagesWithSingleStringMessageAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithSingleStringMessageAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(StringDataValue.Create("Hello, how can I help you?")),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesWithSingleRecordMessageAsync()
{
// Arrange
ChatMessage testMessage = new(ChatRole.User, "Test message content");
DataValue messageDataValue = testMessage.ToRecord().ToDataValue();
Assert.IsType<RecordDataValue>(messageDataValue);
RecordDataValue messageRecord = (RecordDataValue)messageDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithSingleRecordMessageAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(messageRecord),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesWithMultipleMessagesAsync()
{
// Arrange
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "First message"),
new ChatMessage(ChatRole.Assistant, "Second message"),
new ChatMessage(ChatRole.User, "Third message")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithMultipleMessagesAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 3);
}
[Fact]
public async Task CopyMessagesWithVariableExpressionAsync()
{
// Arrange
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message from variable")
];
TableValue messagesTable = testMessages.ToTable();
this.State.Set("SourceMessages", messagesTable);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithVariableExpressionAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages")),
expectedMessageCount: 1);
}
[Fact]
public async Task CopyMessagesToWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message to workflow conversation")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesToWorkflowConversationAsync),
conversationId: "WorkflowConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 1,
expectWorkflowEvent: true);
}
[Fact]
public async Task CopyMessagesToNonWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
List<ChatMessage> testMessages =
[
new ChatMessage(ChatRole.User, "Message to non-workflow conversation")
];
DataValue messagesDataValue = testMessages.ToTable().ToDataValue();
Assert.IsType<TableDataValue>(messagesDataValue);
TableDataValue messagesTable = (TableDataValue)messagesDataValue;
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesToNonWorkflowConversationAsync),
conversationId: "DifferentConversationId",
messages: ValueExpression.Literal(messagesTable),
expectedMessageCount: 1,
expectWorkflowEvent: false);
}
[Fact]
public async Task CopyMessagesWithBlankDataValueAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(CopyMessagesWithBlankDataValueAsync),
conversationId: "TestConversationId",
messages: ValueExpression.Literal(DataValue.Blank()),
expectedMessageCount: 0);
}
private async Task ExecuteTestAsync(
string displayName,
string conversationId,
ValueExpression messages,
int expectedMessageCount,
bool expectWorkflowEvent = false)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
mockAgentProvider.TestMessages.Clear();
CopyConversationMessages model = this.CreateModel(
this.FormatDisplayName(displayName),
conversationId,
messages);
CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
Assert.Equal(expectedMessageCount, mockAgentProvider.TestMessages.Count);
VerifyModel(model, action);
AgentResponseEvent[] responseEvents = events.OfType<AgentResponseEvent>().ToArray();
if (expectWorkflowEvent && expectedMessageCount > 0)
{
Assert.NotEmpty(responseEvents);
AgentResponseEvent responseEvent = responseEvents.First();
Assert.Equal(action.Id, responseEvent.ExecutorId);
Assert.NotNull(responseEvent.Response);
Assert.Equal(expectedMessageCount, responseEvent.Response.Messages.Count);
}
else
{
Assert.Empty(responseEvents);
}
}
private CopyConversationMessages CreateModel(
string displayName,
string conversationId,
ValueExpression messages)
{
CopyConversationMessages.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = StringExpression.Literal(conversationId),
Messages = messages
};
return AssignParent<CopyConversationMessages>(actionBuilder);
}
}
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="CreateConversationExecutor "/>.
/// </summary>
public sealed class CreateConversationExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CreateNewConversationAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateNewConversationAsync),
"TestConversationId",
executionIteration: 1);
}
[Fact]
public async Task CreateMultipleConversationsAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(CreateMultipleConversationsAsync),
"TestConversationId",
executionIteration: 4);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
int executionIteration)
{
// Arrange
// Initialize state to simulate workflow environment.
this.State.InitializeSystem();
CreateConversation model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName));
MockAgentProvider mockAgentProvider = new();
CreateConversationExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
int expectedIterationCount = executionIteration;
while (executionIteration-- > 0)
{
await this.ExecuteAsync(action);
}
// Assert
VerifyModel(model, action);
Assert.Equal(expected: expectedIterationCount, actual: mockAgentProvider.ExistingConversationIds.Count);
this.VerifyState("TestConversationId", FormulaValue.New(mockAgentProvider.ExistingConversationIds.Last()));
}
private CreateConversation CreateModel(string displayName, string conversationIdVariable)
{
CreateConversation.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ConversationId = PropertyPath.Create(conversationIdVariable)
};
return AssignParent<CreateConversation>(actionBuilder);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="DefaultActionExecutor"/>.
/// </summary>
public sealed class DefaultActionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ExecuteDefaultActionAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ExecuteDefaultActionAsync)));
}
private async Task ExecuteTestAsync(string displayName)
{
// Arrange
ResetVariable model = this.CreateModel(displayName);
// Act
DefaultActionExecutor action = new(model, this.State);
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.NotEmpty(events);
}
private ResetVariable CreateModel(string displayName)
{
// Use a simple concrete action type since DialogAction.Builder is abstract
ResetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(FormatVariablePath("TestVariable")),
};
return AssignParent<ResetVariable>(actionBuilder);
}
}
@@ -0,0 +1,440 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="EditTableExecutor"/>.
/// </summary>
public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModelNullItemsVariable() =>
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() => new EditTableExecutor(new EditTable(), this.State));
[Fact]
public async Task AddItemToTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemToTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([new("id", new NumberDataValue(7))]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(7, idValue.Value);
}
[Fact]
public async Task AddItemWithMultipleFieldsAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1, name: \"First\"}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemWithMultipleFieldsAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([
new("id", new NumberDataValue(2)),
new("name", new StringDataValue("Second"))
]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(2, idValue.Value);
StringValue nameValue = Assert.IsType<StringValue>(resultRecord.GetField("name"));
Assert.Equal("Second", nameValue.Value);
}
[Fact]
public async Task AddItemToEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(AddItemToEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Add,
value: new RecordDataValue([new("id", new NumberDataValue(1))]));
// Verify the variable now contains the added record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(1, idValue.Value);
}
[Fact]
public async Task RemoveItemFromTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}, {id: 7}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(RemoveItemFromTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Remove,
value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]));
// Verify the variable now contains an empty record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
// Empty record should have no fields
Assert.Empty(resultRecord.Fields);
}
[Fact]
public async Task RemoveMultipleItemsFromTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}, {id: 3}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(RemoveMultipleItemsFromTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Remove,
value: new TableDataValue([
new RecordDataValue([new("id", new NumberDataValue(1))]),
new RecordDataValue([new("id", new NumberDataValue(3))])
]));
// Verify the variable now contains an empty record
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
// Empty record should have no fields
Assert.Empty(resultRecord.Fields);
}
[Fact]
public async Task ClearTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(ClearTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Clear,
value: null);
// Verify table is cleared
FormulaValue resultValue = this.State.Get("MyTable");
Assert.IsType<BlankValue>(resultValue);
}
[Fact]
public async Task ClearEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(ClearEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.Clear,
value: null);
// Verify table is blank
FormulaValue resultValue = this.State.Get("MyTable");
Assert.IsType<BlankValue>(resultValue);
}
[Fact]
public async Task TakeFirstItemAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstItemAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify the variable now contains the first record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(10, idValue.Value);
}
[Fact]
public async Task TakeFirstFromEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstFromEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify table is still empty (nothing was taken, variable remains unchanged)
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Empty(resultTable.Rows);
}
[Fact]
public async Task TakeLastItemAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastItemAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify the variable now contains the last record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(30, idValue.Value);
}
[Fact]
public async Task TakeLastFromEmptyTableAsync()
{
// Arrange - Initialize empty table using Power FX expression with schema
FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})");
TableValue table = Assert.IsAssignableFrom<TableValue>(tableValue);
// Clear the table to make it empty but preserve schema
await table.ClearAsync(CancellationToken.None);
this.State.Set("MyTable", table);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastFromEmptyTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify table is still empty (nothing was taken, variable remains unchanged)
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Empty(resultTable.Rows);
}
[Fact]
public async Task TakeFirstFromSingleItemTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeFirstFromSingleItemTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeFirst,
value: null);
// Verify variable contains the record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(100, idValue.Value);
}
[Fact]
public async Task TakeLastFromSingleItemTableAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]");
this.State.Set("MyTable", tableValue);
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(TakeLastFromSingleItemTableAsync),
variableName: "MyTable",
changeType: TableChangeType.TakeLast,
value: null);
// Verify variable contains the record that was taken
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(100, idValue.Value);
}
[Fact]
public async Task ErrorWhenVariableIsNotTableAsync()
{
// Arrange
this.State.Set("NotATable", FormulaValue.New("This is a string, not a table"));
EditTable model = this.CreateModel(
nameof(ErrorWhenVariableIsNotTableAsync),
"NotATable",
TableChangeType.Add,
new RecordDataValue([new("id", new NumberDataValue(1))]));
// Act
EditTableExecutor action = new(model, this.State);
// Assert - Should throw an exception for non-table variable
DeclarativeActionException exception = await Assert.ThrowsAsync<DeclarativeActionException>(
async () => await this.ExecuteAsync(action));
Assert.NotNull(exception);
}
[Fact]
public async Task AddWithExpressionAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 5}]");
this.State.Set("MyTable", tableValue);
this.State.Set("NewId", FormulaValue.New(10));
EditTable model = this.CreateModel(
nameof(AddWithExpressionAsync),
"MyTable",
TableChangeType.Add,
ValueExpression.Expression("{id: Local.NewId}"));
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert - Variable should contain the newly added record
VerifyModel(model, action);
FormulaValue resultValue = this.State.Get("MyTable");
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
Assert.Equal(10, idValue.Value);
}
[Fact]
public async Task RemoveWithNonTableValueAsync()
{
// Arrange - Initialize table using Power FX expression
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]");
this.State.Set("MyTable", tableValue);
// Try to remove using a non-table value (should not throw, just not remove anything)
EditTable model = this.CreateModel(
nameof(RemoveWithNonTableValueAsync),
"MyTable",
TableChangeType.Remove,
new RecordDataValue([new("id", new NumberDataValue(1))]));
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert - table should remain unchanged since value is not a TableDataValue
VerifyModel(model, action);
FormulaValue resultValue = this.State.Get("MyTable");
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
Assert.Equal(2, resultTable.Rows.Count());
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
TableChangeType changeType,
DataValue? value)
{
// Arrange
EditTable model = this.CreateModel(displayName, variableName, changeType, value);
// Act
EditTableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
DataValue? value)
{
ValueExpression.Builder? valueExpressionBuilder = value switch
{
null => null,
_ => new ValueExpression.Builder(ValueExpression.Literal(value))
};
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
ValueExpression valueExpression)
{
ValueExpression.Builder valueExpressionBuilder = new(valueExpression);
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
}
private EditTable CreateModel(
string displayName,
string variableName,
TableChangeType changeType,
ValueExpression.Builder? valueExpression)
{
EditTable.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)),
ChangeType = TableChangeTypeWrapper.Get(changeType),
Value = valueExpression,
};
return AssignParent<EditTable>(actionBuilder);
}
}
@@ -0,0 +1,344 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="EditTableV2Executor"/>.
/// </summary>
public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModelNullItemsVariable()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelNullItemsVariable)),
ItemsVariable = null,
ChangeType = new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test")))
}.Build()
}.Build();
// Act, Assert
DeclarativeModelException exception = Assert.Throws<DeclarativeModelException>(() => new EditTableV2Executor(model, this.State));
Assert.Contains("required", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InvalidModelVariableNotTableAsync()
{
// Arrange
this.State.Set("NotATable", FormulaValue.New("I am a string"));
EditTableV2 model = this.CreateModel(
nameof(InvalidModelVariableNotTableAsync),
"NotATable",
new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(new StringDataValue("test")))
}.Build());
EditTableV2Executor action = new(model, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task InvalidModelAddItemOperationNullValueAsync()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelAddItemOperationNullValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new AddItemOperation.Builder
{
Value = null
}.Build()
}.Build();
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act, Assert
EditTableV2Executor action = new(model, this.State);
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task InvalidModelRemoveItemOperationNullValueAsync()
{
// Arrange
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(InvalidModelRemoveItemOperationNullValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new RemoveItemOperation.Builder
{
Value = null
}.Build()
}.Build();
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act, Assert
EditTableV2Executor action = new(model, this.State);
await Assert.ThrowsAsync<DeclarativeActionException>(async () => await this.ExecuteAsync(action));
}
[Fact]
public async Task RemoveItemOperationNonTableValueAsync()
{
// Arrange
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1);
this.State.Set("TestTable", tableValue);
// Set a string value instead of a table for removal
this.State.Set("RemoveItems", FormulaValue.New("NotATable"));
EditTableV2 model = new EditTableV2.Builder
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(RemoveItemOperationNonTableValueAsync)),
ItemsVariable = PropertyPath.Create(FormatVariablePath("TestTable")),
ChangeType = new RemoveItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems")))
}.Build()
}.Build();
// Act
EditTableV2Executor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert: When the remove value is not a table, no removal occurs, so the table should be unchanged
FormulaValue value = this.State.Get("TestTable");
Assert.IsAssignableFrom<TableValue>(value);
TableValue resultTable = (TableValue)value;
Assert.Single(resultTable.Rows);
}
[Fact]
public async Task AddItemOperationWithSingleFieldRecordAsync()
{
// Arrange: Create an empty table with single field
RecordType recordType = RecordType.Empty().Add("Name", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(AddItemOperationWithSingleFieldRecordAsync),
variableName: "TestTable",
changeType: this.CreateAddItemOperation(new RecordDataValue.Builder
{
Properties =
{
["Name"] = new StringDataValue("John")
}
}.Build()),
verifyAction: (variableName, recordValue) =>
Assert.Equal("John", recordValue.GetField("Name").ToObject())
);
}
[Fact]
public async Task AddItemOperationWithScalarValueAsync()
{
// Arrange: Create an empty table with single field
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(AddItemOperationWithScalarValueAsync),
variableName: "TestTable",
changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")),
verifyAction: (variableName, recordValue) =>
Assert.Equal("TestValue", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task ClearItemsOperationAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<BlankValue>(
displayName: nameof(ClearItemsOperationAsync),
variableName: "TestTable",
changeType: new ClearItemsOperation.Builder().Build());
}
[Fact]
public async Task RemoveItemOperationAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<BlankValue>(
displayName: nameof(RemoveItemOperationAsync),
variableName: "TestTable",
changeType: this.CreateRemoveItemOperation("Item1"));
}
[Fact]
public async Task TakeLastItemOperationWithItemsAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(TakeLastItemOperationWithItemsAsync),
variableName: "TestTable",
changeType: new TakeLastItemOperation.Builder().Build(),
verifyAction: (variableName, recordValue) =>
Assert.Equal("Item3", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task TakeLastItemOperationEmptyTableAsync()
{
// Arrange: Create an empty table
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Arrange, Act, Assert
await this.ExecuteTestAsync<TableValue>(
displayName: nameof(TakeLastItemOperationEmptyTableAsync),
variableName: "TestTable",
changeType: new TakeLastItemOperation.Builder().Build());
}
[Fact]
public async Task TakeFirstItemOperationWithItemsAsync()
{
// Arrange: Create a table with some items
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
RecordValue record3 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item3")));
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<RecordValue>(
displayName: nameof(TakeFirstItemOperationWithItemsAsync),
variableName: "TestTable",
changeType: new TakeFirstItemOperation.Builder().Build(),
verifyAction: (variableName, recordValue) =>
Assert.Equal("Item1", recordValue.GetField("Value").ToObject())
);
}
[Fact]
public async Task TakeFirstItemOperationEmptyTableAsync()
{
// Arrange: Create an empty table
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
TableValue tableValue = FormulaValue.NewTable(recordType);
this.State.Set("TestTable", tableValue);
// Act & Assert
await this.ExecuteTestAsync<TableValue>(
displayName: nameof(TakeFirstItemOperationEmptyTableAsync),
variableName: "TestTable",
changeType: new TakeFirstItemOperation.Builder().Build());
}
private async Task ExecuteTestAsync<TValue>(
string displayName,
string variableName,
EditTableOperation changeType,
Action<string, TValue>? verifyAction = null) where TValue : FormulaValue
{
// Arrange
EditTableV2 model = this.CreateModel(displayName, variableName, changeType);
EditTableV2Executor action = new(model, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue value = this.State.Get(variableName);
TValue typedValue = Assert.IsAssignableFrom<TValue>(value);
verifyAction?.Invoke(variableName, typedValue);
}
private EditTableV2 CreateModel(string displayName, string variableName, EditTableOperation changeType)
{
EditTableV2.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)),
ChangeType = changeType
};
return AssignParent<EditTableV2>(actionBuilder);
}
private AddItemOperation CreateAddItemOperation(DataValue value)
{
return new AddItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Literal(value))
}.Build();
}
private RemoveItemOperation CreateRemoveItemOperation(string itemValue)
{
// Create a table with the item to remove
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
RecordValue recordToRemove = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New(itemValue)));
TableValue tableToRemove = FormulaValue.NewTable(recordType, recordToRemove);
// Store in state for expression evaluation
this.State.Set("RemoveItems", tableToRemove);
this.State.Bind();
return new RemoveItemOperation.Builder
{
Value = new ValueExpression.Builder(ValueExpression.Variable(PropertyPath.TopicVariable("RemoveItems")))
}.Build();
}
}
@@ -0,0 +1,625 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ForeachExecutor"/>.
/// </summary>
public sealed class ForeachExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void ForeachThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new ForeachExecutor(new Foreach(), this.State));
[Fact]
public void ForeachNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string startStep = ForeachExecutor.Steps.Start(testId);
string nextStep = ForeachExecutor.Steps.Next(testId);
string endStep = ForeachExecutor.Steps.End(testId);
// Assert
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Start)}", startStep);
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.Next)}", nextStep);
Assert.Equal($"{testId}_{nameof(ForeachExecutor.Steps.End)}", endStep);
}
[Fact]
public async Task ForeachInvokedWithSingleValueAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ForeachInvokedWithSingleValueAsync),
items: ValueExpression.Literal(new NumberDataValue(42)),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachInvokedWithTableValueAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ForeachInvokedWithTableValueAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachInvokedWithIndexAsync()
{
// Arrange
this.SetVariableState("CurrentValue", "CurrentIndex");
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(1))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(2))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("item", new NumberDataValue(3))));
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ForeachInvokedWithIndexAsync),
items: ValueExpression.Literal(tableValue),
valueName: "CurrentValue",
indexName: "CurrentIndex");
}
[Fact]
public async Task ForeachInvokedWithExpressionAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
this.State.Set("SourceArray", FormulaValue.NewTable(RecordType.Empty()));
// Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ForeachInvokedWithExpressionAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachTakeNextAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
this.State.Set(
"SourceArray",
FormulaValue.NewTable(
RecordType.Empty(),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
// Act & Assert
await this.TakeNextTestAsync(
displayName: nameof(ForeachTakeNextAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachTakeNextWithIndexAsync()
{
// Arrange
this.SetVariableState("CurrentValue", "CurrentIndex");
this.State.Set(
"SourceArray",
FormulaValue.NewTable(
RecordType.Empty(),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
// Act & Assert
await this.TakeNextTestAsync(
displayName: nameof(ForeachTakeNextWithIndexAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
valueName: "CurrentValue",
indexName: "CurrentIndex");
}
[Fact]
public async Task ForeachTakeNextWithMultiFieldRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(
new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice")),
new KeyValuePair<string, DataValue>("role", new StringDataValue("Engineer"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithMultiFieldRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
Assert.Equal("Engineer", currentValue.GetField("role").ToObject());
}
/// <summary>
/// Power Fx wraps scalar array literals such as <c>=[1, 2, 3]</c> as <c>Table({Value: 1}, ...)</c>;
/// the loop value must expose the bare scalar, not the single-column wrapper record.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleColumnValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(1))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(2))),
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("Value", new NumberDataValue(3))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleColumnValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
FormulaValue currentValue = this.State.Get(CurrentValueName);
Assert.IsNotType<RecordValue>(currentValue, exactMatch: false);
Assert.Equal(1m, currentValue.ToObject());
}
/// <summary>
/// Single-field records whose only field is NOT named <c>Value</c> are not Power Fx auto-wraps;
/// they are preserved as records so the field name remains accessible inside the loop body.
/// </summary>
[Fact]
public async Task ForeachTakeNextWithSingleFieldNonValueRecordAsync()
{
// Arrange
const string CurrentValueName = "CurrentValue";
this.SetVariableState(CurrentValueName);
TableDataValue tableValue = DataValue.TableFromRecords(
DataValue.RecordFromFields(new KeyValuePair<string, DataValue>("name", new StringDataValue("Alice"))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachTakeNextWithSingleFieldNonValueRecordAsync),
items: ValueExpression.Literal(tableValue),
valueName: CurrentValueName,
indexName: null);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
RecordValue currentValue = Assert.IsType<RecordValue>(this.State.Get(CurrentValueName), exactMatch: false);
Assert.Equal("Alice", currentValue.GetField("name").ToObject());
}
[Fact]
public async Task ForeachTakeLastAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
this.State.Set(
"SourceArray",
FormulaValue.NewTable(
RecordType.Empty(),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10)))));
// Act & Assert
await this.TakeNextTestAsync(
displayName: nameof(ForeachTakeLastAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable("SourceArray")),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachTakeNextWhenDoneAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
// Act & Assert
await this.TakeNextTestAsync(
displayName: nameof(ForeachTakeNextWhenDoneAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null,
expectValue: false);
}
[Fact]
public async Task ForeachCompletedWithoutIndexAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
// Act & Assert
await this.CompletedTestAsync(
displayName: nameof(ForeachCompletedWithoutIndexAsync),
valueName: "CurrentValue",
indexName: null);
}
[Fact]
public async Task ForeachCompletedWithIndexAsync()
{
// Arrange
this.SetVariableState("CurrentValue", "CurrentIndex");
// Act & Assert
await this.CompletedTestAsync(
displayName: nameof(ForeachCompletedWithIndexAsync),
valueName: "CurrentValue",
indexName: "CurrentIndex");
}
/// <summary>
/// Regression test for GH-5009: a <see cref="ForeachExecutor"/> that is re-instantiated
/// during checkpoint restore (e.g. cross-process resume after a <c>Question</c> inside the
/// loop body) must continue iterating from where it left off, not exit after the first
/// iteration.
/// </summary>
[Fact]
public async Task ForeachStateRestoredAcrossCheckpointAsync()
{
// Arrange — a 3-item source table and a freshly-bound foreach executor (instance A).
const string SourceVariableName = "SourceArray";
this.SetVariableState("CurrentValue");
this.State.Set(
SourceVariableName,
FormulaValue.NewTable(
RecordType.Empty(),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(10))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(20))),
FormulaValue.NewRecordFromFields(new NamedValue("value", FormulaValue.New(30)))));
Foreach model = this.CreateModel(
displayName: nameof(ForeachStateRestoredAcrossCheckpointAsync),
items: ValueExpression.Variable(PropertyPath.TopicVariable(SourceVariableName)),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor instanceA = new(model, this.State);
// Drive instance A through ExecuteAsync (initializes _values/_index) and one TakeNextAsync
// so that _index advances to 1 and HasValue is true — the state at the point a Question
// inside the loop body would pause the workflow and trigger a checkpoint.
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
Assert.True(instanceA.HasValue, "Instance A should have a current item after the first TakeNextAsync.");
// Act 1 — instance A persists checkpoint state.
InMemoryWorkflowContext checkpoint = new();
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
// Act 2 — a fresh instance B (simulating cross-process resume) restores from the checkpoint.
ForeachExecutor instanceB = new(model, this.State);
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
// Assert — HasValue carries over so the routing predicate after loopId continues to take
// the "loop body" edge instead of falling through to the loop continuation.
Assert.True(instanceB.HasValue, "Restored instance should report HasValue == true at the checkpointed cursor.");
// Drive iteration 2 and 3 through instance B; both should succeed.
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 2 (value=20).");
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.True(instanceB.HasValue, "Restored instance should advance to iteration 3 (value=30).");
// Driving past the end exits the loop normally.
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.False(instanceB.HasValue, "Restored instance should report HasValue == false after exhausting all items.");
}
/// <summary>
/// When no checkpoint state has been written for the executor (e.g. first run), the restore
/// hook must be a no-op and leave constructor defaults in place.
/// </summary>
[Fact]
public async Task ForeachRestoreWithNoSavedStateAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
Foreach model = this.CreateModel(
displayName: nameof(ForeachRestoreWithNoSavedStateAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor executor = new(model, this.State);
InMemoryWorkflowContext emptyContext = new();
// Act — restoring against an empty context must not throw and must leave the executor
// in its constructor-default state.
await InvokeOnCheckpointRestoredAsync(executor, emptyContext);
// Assert
Assert.False(executor.HasValue);
// A subsequent TakeNextAsync (without a prior ExecuteAsync) should report no value
// because _values is still the empty constructor default.
await executor.TakeNextAsync(emptyContext, _: null, CancellationToken.None);
Assert.False(executor.HasValue);
}
/// <summary>
/// Checkpoint/restore around a foreach over an empty source must roundtrip cleanly
/// (zero-length <c>PortableValue[]</c> snapshot).
/// </summary>
[Fact]
public async Task ForeachStateSurvivesEmptyValuesAsync()
{
// Arrange
this.SetVariableState("CurrentValue");
Foreach model = this.CreateModel(
displayName: nameof(ForeachStateSurvivesEmptyValuesAsync),
items: ValueExpression.Literal(DataValue.EmptyTable),
valueName: "CurrentValue",
indexName: null);
ForeachExecutor instanceA = new(model, this.State);
// Run ExecuteAsync (which sets _values = []) followed by one TakeNextAsync (which sets
// HasValue = false on an empty source).
await this.ExecuteAsync(instanceA, ForeachExecutor.Steps.Next(instanceA.Id), instanceA.TakeNextAsync);
Assert.False(instanceA.HasValue);
// Act — checkpoint and restore into a fresh instance.
InMemoryWorkflowContext checkpoint = new();
await InvokeOnCheckpointingAsync(instanceA, checkpoint);
ForeachExecutor instanceB = new(model, this.State);
await InvokeOnCheckpointRestoredAsync(instanceB, checkpoint);
// Assert — restored instance must agree that the source is empty and HasValue is false.
Assert.False(instanceB.HasValue);
await instanceB.TakeNextAsync(checkpoint, _: null, CancellationToken.None);
Assert.False(instanceB.HasValue);
}
private void SetVariableState(string valueName, string? indexName = null, FormulaValue? valueState = null)
{
this.State.Set(valueName, valueState ?? FormulaValue.New("something"));
if (indexName is not null)
{
this.State.Set(indexName, FormulaValue.New(33));
}
}
private async Task ExecuteTestAsync(
string displayName,
ValueExpression items,
string valueName,
string? indexName,
bool expectValue = false)
{
// Arrange
Foreach model = this.CreateModel(displayName, items, valueName, indexName);
ForeachExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
// IsDiscreteAction should be false for Foreach
VerifyIsDiscrete(action, isDiscrete: false);
// Verify HasValue state after execution
Assert.Equal(expectValue, action.HasValue);
// Verify value was reset at the end
this.VerifyUndefined(valueName);
// Verify index was reset at the end if it was used
if (indexName is not null)
{
this.VerifyUndefined(indexName);
}
}
private async Task TakeNextTestAsync(
string displayName,
ValueExpression items,
string valueName,
string? indexName,
bool expectValue = true)
{
// Arrange
Foreach model = this.CreateModel(displayName, items, valueName, indexName);
ForeachExecutor action = new(model, this.State);
// Act
await this.ExecuteAsync(action, ForeachExecutor.Steps.Next(action.Id), action.TakeNextAsync);
// Assert
VerifyModel(model, action);
// Verify HasValue state after execution
Assert.Equal(expectValue, action.HasValue);
}
private async Task CompletedTestAsync(
string displayName,
string valueName,
string? indexName)
{
// Arrange
Foreach model = this.CreateModel(displayName, ValueExpression.Literal(DataValue.EmptyTable), valueName, indexName);
ForeachExecutor action = new(model, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(ForeachExecutor.Steps.End(action.Id), action.CompleteAsync);
// Assert
VerifyModel(model, action);
VerifyCompletionEvent(events);
// Verify HasValue state after completion
Assert.False(action.HasValue);
// Verify value was reset at the end
this.VerifyUndefined(valueName);
// Verify index was reset at the end if it was used
if (indexName is not null)
{
this.VerifyUndefined(indexName);
}
}
private Foreach CreateModel(
string displayName,
ValueExpression items,
string valueName,
string? indexName)
{
Foreach.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Items = items,
Value = PropertyPath.Create(FormatVariablePath(valueName)),
};
if (indexName is not null)
{
actionBuilder.Index = PropertyPath.Create(FormatVariablePath(indexName));
}
return AssignParent<Foreach>(actionBuilder);
}
// Reflection helpers used to invoke the `protected internal` checkpoint hooks on the executor
// base class from this test project (which is in a different assembly than Microsoft.Agents.AI.Workflows
// and is not granted InternalsVisibleTo there).
private static Task InvokeOnCheckpointingAsync(Executor executor, IWorkflowContext context) =>
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointingAsync");
private static Task InvokeOnCheckpointRestoredAsync(Executor executor, IWorkflowContext context) =>
InvokeProtectedCheckpointHookAsync(executor, context, methodName: "OnCheckpointRestoredAsync");
private static async Task InvokeProtectedCheckpointHookAsync(Executor executor, IWorkflowContext context, string methodName)
{
MethodInfo method = typeof(Executor).GetMethod(
methodName,
BindingFlags.Instance | BindingFlags.NonPublic,
binder: null,
types: new[] { typeof(IWorkflowContext), typeof(CancellationToken) },
modifiers: null) ?? throw new InvalidOperationException($"Could not locate {methodName} on Executor.");
ValueTask invocation = (ValueTask)method.Invoke(executor, new object[] { context, CancellationToken.None })!;
await invocation;
}
/// <summary>
/// Minimal in-memory <see cref="IWorkflowContext"/> implementation used to drive the
/// checkpoint/restore overrides on <see cref="ForeachExecutor"/> directly from a unit test.
/// Records state writes in a (scope, key) dictionary and serves matching reads back. Only the
/// state-related members are exercised by the checkpoint hooks; the other members are stubbed.
/// </summary>
private sealed class InMemoryWorkflowContext : IWorkflowContext
{
private readonly Dictionary<(string? scope, string key), object?> _store = [];
public bool ConcurrentRunsEnabled => false;
public IReadOnlyDictionary<string, string>? TraceContext => null;
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
this._store[(scopeName, key)] = value;
return default;
}
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
{
return new ValueTask<T?>(typed);
}
return new ValueTask<T?>(default(T));
}
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
if (this._store.TryGetValue((scopeName, key), out object? stored) && stored is T typed)
{
return new ValueTask<T>(typed);
}
T initial = initialStateFactory();
this._store[(scopeName, key)] = initial;
return new ValueTask<T>(initial);
}
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
HashSet<string> keys = new(
this._store.Keys
.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal))
.Select(slot => slot.key));
return new ValueTask<HashSet<string>>(keys);
}
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
foreach ((string? scope, string key) slot in this._store.Keys.Where(slot => string.Equals(slot.scope, scopeName, StringComparison.Ordinal)).ToArray())
{
this._store.Remove(slot);
}
return default;
}
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default;
public ValueTask SendMessageAsync(object message, string? targetId, CancellationToken cancellationToken = default) => default;
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default;
public ValueTask RequestHaltAsync() => default;
}
}
@@ -0,0 +1,759 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="HttpRequestExecutor"/>.
/// </summary>
public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string TestUrl = "https://api.example.com/data";
private readonly Mock<ResponseAgentProvider> _agentProvider = new(MockBehavior.Loose);
[Fact]
public void InvalidModel()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
// Act & Assert
Assert.Throws<DeclarativeModelException>(() => new HttpRequestExecutor(
new HttpRequestAction(),
mockHandler.Object,
this._agentProvider.Object,
this.State));
}
[Fact]
public void HttpRequestIsDiscreteAction()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestIsDiscreteAction),
url: TestUrl,
method: HttpMethodType.Get);
HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State);
// Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action).
VerifyIsDiscrete(action, isDiscrete: true);
}
[Fact]
public async Task HttpGetReturnsJsonObjectAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonObjectAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.IsType<RecordValue>(this.State.Get(ResponseVar), exactMatch: false);
handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl);
}
[Fact]
public async Task HttpGetReturnsPlainStringAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsPlainStringAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("not-json content"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(ResponseVar, FormulaValue.New("not-json content"));
}
[Fact]
public async Task HttpGetWithEmptyBodyYieldsBlankAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult(null));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined(ResponseVar);
}
[Fact]
public async Task HttpGetForwardsHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetForwardsHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["Authorization"] = "Bearer token",
["Accept"] = "application/json",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?["Authorization"] == "Bearer token" &&
info.Headers?["Accept"] == "application/json");
}
[Fact]
public async Task HttpPostWithJsonBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithJsonBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
jsonBody: new StringDataValue("hello"));
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Method == "POST" &&
info.BodyContentType == "application/json" &&
info.Body == "\"hello\"");
}
[Fact]
public async Task HttpPostWithRawBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithRawBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
rawBody: "raw body content",
rawContentType: "text/plain");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.BodyContentType == "text/plain" &&
info.Body == "raw body content");
}
[Fact]
public async Task HttpRequestRaisesOnErrorByDefaultAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
string longBody = new('x', 10_000);
MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - message contains status and truncation marker, bounded in length, never the full body.
Assert.Contains("500", exception.Message);
Assert.Contains("[truncated]", exception.Message);
Assert.DoesNotContain(longBody, exception.Message);
Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars.");
}
[Fact]
public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - status present, no stray "Body: ''" noise.
Assert.Contains("404", exception.Message);
Assert.DoesNotContain("Body:", exception.Message);
}
[Fact]
public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - CR/LF/TAB collapsed to spaces so the message stays on one line.
Assert.DoesNotContain("\r", exception.Message);
Assert.DoesNotContain("\n", exception.Message);
Assert.DoesNotContain("\t", exception.Message);
Assert.Contains("line1", exception.Message);
Assert.Contains("line2", exception.Message);
}
[Fact]
public async Task HttpRequestPassesTimeoutToHandlerAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 1500);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Timeout is not null &&
info.Timeout.Value == TimeSpan.FromMilliseconds(1500));
}
[Fact]
public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new OperationCanceledException());
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new InvalidOperationException("transport failure"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestStoresResponseHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
const string HeaderVar = "Headers";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestStoresResponseHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseHeadersVariable: HeaderVar);
Dictionary<string, IReadOnlyList<string>> responseHeaders = new(StringComparer.OrdinalIgnoreCase)
{
["X-Request-Id"] = ["abc-123"],
["Set-Cookie"] = ["a=1", "b=2"],
};
MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue storedHeaders = this.State.Get(HeaderVar);
Assert.IsType<RecordValue>(storedHeaders, exactMatch: false);
}
[Fact]
public async Task HttpRequestForwardsQueryParametersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsQueryParametersAsync),
url: TestUrl,
method: HttpMethodType.Get,
queryParameters: new Dictionary<string, DataValue>
{
["filter"] = StringDataValue.Create("active"),
["limit"] = NumberDataValue.Create(10),
["includeDeleted"] = BooleanDataValue.Create(false),
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.QueryParameters?.Count == 3 &&
info.QueryParameters["filter"] == "active" &&
info.QueryParameters["limit"] == "10" &&
info.QueryParameters["includeDeleted"] == "false");
}
[Fact]
public async Task HttpRequestAddsResponseToConversationAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "conv-12345";
const string ResponseBody = "response-text";
this._agentProvider
.Setup(p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>((_, message, _) => Task.FromResult(message));
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestAddsResponseToConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: ConversationId);
MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(
ConversationId,
It.Is<ChatMessage>(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HttpRequestWithoutConversationIdSkipsConversationAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestForwardsConnectionNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConnectionName = "my-connection";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsConnectionNameAsync),
url: TestUrl,
method: HttpMethodType.Get,
connectionName: ConnectionName);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.ConnectionName == ConnectionName);
}
[Fact]
public async Task HttpRequestEmptyConversationIdSkipsConversationAsync()
{
// Arrange - empty-string conversationId should be treated as unset.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "");
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestEmptyResponseBodySkipsConversationAsync()
{
// Arrange - conversationId set, but empty body should not produce a conversation message.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "conv-1");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpGetReturnsJsonArrayAsync()
{
// Arrange - exercises JsonValueKind.Array branch of ParseResponseBody.
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonArrayAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue stored = this.State.Get(ResponseVar);
Assert.IsType<TableValue>(stored, exactMatch: false);
}
[Fact]
public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync()
{
// Arrange - empty header values should be filtered out (matches GetHeaders guard).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["X-Trace"] = "trace-1",
["X-Empty"] = "",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?.ContainsKey("X-Trace") == true &&
info.Headers?.ContainsKey("X-Empty") == false);
}
[Fact]
public async Task HttpRequestZeroTimeoutNotForwardedAsync()
{
// Arrange - non-positive timeouts should not be forwarded (handler default applies).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 0);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.Timeout is null);
}
private static HttpRequestResult HttpRequestResult(
string? body,
int statusCode = 200,
bool isSuccess = true,
IReadOnlyDictionary<string, IReadOnlyList<string>>? headers = null) =>
new()
{
StatusCode = statusCode,
IsSuccessStatusCode = isSuccess,
Body = body,
Headers = headers,
};
private HttpRequestAction CreateModel(
string displayName,
string url,
HttpMethodType method,
string? responseVariable = null,
string? responseHeadersVariable = null,
IReadOnlyDictionary<string, string>? headers = null,
IReadOnlyDictionary<string, DataValue>? queryParameters = null,
string? conversationId = null,
string? connectionName = null,
DataValue? jsonBody = null,
string? rawBody = null,
string? rawContentType = null,
long? timeoutMilliseconds = null,
string? continueOnErrorStatusVariable = null,
string? continueOnErrorBodyVariable = null)
{
HttpRequestAction.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Url = new StringExpression.Builder(StringExpression.Literal(url)),
Method = new EnumExpression<HttpMethodTypeWrapper>.Builder(
EnumExpression<HttpMethodTypeWrapper>.Literal(HttpMethodTypeWrapper.Get(method))),
};
if (responseVariable is not null)
{
builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable));
}
if (responseHeadersVariable is not null)
{
builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable));
}
if (headers is not null)
{
foreach (KeyValuePair<string, string> header in headers)
{
builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value)));
}
}
if (queryParameters is not null)
{
foreach (KeyValuePair<string, DataValue> parameter in queryParameters)
{
builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value)));
}
}
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (connectionName is not null)
{
builder.Connection = new RemoteConnection.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(connectionName)),
};
}
if (jsonBody is not null)
{
builder.Body = new JsonRequestContent.Builder()
{
Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)),
};
}
else if (rawBody is not null)
{
RawRequestContent.Builder rawBuilder = new()
{
Content = new StringExpression.Builder(StringExpression.Literal(rawBody)),
};
if (rawContentType is not null)
{
rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType));
}
builder.Body = rawBuilder;
}
if (timeoutMilliseconds is not null)
{
builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value));
}
if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null)
{
ContinueOnErrorBehavior.Builder continueBuilder = new();
if (continueOnErrorStatusVariable is not null)
{
continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable));
}
if (continueOnErrorBodyVariable is not null)
{
continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable));
}
builder.ErrorHandling = continueBuilder;
}
return AssignParent<HttpRequestAction>(builder);
}
private sealed class MockHttpRequestHandler : Mock<IHttpRequestHandler>
{
private HttpRequestInfo? _lastRequest;
public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null)
{
this.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
.Returns<HttpRequestInfo, CancellationToken>((info, _) =>
{
this._lastRequest = info;
if (throwOnSend is not null)
{
throw throwOnSend;
}
return Task.FromResult(result);
});
}
public void VerifySent(Func<HttpRequestInfo, bool> predicate)
{
Assert.NotNull(this._lastRequest);
Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate.");
}
}
}
@@ -0,0 +1,358 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="InvokeAzureAgentExecutor"/>.
/// </summary>
public sealed class InvokeAzureAgentExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvokeAzureAgentThrowsWhenModelInvalid() =>
// Arrange, Act & Assert
Assert.Throws<DeclarativeModelException>(() => new InvokeAzureAgentExecutor(new InvokeAzureAgent(), new CapturingAgentProvider("text"), this.State));
#region Input argument binding
[Fact]
public async Task MultipleNamedArgumentsAreAllBoundAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MultipleNamedArgumentsAreAllBoundAsync),
agentName: "BrainCombine",
arguments:
[
("a", ValueExpression.Literal(new StringDataValue("alpha"))),
("b", ValueExpression.Literal(new StringDataValue("beta"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
Assert.Equal("alpha", provider.CapturedArguments!["a"]);
Assert.Equal("beta", provider.CapturedArguments!["b"]);
}
[Fact]
public async Task RecordValuedArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
this.State.Set(
"R",
FormulaValue.NewRecordFromFields(
new NamedValue("a", FormulaValue.New("alpha")),
new NamedValue("b", FormulaValue.New("beta"))));
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(RecordValuedArgumentIsBoundAsRecordAsync),
agentName: "BrainTest",
arguments:
[
("input", ValueExpression.Variable(PropertyPath.TopicVariable("R"))),
]);
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
[Fact]
public async Task InlineRecordExpressionArgumentIsBoundAsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("acknowledged");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(InlineRecordExpressionArgumentIsBoundAsRecordAsync),
agentName: "BrainInlineRecord",
arguments:
[
("input", ValueExpression.Expression("""{ a: "alpha", b: "beta" }""")),
]);
// Act (reporter repro (b): a single argument whose value is an inline record literal)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
Assert.NotNull(provider.CapturedArguments);
IDictionary<string, object?> record = Assert.IsAssignableFrom<IDictionary<string, object?>>(provider.CapturedArguments!["input"]);
Assert.Equal("alpha", record["a"]);
Assert.Equal("beta", record["b"]);
}
#endregion
#region Response object parsing
[Fact]
public async Task JsonObjectOutputAssignsRecordAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""{ "a": "alpha", "b": "beta" }""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonObjectOutputAssignsRecordAsync),
agentName: "BrainObject",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
RecordValue record = Assert.IsAssignableFrom<RecordValue>(this.State.Get("Result"));
Assert.Equal("alpha", ((StringValue)record.GetField("a")).Value);
}
[Fact]
public async Task JsonArrayOutputAssignsListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonArrayOutputAssignsListAsync),
agentName: "BrainArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Equal(2, table.Rows.Count());
}
[Fact]
public async Task EmptyJsonArrayOutputAssignsEmptyListAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(EmptyJsonArrayOutputAssignsEmptyListAsync),
agentName: "BrainEmptyArray",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
TableValue table = Assert.IsAssignableFrom<TableValue>(this.State.Get("Result"));
Assert.Empty(table.Rows);
}
[Fact]
public async Task JsonScalarOutputAssignsScalarAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("42");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(JsonScalarOutputAssignsScalarAsync),
agentName: "BrainScalar",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
NumberValue number = Assert.IsAssignableFrom<NumberValue>(this.State.Get("Result"));
Assert.Equal(42d, number.Value);
}
[Fact]
public async Task MixedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha",1]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(MixedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainMixedArray",
responseObjectVariable: "Result");
// Act (must not throw despite non-convertible JSON)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NestedJsonArrayOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("[[1,2],[3,4]]");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NestedJsonArrayOutputSkipsAssignmentAsync),
agentName: "BrainNestedArray",
responseObjectVariable: "Result");
// Act (a nested array parses but is not convertible to a workflow value — must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task PlainTextOutputSkipsAssignmentAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("hello world");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(PlainTextOutputSkipsAssignmentAsync),
agentName: "BrainText",
responseObjectVariable: "Result");
// Act
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
// Assert
this.VerifyUndefined("Result");
}
[Fact]
public async Task NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync()
{
// Arrange
this.State.InitializeSystem();
CapturingAgentProvider provider = new("""["alpha","beta"]""");
InvokeAzureAgent model =
this.CreateModel(
displayName: nameof(NonObjectJsonOutputWithoutResponseObjectDoesNotThrowAsync),
agentName: "BrainNoOutput");
// Act & Assert (reporter repro shape — no output block; must not throw)
await this.ExecuteAsync(new InvokeAzureAgentExecutor(model, provider, this.State), isDiscrete: false);
}
#endregion
#region Helpers
private InvokeAzureAgent CreateModel(
string displayName,
string agentName,
IReadOnlyList<(string Key, ValueExpression Value)>? arguments = null,
string? responseObjectVariable = null)
{
InvokeAzureAgent.Builder builder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Agent =
new AzureAgentUsage.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(agentName)),
},
};
if (arguments is not null)
{
AzureAgentInput.Builder inputBuilder = new();
foreach ((string key, ValueExpression value) in arguments)
{
inputBuilder.Arguments.Add(key, value);
}
builder.Input = inputBuilder;
}
if (responseObjectVariable is not null)
{
builder.Output =
new AzureAgentOutput.Builder
{
AutoSend = new BoolExpression.Builder(BoolExpression.Literal(false)),
ResponseObject = new InitializablePropertyPath(PropertyPath.TopicVariable(responseObjectVariable), isInitializer: false),
};
}
return AssignParent<InvokeAzureAgent>(builder);
}
/// <summary>
/// Minimal <see cref="ResponseAgentProvider"/> that returns a single configured text response and
/// captures the input arguments supplied to <see cref="InvokeAgentAsync"/>.
/// </summary>
private sealed class CapturingAgentProvider(string responseText) : ResponseAgentProvider
{
public IDictionary<string, object?>? CapturedArguments { get; private set; }
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default)
{
this.CapturedArguments = inputArguments;
return YieldAsync(responseText);
}
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N"));
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
Task.FromResult(conversationMessage);
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public override async IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId,
int? limit = null,
string? after = null,
string? before = null,
bool newestFirst = false,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
private static async IAsyncEnumerable<AgentResponseUpdate> YieldAsync(string text)
{
yield return new AgentResponseUpdate(ChatRole.Assistant, text);
await Task.CompletedTask.ConfigureAwait(false);
}
}
#endregion
}
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ParseValueExecutor"/>.
/// </summary>
public sealed class ParseValueExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ParseRecordAsync()
{
// Arrange
RecordDataType.Builder recordBuilder =
new()
{
Properties =
{
{"key1", new PropertyInfo.Builder() { Type = DataType.String } },
}
};
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseRecordAsync)),
recordBuilder,
@"{ ""key1"": ""val1"" }",
FormulaValue.NewRecordFromFields(new NamedValue("key1", FormulaValue.New("val1"))));
}
[Fact]
public async Task ParseTableAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseTableAsync)),
DataType.EmptyTable,
@"[""apple"",""banana"",""cat""]",
FormulaValue.NewSingleColumnTable(FormulaValue.New("apple"), FormulaValue.New("banana"), FormulaValue.New("cat")));
}
[Fact]
public async Task ParseBooleanAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseBooleanAsync)),
new BooleanDataType.Builder(),
"True",
FormulaValue.New(true));
}
[Fact]
public async Task ParseNumberAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseNumberAsync)),
new NumberDataType.Builder(),
"42",
FormulaValue.New(42));
}
[Fact]
public async Task ParseStringAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(ParseStringAsync)),
new StringDataType.Builder(),
"Hello, World!",
FormulaValue.New("Hello, World!"));
}
private async Task ExecuteTestAsync(
string displayName,
DataType.Builder dataBuilder,
string sourceText,
FormulaValue expectedValue)
{
ParseValue model =
this.CreateModel(
displayName,
"Target",
dataBuilder,
sourceText);
// Act
ParseValueExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState("Target", expectedValue);
}
private ParseValue CreateModel(
string displayName,
string variableName,
DataType.Builder typeBuilder,
string sourceText)
{
ParseValue.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
ValueType = typeBuilder,
Variable = PropertyPath.TopicVariable(variableName),
Value = new ValueExpression.Builder(ValueExpression.Literal(StringDataValue.Create(sourceText))),
};
return AssignParent<ParseValue>(actionBuilder);
}
}
@@ -0,0 +1,501 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="QuestionExecutor"/>.
/// </summary>
public sealed class QuestionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void QuestionNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string prepareStep = QuestionExecutor.Steps.Prepare(testId);
string inputStep = QuestionExecutor.Steps.Input(testId);
string captureStep = QuestionExecutor.Steps.Capture(testId);
// Assert
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Prepare)}", prepareStep);
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Input)}", inputStep);
Assert.Equal($"{testId}_{nameof(QuestionExecutor.Steps.Capture)}", captureStep);
}
[Theory]
[InlineData(true, false)]
[InlineData("anything", false)]
[InlineData(null, true)]
public void QuestionIsComplete(object? result, bool expectIsComplete)
{
// Arrange - "Complete" result corresponds to null value
ActionExecutorResult executorResult = new(nameof(QuestionIsComplete), result);
// Act
bool isComplete = QuestionExecutor.IsComplete(executorResult);
// Assert
Assert.Equal(expectIsComplete, isComplete);
}
[Fact]
public async Task QuestionExecuteWithResultUndefinedAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithResultUndefinedAsync),
"TestVariable");
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Fact]
public async Task QuestionExecuteWithAlwaysPromptAsync()
{
// Arrange
this.State.Set("TestVariable", FormulaValue.New("existing-value"));
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithAlwaysPromptAsync),
"TestVariable",
alwaysPrompt: true);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Theory]
[InlineData(SkipQuestionMode.AlwaysSkipIfVariableHasValue)]
[InlineData(SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue)]
[InlineData(SkipQuestionMode.AlwaysAsk)]
public async Task QuestionExecuteWithSkipModeAsyncWithResultUndefinedAsync(SkipQuestionMode skipMode)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithSkipModeAsyncWithResultUndefinedAsync),
variableName: "TestVariable",
skipMode: skipMode);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt: true);
}
[Theory]
[InlineData(SkipQuestionMode.AlwaysSkipIfVariableHasValue, false)]
[InlineData(SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue, false)]
[InlineData(SkipQuestionMode.AlwaysAsk, true)]
public async Task QuestionExecuteWithSkipModeAsyncWithResultDefinedAsync(SkipQuestionMode skipMode, bool expectPrompt)
{
// Arrange
this.State.Set("TestVariable", FormulaValue.New("existing-value"));
Question model = this.CreateModel(
displayName: nameof(QuestionExecuteWithSkipModeAsyncWithResultDefinedAsync),
variableName: "TestVariable",
skipMode: skipMode);
// Act & Assert
await this.ExecuteTestAsync(model, expectPrompt);
}
[Fact]
public async Task QuestionPrepareResponseAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionPrepareResponseAsync),
variableName: "TestVariable",
promptText: "Provide input:");
// Act & Assert
await this.PrepareResponseTestAsync(model, expectedPrompt: "Provide input:");
}
[Fact]
public async Task QuestionCaptureResponseWithValidEntityAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithValidEntityAsync),
variableName: "TestVariable",
alwaysPrompt: true,
skipMode: SkipQuestionMode.AlwaysAsk,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "42",
expectAutoSend: true);
}
[Theory]
[InlineData(null)]
[InlineData("Invalid input, please try again.")]
public async Task QuestionCaptureResponseWithInvalidEntityAsync(string? invalidResponse)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithInvalidEntityAsync),
variableName: "TestVariable",
invalidResponseText: invalidResponse,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "not-a-number",
expectResponse: false);
}
[Theory]
[InlineData(null)]
[InlineData("Invalid input, please try again.")]
public async Task QuestionCaptureResponseWithUnrecognizedResponseAsync(string? unrecognizedResponse)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithUnrecognizedResponseAsync),
variableName: "TestVariable",
unrecognizedResponseText: unrecognizedResponse);
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: null,
expectResponse: false);
}
[Fact]
public async Task QuestionCaptureResponseWithUnsupportedPromptAsync()
{
// Arrange
Question.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(nameof(QuestionCaptureResponseWithUnsupportedPromptAsync)),
Variable = PropertyPath.Create(FormatVariablePath("TestVariable")),
Prompt = new UnknownActivityTemplateBase.Builder(),
UnrecognizedPrompt = new UnknownActivityTemplateBase.Builder(),
Entity = new StringPrebuiltEntity(),
};
Question model = actionBuilder.Build();
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: null,
expectResponse: false);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task QuestionCaptureResponseExceedingRepeatCountAsync(bool hasDefault)
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseExceedingRepeatCountAsync),
variableName: "TestVariable",
repeatCount: 0,
defaultValue: hasDefault ? new NumberDataValue(0) : null,
entity: new NumberPrebuiltEntity());
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "not-a-number",
expectResponse: false);
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendFalseAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendFalseAsync),
variableName: "TestVariable",
autoSend: new BooleanDataValue(false));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response");
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendTrueAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendTrueAsync),
variableName: "TestVariable",
autoSend: new BooleanDataValue(true));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response",
expectAutoSend: true);
}
[Fact]
public async Task QuestionCaptureResponseWithAutoSendInvalidAsync()
{
// Arrange
Question model = this.CreateModel(
displayName: nameof(QuestionCaptureResponseWithAutoSendInvalidAsync),
variableName: "TestVariable",
autoSend: new NumberDataValue(33));
// Act & Assert
await this.CaptureResponseTestAsync(
model,
variableName: "TestVariable",
responseText: "test response");
}
[Fact]
public async Task QuestionCompleteAsync()
{
// Arrange
Question model =
this.CreateModel(
displayName: nameof(QuestionCompleteAsync),
variableName: "TestVariable");
// Act & Assert
await this.CompleteTestAsync(model);
}
private async Task ExecuteTestAsync(Question model, bool expectPrompt)
{
// Arrange
bool? sentMessage = null;
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
action,
QuestionExecutor.Steps.Capture(action.Id),
CaptureResultAsync);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
Assert.NotNull(sentMessage);
Assert.Equal(expectPrompt, sentMessage);
ValueTask CaptureResultAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
Assert.Null(sentMessage); // Should only be called once
sentMessage = message.Result is not null;
return default;
}
}
private async Task PrepareResponseTestAsync(
Question model,
string expectedPrompt)
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
string? capturedPrompt = null;
// Act
await this.ExecuteAsync(
[
action,
new DelegateActionExecutor(
QuestionExecutor.Steps.Prepare(action.Id),
this.State,
action.PrepareResponseAsync),
new DelegateActionExecutor<ExternalInputRequest>(
QuestionExecutor.Steps.Capture(action.Id),
this.State,
CaptureExternalRequestAsync)
],
isDiscrete: false);
// Assert
VerifyModel(model, action);
Assert.NotNull(capturedPrompt);
Assert.Equal(expectedPrompt, capturedPrompt);
ValueTask CaptureExternalRequestAsync(IWorkflowContext context, ExternalInputRequest request, CancellationToken cancellationToken)
{
Assert.Null(capturedPrompt);
capturedPrompt = request.AgentResponse.Text;
return default;
}
}
private async Task CaptureResponseTestAsync(
Question model,
string variableName,
string? responseText,
bool expectResponse = true,
bool expectAutoSend = false)
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("ExternalConversationId"), VariableScopeNames.System);
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
mockProvider
.Setup(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((string cid, ChatMessage msg, CancellationToken ct) => msg);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
ExternalInputResponse response = responseText is not null
? new ExternalInputResponse(new ChatMessage(ChatRole.User, responseText))
: new ExternalInputResponse([]);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(
action,
QuestionExecutor.Steps.Capture(action.Id),
(context, message, cancellationToken) =>
action.CaptureResponseAsync(context, response, cancellationToken));
// Assert
VerifyModel(model, action);
if (expectResponse)
{
// Variable should be set with the extracted value
FormulaValue actualValue = this.State.Get(variableName);
Assert.Equal(responseText, actualValue.Format());
}
else
{
// Should have prompted again or sent unrecognized/invalid message
Assert.Contains(events, e => e is MessageActivityEvent);
}
if (expectAutoSend)
{
this.VerifyState(SystemScope.Names.LastMessageText, VariableScopeNames.System, FormulaValue.New(responseText ?? string.Empty));
}
else
{
this.VerifyUndefined(SystemScope.Names.LastMessageText, VariableScopeNames.System);
}
}
private async Task CompleteTestAsync(Question model)
{
// Arrange
Mock<ResponseAgentProvider> mockProvider = new(MockBehavior.Loose);
QuestionExecutor action = new(model, mockProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(
QuestionExecutor.Steps.Input(action.Id),
action.CompleteAsync);
// Assert
VerifyModel(model, action);
VerifyCompletionEvent(events);
}
private Question CreateModel(
string displayName,
string variableName,
string promptText = "Please provide a value",
string? invalidResponseText = null,
string? unrecognizedResponseText = null,
string? defaultValueResponseText = null,
DataValue? defaultValue = null,
bool? alwaysPrompt = null,
SkipQuestionMode? skipMode = null,
int? repeatCount = null,
EntityReference? entity = null,
DataValue? autoSend = null)
{
BoolExpression.Builder? alwaysPromptExpression = null;
if (alwaysPrompt is not null)
{
alwaysPromptExpression = BoolExpression.Literal(alwaysPrompt.Value).ToBuilder();
}
IntExpression.Builder? repeatCountExpression = null;
if (repeatCount is not null)
{
repeatCountExpression = IntExpression.Literal(repeatCount.Value).ToBuilder();
}
ValueExpression.Builder? defaultValueExpression = null;
if (defaultValue is not null)
{
defaultValueExpression = ValueExpression.Literal(defaultValue).ToBuilder();
}
EnumExpression<SkipQuestionModeWrapper>.Builder? skipModeExpression = null;
if (skipMode is not null)
{
skipModeExpression = EnumExpression<SkipQuestionModeWrapper>.Literal(skipMode).ToBuilder();
}
Question.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
AlwaysPrompt = alwaysPromptExpression,
SkipQuestionMode = skipModeExpression,
Variable = PropertyPath.Create(FormatVariablePath(variableName)),
Prompt = CreateMessageActivity(promptText),
InvalidPrompt = CreateOptionalMessageActivity(invalidResponseText),
UnrecognizedPrompt = CreateOptionalMessageActivity(unrecognizedResponseText),
DefaultValue = defaultValueExpression,
DefaultValueResponse = CreateOptionalMessageActivity(defaultValueResponseText),
RepeatCount = repeatCountExpression,
Entity = entity ?? new StringPrebuiltEntity(),
};
if (autoSend is not null)
{
RecordDataValue.Builder extensionDataBuilder = new();
extensionDataBuilder.Properties.Add("autoSend", autoSend);
actionBuilder.ExtensionData = extensionDataBuilder.Build();
}
return AssignParent<Question>(actionBuilder);
}
private static MessageActivityTemplate.Builder? CreateOptionalMessageActivity(string? text) =>
text is null ? null : CreateMessageActivity(text);
private static MessageActivityTemplate.Builder CreateMessageActivity(string text) =>
new()
{
Text = { TemplateLine.Parse(text) },
};
}
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RequestExternalInputExecutor"/>.
/// </summary>
public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void RequestExternalInputNamingConvention()
{
// Arrange
string testId = this.CreateActionId().Value;
// Act
string inputStep = RequestExternalInputExecutor.Steps.Input(testId);
string captureStep = RequestExternalInputExecutor.Steps.Capture(testId);
// Assert
Assert.Equal($"{testId}_{nameof(RequestExternalInputExecutor.Steps.Input)}", inputStep);
Assert.Equal($"{testId}_{nameof(RequestExternalInputExecutor.Steps.Capture)}", captureStep);
}
[Fact]
public async Task ExecuteRequestsExternalInputAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
displayName: nameof(ExecuteRequestsExternalInputAsync),
variableName: "TestVariable");
}
[Fact]
public async Task CaptureResponseWithVariableAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithVariableAsync),
variableName: "TestVariable");
}
[Fact]
public async Task CaptureResponseWithoutVariableAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithoutVariableAsync),
variableName: null);
}
[Fact]
public async Task CaptureResponseWithMultipleMessagesAsync()
{
// Arrange, Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithMultipleMessagesAsync),
variableName: "TestVariable",
messageCount: 3);
}
[Fact]
public async Task CaptureResponseWithWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithWorkflowConversationAsync),
variableName: "TestVariable",
messageCount: 2,
expectMessagesCreated: true);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAsync()
{
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAsync),
variableName: "TestVariable",
messageCount: 0);
}
[Fact]
public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync()
{
// Arrange
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
// Act & Assert
await this.CaptureResponseTestAsync(
displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync),
variableName: "TestVariable",
messageCount: 0,
expectMessagesCreated: false);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)
{
MockAgentProvider mockAgentProvider = new();
RequestExternalInput model = this.CreateModel(displayName, variableName);
RequestExternalInputExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false);
// Assert
VerifyModel(model, action);
VerifyInvocationEvent(events);
}
private async Task CaptureResponseTestAsync(
string displayName,
string? variableName = null,
int messageCount = 1,
bool expectMessagesCreated = false)
{
// Arrange
RequestExternalInput model = this.CreateModel(displayName, variableName);
MockAgentProvider mockAgentProvider = new();
RequestExternalInputExecutor action = new(model, mockAgentProvider.Object, this.State);
// Create test messages
List<ChatMessage> testMessages = [];
for (int i = 0; i < messageCount; i++)
{
testMessages.Add(new ChatMessage(ChatRole.User, $"Test message {i + 1}"));
}
ExternalInputResponse response = new(testMessages);
// Act
WorkflowEvent[] events =
await this.ExecuteAsync(
RequestExternalInputExecutor.Steps.Capture(action.Id),
(context, message, cancellationToken) => action.CaptureResponseAsync(context, response, cancellationToken));
// Assert
VerifyModel(model, action);
VerifyCompletionEvent(events);
// Verify messages were created in the workflow conversation if expected
mockAgentProvider.Verify(p => p.CreateMessageAsync(
It.IsAny<string>(),
It.IsAny<ChatMessage>(),
It.IsAny<CancellationToken>()), Times.Exactly(expectMessagesCreated ? messageCount : 0));
// Verify the variable was set correctly
if (variableName is not null)
{
this.VerifyState(variableName, testMessages.ToTable());
}
}
private RequestExternalInput CreateModel(string displayName, string? variablePath)
{
RequestExternalInput.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = variablePath is null ? null : (InitializablePropertyPath?)PropertyPath.Create(FormatVariablePath(variablePath)),
};
return AssignParent<RequestExternalInput>(actionBuilder);
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="ResetVariableExecutor"/>.
/// </summary>
public sealed class ResetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task ResetDefinedValueAsync()
{
// Arrange
this.State.Set("MyVar1", FormulaValue.New("Value #1"));
this.State.Set("MyVar2", FormulaValue.New("Value #2"));
ResetVariable model =
this.CreateModel(
this.FormatDisplayName(nameof(ResetDefinedValueAsync)),
FormatVariablePath("MyVar1"));
// Act
ResetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("MyVar1");
this.VerifyState("MyVar2", FormulaValue.New("Value #2"));
}
[Fact]
public async Task ResetUndefinedValueAsync()
{
// Arrange
this.State.Set("MyVar1", FormulaValue.New("Value #1"));
ResetVariable model =
this.CreateModel(
this.FormatDisplayName(nameof(ResetUndefinedValueAsync)),
FormatVariablePath("NoVar"));
// Act
ResetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined("NoVar");
this.VerifyState("MyVar1", FormulaValue.New("Value #1"));
}
private ResetVariable CreateModel(string displayName, string variablePath)
{
ResetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(variablePath),
};
return AssignParent<ResetVariable>(actionBuilder);
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RetrieveConversationMessageExecutor"/>.
/// </summary>
public sealed class RetrieveConversationMessageExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task RetrieveMessageSuccessfullyAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(nameof(RetrieveMessageSuccessfullyAsync),
"TestMessage");
}
private async Task ExecuteTestAsync(
string displayName,
string variableName)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
RetrieveConversationMessage model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
"TestConversationId",
"DefaultMessageId");
RetrieveConversationMessageExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
ChatMessage? testMessage = mockAgentProvider.TestMessages?.FirstOrDefault();
Assert.NotNull(testMessage);
VerifyModel(model, action);
this.VerifyState(variableName, testMessage.ToRecord());
}
private RetrieveConversationMessage CreateModel(
string displayName,
string messageVariable,
string conversationId,
string messageId)
{
RetrieveConversationMessage.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Message = PropertyPath.Create(messageVariable),
ConversationId = StringExpression.Literal(conversationId),
MessageId = StringExpression.Literal(messageId)
};
return AssignParent<RetrieveConversationMessage>(actionBuilder);
}
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="RetrieveConversationMessagesExecutor"/>.
/// </summary>
public sealed class RetrieveConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task RetrieveAllMessagesSuccessfullyAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
nameof(RetrieveAllMessagesSuccessfullyAsync),
"TestMessages",
"TestConversationId");
}
[Fact]
public async Task RetrieveMessagesWithOptionalValuesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
nameof(RetrieveMessagesWithOptionalValuesAsync),
"TestMessages",
"TestConversationId",
limit: IntExpression.Literal(2),
after: StringExpression.Literal("11/01/2025"),
before: StringExpression.Literal("12/01/2025"),
sortOrder: EnumExpression<AgentMessageSortOrderWrapper>.Literal(AgentMessageSortOrderWrapper.Get(AgentMessageSortOrder.NewestFirst)));
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
string conversationId,
IntExpression? limit = null,
StringExpression? after = null,
StringExpression? before = null,
EnumExpression<AgentMessageSortOrderWrapper>? sortOrder = null)
{
// Arrange
MockAgentProvider mockAgentProvider = new();
RetrieveConversationMessages model = this.CreateModel(
this.FormatDisplayName(displayName),
FormatVariablePath(variableName),
conversationId,
limit,
after,
before,
sortOrder);
RetrieveConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
var testMessages = mockAgentProvider.TestMessages;
Assert.NotNull(testMessages);
VerifyModel(model, action);
this.VerifyState(variableName, testMessages.ToTable());
}
private RetrieveConversationMessages CreateModel(
string displayName,
string variableName,
string conversationId,
IntExpression? limit,
StringExpression? after,
StringExpression? before,
EnumExpression<AgentMessageSortOrderWrapper>? sortOrder)
{
RetrieveConversationMessages.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Messages = PropertyPath.Create(variableName),
ConversationId = StringExpression.Literal(conversationId)
};
if (limit is not null)
{
actionBuilder.Limit = limit;
}
if (after is not null)
{
actionBuilder.MessageAfter = after;
}
if (before is not null)
{
actionBuilder.MessageBefore = before;
}
if (sortOrder is not null)
{
actionBuilder.SortOrder = sortOrder;
}
return AssignParent<RetrieveConversationMessages>(actionBuilder);
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SendActivityExecutor"/>.
/// </summary>
public sealed class SendActivityExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task CaptureActivityAsync()
{
// Arrange
SendActivity model =
this.CreateModel(
this.FormatDisplayName(nameof(CaptureActivityAsync)),
"Test activity message");
// Act
SendActivityExecutor action = new(model, this.State);
WorkflowEvent[] events = await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.Contains(events, e => e is MessageActivityEvent);
// The executor must also emit an AgentResponseEvent carrying the activity text
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
Assert.Equal(action.Id, agentEvent.ExecutorId);
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);
}
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
{
MessageActivityTemplate.Builder activityBuilder =
new()
{
Summary = summary,
Text = { TemplateLine.Parse(activityMessage) },
};
SendActivity.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Activity = activityBuilder.Build(),
};
return AssignParent<SendActivity>(actionBuilder);
}
}
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetMultipleVariablesExecutor"/>.
/// </summary>
public sealed class SetMultipleVariablesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task SetMultipleVariablesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesAsync),
assignments: [
new AssignmentCase("Variable1", new NumberDataValue(42), FormulaValue.New(42)),
new AssignmentCase("Variable2", new StringDataValue("Test"), FormulaValue.New("Test")),
new AssignmentCase("Variable3", new BooleanDataValue(true), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithExpressionsAsync()
{
// Arrange
this.State.Set("SourceNumber", FormulaValue.New(10));
this.State.Set("SourceText", FormulaValue.New("Hello"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithExpressionsAsync),
assignments: [
new AssignmentCase("CalcVariable", ValueExpression.Expression("Local.SourceNumber * 2"), FormulaValue.New(20)),
new AssignmentCase("ConcatVariable", ValueExpression.Expression(@"Concatenate(Local.SourceText, "" World"")"), FormulaValue.New("Hello World")),
new AssignmentCase("BoolVariable", ValueExpression.Expression("Local.SourceNumber > 5"), FormulaValue.New(true))
]);
}
[Fact]
public async Task SetMultipleVariablesWithVariableReferencesAsync()
{
// Arrange
this.State.Set("Source1", FormulaValue.New(123));
this.State.Set("Source2", FormulaValue.New("Reference"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithVariableReferencesAsync),
assignments: [
new AssignmentCase("Target1", ValueExpression.Variable(PropertyPath.TopicVariable("Source1")), FormulaValue.New(123)),
new AssignmentCase("Target2", ValueExpression.Variable(PropertyPath.TopicVariable("Source2")), FormulaValue.New("Reference"))
]);
}
[Fact]
public async Task SetMultipleVariablesWithNullValuesAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithNullValuesAsync),
assignments: [
new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()),
new AssignmentCase("NormalVar", new StringDataValue("NotNull"), FormulaValue.New("NotNull")),
new AssignmentCase("NullVar2", null, FormulaValue.NewBlank())
]);
}
[Fact]
public async Task SetMultipleVariablesWithNullVariableAsync()
{
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesWithNullVariableAsync),
assignments: [
new AssignmentCase("NullVar1", null, FormulaValue.NewBlank()),
new AssignmentCase(null, new StringDataValue("NotNull"), FormulaValue.New("NotNull")),
new AssignmentCase("NullVar2", null, FormulaValue.NewBlank())
]);
}
[Fact]
public async Task SetMultipleVariablesUpdateExistingAsync()
{
// Arrange
this.State.Set("ExistingVar1", FormulaValue.New(999));
this.State.Set("ExistingVar2", FormulaValue.New("OldValue"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetMultipleVariablesUpdateExistingAsync),
assignments: [
new AssignmentCase("ExistingVar1", new NumberDataValue(111), FormulaValue.New(111)),
new AssignmentCase("ExistingVar2", new StringDataValue("NewValue"), FormulaValue.New("NewValue")),
new AssignmentCase("NewVar", new BooleanDataValue(false), FormulaValue.New(false))
]);
}
[Fact]
public async Task SetMultipleVariablesEmptyAssignmentsAsync()
{
// Arrange
SetMultipleVariables model = this.CreateModel(nameof(SetMultipleVariablesEmptyAssignmentsAsync), []);
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() =>
{
// Empty variables assignment should fail RequiredProperties validation.
_ = new SetMultipleVariablesExecutor(model, this.State);
});
}
private async Task ExecuteTestAsync(string displayName, AssignmentCase[] assignments)
{
// Arrange
SetMultipleVariables model = this.CreateModel(displayName, assignments);
// Act
SetMultipleVariablesExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
foreach (AssignmentCase assignment in assignments.Where(a => a.VariableName != null))
{
this.VerifyState(assignment.VariableName!, assignment.ExpectedValue);
}
}
private SetMultipleVariables CreateModel(string displayName, AssignmentCase[] assignments)
{
SetMultipleVariables.Builder actionBuilder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
};
foreach (AssignmentCase assignment in assignments)
{
ValueExpression.Builder? valueExpressionBuilder = assignment.ValueExpression switch
{
null => null,
DataValue dataValue => new ValueExpression.Builder(ValueExpression.Literal(dataValue)),
ValueExpression valueExpression => new ValueExpression.Builder(valueExpression),
_ => throw new System.ArgumentException($"Unsupported value type: {assignment.ValueExpression?.GetType().Name}")
};
InitializablePropertyPath? variablePath = null;
if (assignment.VariableName != null)
{
variablePath = PropertyPath.Create(FormatVariablePath(assignment.VariableName));
}
actionBuilder.Assignments.Add(new VariableAssignment.Builder()
{
Variable = variablePath,
Value = valueExpressionBuilder,
});
}
return AssignParent<SetMultipleVariables>(actionBuilder);
}
private sealed record AssignmentCase(string? VariableName, object? ValueExpression, FormulaValue ExpectedValue);
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetTextVariableExecutor"/>.
/// </summary>
public sealed class SetTextVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public async Task SetLiteralValueAsync()
{
// Arrange, Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(SetLiteralValueAsync)),
"TextVar",
"New value");
}
[Fact]
public async Task UpdateExistingValueAsync()
{
// Arrange
this.State.Set("TextVar", FormulaValue.New("Old value"));
// Act & Assert
await this.ExecuteTestAsync(
this.FormatDisplayName(nameof(UpdateExistingValueAsync)),
"TextVar",
"New value");
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
string textValue)
{
// Arrange
SetTextVariable model =
this.CreateModel(
displayName,
variableName,
textValue);
// Act
SetTextVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(variableName, FormulaValue.New(textValue));
}
private SetTextVariable CreateModel(string displayName, string variablePath, string textValue)
{
SetTextVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(FormatVariablePath(variablePath)),
Value = TemplateLine.Parse(textValue),
};
return AssignParent<SetTextVariable>(actionBuilder);
}
}
@@ -0,0 +1,201 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="SetVariableExecutor"/>.
/// </summary>
public sealed class SetVariableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
[Fact]
public void InvalidModel() =>
// Arrange, Act, Assert
Assert.Throws<DeclarativeModelException>(() => new SetVariableExecutor(new SetVariable(), this.State));
[Fact]
public async Task SetNumericValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetNumericValueAsync),
variableName: "TestVariable",
variableValue: new NumberDataValue(42),
expectedValue: FormulaValue.New(42));
[Fact]
public async Task SetStringValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetStringValueAsync),
variableName: "TestVariable",
variableValue: new StringDataValue("Text"),
expectedValue: FormulaValue.New("Text"));
[Fact]
public async Task SetBooleanValueAsync() =>
// Arrange, Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanValueAsync),
variableName: "TestVariable",
variableValue: new BooleanDataValue(true),
expectedValue: FormulaValue.New(true));
[Fact]
public async Task SetBooleanExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("true || false"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(true));
}
[Fact]
public async Task SetNumberExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression("9 - 3"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(6));
}
[Fact]
public async Task SetStringExpressionAsync()
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Expression(@"Concatenate(""A"", ""B"", ""C"")"));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New("ABC"));
}
[Fact]
public async Task SetBooleanVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New(true));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(true));
}
[Fact]
public async Task SetNumberVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New(321));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New(321));
}
[Fact]
public async Task SetStringVariableAsync()
{
// Arrange
this.State.Set("Source", FormulaValue.New("Test"));
ValueExpression.Builder expressionBuilder = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(SetBooleanExpressionAsync),
variableName: "TestVariable",
valueExpression: expressionBuilder,
expectedValue: FormulaValue.New("Test"));
}
[Fact]
public async Task UpdateExistingValueAsync()
{
// Arrange
this.State.Set("VarA", FormulaValue.New(33));
// Act, Assert
await this.ExecuteTestAsync(
displayName: nameof(UpdateExistingValueAsync),
variableName: "VarA",
variableValue: new NumberDataValue(42),
expectedValue: FormulaValue.New(42));
}
private Task ExecuteTestAsync(
string displayName,
string variableName,
DataValue variableValue,
FormulaValue expectedValue)
{
// Arrange
ValueExpression.Builder expressionBuilder = new(ValueExpression.Literal(variableValue));
// Act & Assert
return this.ExecuteTestAsync(displayName, variableName, expressionBuilder, expectedValue);
}
private async Task ExecuteTestAsync(
string displayName,
string variableName,
ValueExpression.Builder valueExpression,
FormulaValue expectedValue)
{
// Arrange
SetVariable model =
this.CreateModel(
displayName,
FormatVariablePath(variableName),
valueExpression);
this.State.Set(variableName, FormulaValue.New(33));
// Act
SetVariableExecutor action = new(model, this.State);
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(variableName, expectedValue);
}
private SetVariable CreateModel(string displayName, string variablePath, ValueExpression.Builder valueExpression)
{
SetVariable.Builder actionBuilder =
new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Variable = PropertyPath.Create(variablePath),
Value = valueExpression,
};
return AssignParent<SetVariable>(actionBuilder);
}
}
@@ -0,0 +1,131 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
using Xunit.Sdk;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Base test class for <see cref="DeclarativeActionExecutor"/> implementations.
/// </summary>
public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : WorkflowTest(output)
{
internal WorkflowFormulaState State { get; } = new(RecalcEngineFactory.Create());
protected ActionId CreateActionId() => new($"{this.GetType().Name}_{Guid.NewGuid():N}");
protected string FormatDisplayName(string name) => $"{this.GetType().Name}_{name}";
internal Task<WorkflowEvent[]> ExecuteAsync(string actionId, DelegateAction<ActionExecutorResult> executorAction) =>
this.ExecuteAsync([new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false);
internal Task<WorkflowEvent[]> ExecuteAsync(Executor executor, string actionId, DelegateAction<ActionExecutorResult> executorAction) =>
this.ExecuteAsync([executor, new DelegateActionExecutor(actionId, this.State, executorAction)], isDiscrete: false);
internal async Task<WorkflowEvent[]> ExecuteAsync(DeclarativeActionExecutor executor, bool isDiscrete = true)
{
VerifyIsDiscrete(executor, isDiscrete);
return await this.ExecuteAsync([executor], isDiscrete);
}
internal async Task<WorkflowEvent[]> ExecuteAsync(Executor[] executors, bool isDiscrete)
{
this.State.Bind();
TestWorkflowExecutor workflowExecutor = new();
WorkflowBuilder workflowBuilder = new(workflowExecutor);
Executor prevExecutor = workflowExecutor;
foreach (Executor executor in executors)
{
workflowBuilder.AddEdge(prevExecutor, executor);
prevExecutor = executor;
}
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflowBuilder.Build(), this.State);
WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync();
if (isDiscrete)
{
VerifyInvocationEvent(events);
VerifyCompletionEvent(events);
}
ExecutorFailedEvent[] failureEvents = events.OfType<ExecutorFailedEvent>().ToArray();
switch (failureEvents.Length)
{
case 0:
break;
case 1:
throw failureEvents[0].Data ?? new XunitException("Executor failed without exception data.");
default:
AggregateException aggregateException = new("One or more executor failures occurred.", failureEvents.Select(e => e.Data).Where(e => e is not null).Cast<Exception>());
throw aggregateException;
}
return events;
}
internal static void VerifyModel(DialogAction model, DeclarativeActionExecutor action)
{
Assert.Equal(model.Id, action.Id);
Assert.Equal(model, action.Model);
}
internal static void VerifyIsDiscrete(DeclarativeActionExecutor action, bool isDiscrete = true)
{
Assert.Equal(
isDiscrete,
action.GetType().BaseType?
.GetProperty("IsDiscreteAction", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
.GetValue(action));
}
protected static void VerifyInvocationEvent(WorkflowEvent[] events) =>
Assert.Contains(events, e => e is DeclarativeActionInvokedEvent);
protected static void VerifyCompletionEvent(WorkflowEvent[] events) =>
Assert.Contains(events, e => e is DeclarativeActionCompletedEvent);
protected void VerifyState(string variableName, FormulaValue expectedValue) => this.VerifyState(variableName, WorkflowFormulaState.DefaultScopeName, expectedValue);
protected void VerifyState(string variableName, string scopeName, FormulaValue expectedValue)
{
FormulaValue actualValue = this.State.Get(variableName, scopeName);
Assert.Equal(expectedValue.Format(), actualValue.Format());
}
protected void VerifyUndefined(string variableName, string? scopeName = null) =>
Assert.IsType<BlankValue>(this.State.Get(variableName, scopeName));
protected static TAction AssignParent<TAction>(DialogAction.Builder actionBuilder) where TAction : DialogAction
{
OnActivity.Builder activityBuilder =
new()
{
Id = new("root"),
};
activityBuilder.Actions.Add(actionBuilder);
OnActivity model = activityBuilder.Build();
return (TAction)model.Actions[0];
}
internal sealed class TestWorkflowExecutor() : Executor<WorkflowFormulaState>("test_workflow")
{
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context, CancellationToken cancellationToken) =>
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions;
public sealed class AgentMessageTests
{
[Fact]
public void Construct_Function()
{
AgentMessage function = new();
Assert.NotNull(function);
}
[Fact]
public void Execute_ReturnsBlank_ForEmptyInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = AgentMessage.Execute(sourceValue);
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void Execute_ReturnsExpectedRecord_ForNonEmptyInput()
{
const string Text = "Hello";
FormulaValue sourceValue = FormulaValue.New(Text);
StringValue stringValue = Assert.IsType<StringValue>(sourceValue);
FormulaValue result = AgentMessage.Execute(stringValue);
RecordValue recordResult = Assert.IsType<RecordValue>(result, exactMatch: false);
// Discriminator
FormulaValue discriminator = recordResult.GetField(TypeSchema.Discriminator);
StringValue discriminatorValue = Assert.IsType<StringValue>(discriminator);
Assert.Equal(nameof(ChatMessage), discriminatorValue.Value);
// Role
FormulaValue role = recordResult.GetField(TypeSchema.Message.Fields.Role);
StringValue roleValue = Assert.IsType<StringValue>(role);
Assert.Equal(ChatRole.Assistant.Value, roleValue.Value);
// Content table
FormulaValue content = recordResult.GetField(TypeSchema.Message.Fields.Content);
TableValue table = Assert.IsType<TableValue>(content, exactMatch: false);
List<RecordValue> rows = table.Rows.Select(value => value.Value).ToList();
Assert.Single(rows);
StringValue contentType = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.MessageContent.Fields.Type));
Assert.Equal(TypeSchema.MessageContent.ContentTypes.Text, contentType.Value);
StringValue contentValue = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.MessageContent.Fields.Value));
Assert.Equal(Text, contentValue.Value);
}
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions;
public sealed class MessageTextTests
{
[Fact]
public void Construct_Function()
{
MessageText.StringInput function1 = new();
Assert.NotNull(function1);
MessageText.RecordInput function2 = new();
Assert.NotNull(function2);
MessageText.TableInput function3 = new();
Assert.NotNull(function3);
}
[Fact]
public void Execute_ReturnsEmpty_ForEmptyInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = MessageText.StringInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForStringInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New("wowsie");
// Act
FormulaValue result = MessageText.StringInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal(sourceValue.Value, stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForMessageInput()
{
// Arrange
RecordValue sourceValue = new ChatMessage(ChatRole.User, "test message").ToRecord();
// Act
FormulaValue result = MessageText.RecordInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal("test message", stringResult.Value);
}
[Fact]
public void Execute_ReturnsEmpty_ForUnknownInput()
{
// Arrange
RecordValue sourceValue = FormulaValue.NewRecordFromFields(new NamedValue("Anything", FormulaValue.New(333)));
// Act
FormulaValue result = MessageText.RecordInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForMessagesInput()
{
// Arrange
TableValue sourceValue = new ChatMessage[]
{
new(ChatRole.User, "test message 1"),
new(ChatRole.User, "test message 2"),
}.ToTable();
// Act
FormulaValue result = MessageText.TableInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal("test message 1\ntest message 2", stringResult.Value);
}
[Fact]
public void Execute_ReturnsEmpty_ForEmptyList()
{
// Arrange
TableValue sourceValue = Array.Empty<ChatMessage>().ToTable();
// Act
FormulaValue result = MessageText.TableInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions;
public class UserMessageTests
{
[Fact]
public void Construct_Function()
{
UserMessage function = new();
Assert.NotNull(function);
}
[Fact]
public void Execute_ReturnsBlank_ForEmptyInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = UserMessage.Execute(sourceValue);
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void Execute_ReturnsExpectedRecord_ForNonEmptyInput()
{
const string Text = "Hello";
FormulaValue sourceValue = FormulaValue.New(Text);
StringValue stringValue = Assert.IsType<StringValue>(sourceValue);
FormulaValue result = UserMessage.Execute(stringValue);
RecordValue recordResult = Assert.IsType<RecordValue>(result, exactMatch: false);
// Discriminator
FormulaValue discriminator = recordResult.GetField(TypeSchema.Discriminator);
StringValue discriminatorValue = Assert.IsType<StringValue>(discriminator);
Assert.Equal(nameof(ChatMessage), discriminatorValue.Value);
// Role
FormulaValue role = recordResult.GetField(TypeSchema.Message.Fields.Role);
StringValue roleValue = Assert.IsType<StringValue>(role);
Assert.Equal(ChatRole.User.Value, roleValue.Value);
// Content table
FormulaValue content = recordResult.GetField(TypeSchema.Message.Fields.Content);
TableValue table = Assert.IsType<TableValue>(content, exactMatch: false);
List<RecordValue> rows = table.Rows.Select(value => value.Value).ToList();
Assert.Single(rows);
StringValue contentType = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.MessageContent.Fields.Type));
Assert.Equal(TypeSchema.MessageContent.ContentTypes.Text, contentType.Value);
StringValue contentValue = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.MessageContent.Fields.Value));
Assert.Equal(Text, contentValue.Value);
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.PowerFx;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx;
public class RecalcEngineFactoryTests(ITestOutputHelper output) : WorkflowTest(output)
{
[Fact]
public void DefaultNotNull()
{
// Act
RecalcEngine engine = RecalcEngineFactory.Create();
// Assert
Assert.NotNull(engine);
}
[Fact]
public void NewInstanceEachTime()
{
// Act
RecalcEngine engine1 = RecalcEngineFactory.Create();
RecalcEngine engine2 = RecalcEngineFactory.Create();
// Assert
Assert.NotNull(engine1);
Assert.NotNull(engine2);
Assert.NotSame(engine1, engine2);
}
[Fact]
public void HasSetFunctionEnabled()
{
// Arrange
RecalcEngine engine = RecalcEngineFactory.Create();
// Act
CheckResult result = engine.Check("1+1");
// Assert
Assert.True(result.IsSuccess);
}
[Fact]
public void HasCorrectMaximumExpressionLength()
{
// Arrange
RecalcEngine engine = RecalcEngineFactory.Create(2000, 3);
// Assert
Assert.Equal(2000, engine.Config.MaximumExpressionLength);
Assert.Equal(3, engine.Config.MaxCallDepth);
// Act: Create a long expression that is within the limit
string goodExpression = string.Concat(GenerateExpression(999));
CheckResult goodResult = engine.Check(goodExpression);
// Assert
Assert.True(goodResult.IsSuccess);
// Act: Create a long expression that exceeds the limit
string longExpression = string.Concat(GenerateExpression(1001));
CheckResult longResult = engine.Check(longExpression);
// Assert
Assert.False(longResult.IsSuccess);
static IEnumerable<string> GenerateExpression(int elements)
{
yield return "1";
for (int i = 0; i < elements - 1; i++)
{
yield return "+1";
}
}
}
}
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.PowerFx;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx;
/// <summary>
/// Base test class for PowerFx engine tests.
/// </summary>
public abstract class RecalcEngineTest(ITestOutputHelper output) : WorkflowTest(output)
{
internal WorkflowFormulaState State { get; } = new(RecalcEngineFactory.Create());
protected RecalcEngine Engine => this.State.Engine;
}
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx;
public class TemplateExtensionsTests(ITestOutputHelper output) : RecalcEngineTest(output)
{
[Fact]
public void FormatTemplateLines()
{
// Arrange
List<TemplateLine> template =
[
TemplateLine.Parse("Hello"),
TemplateLine.Parse(" "),
TemplateLine.Parse("World"),
];
// Act
string? result = this.Engine.Format(template);
// Assert
Assert.Equal("Hello World", result);
}
[Fact]
public void FormatTemplateLinesEmpty()
{
// Arrange
List<TemplateLine> template = [];
// Act
string? result = this.Engine.Format(template);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void FormatTemplateLine()
{
// Arrange
TemplateLine line = TemplateLine.Parse("Test");
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal("Test", result);
}
[Fact]
public void FormatTemplateLineNull()
{
// Arrange
TemplateLine? line = null;
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void FormatTextSegment()
{
// Arrange
TemplateSegment textSegment = TemplateSegment.FromText("Hello World");
TemplateLine line = new([textSegment]);
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal("Hello World", result);
}
[Fact]
public void FormatExpressionSegment()
{
// Arrange
ExpressionSegment expressionSegment = new(ValueExpression.Expression("1 + 1"));
TemplateLine line = new([expressionSegment]);
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal("2", result);
}
[Fact]
public void FormatVariableSegment()
{
// Arrange
this.State.Set("Source", FormulaValue.New("Hello World"));
this.State.Bind();
ExpressionSegment expressionSegment = new(ValueExpression.Variable(PropertyPath.TopicVariable("Source")));
TemplateLine line = new([expressionSegment]);
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal("Hello World", result);
}
[Fact]
public void FormatExpressionSegmentUndefined()
{
// Arrange
ExpressionSegment expressionSegment = new();
TemplateLine line = new([expressionSegment]);
// Act & Assert
Assert.Throws<DeclarativeModelException>(() => this.Engine.Format(line));
}
[Fact]
public void FormatMultipleSegments()
{
// Arrange
TemplateSegment textSegment = TemplateSegment.FromText("Hello ");
ExpressionSegment expressionSegment = new(ValueExpression.Expression(@"""World"""));
TemplateLine line = new([textSegment, expressionSegment]);
// Act
string? result = this.Engine.Format(line);
// Assert
Assert.Equal("Hello World", result);
}
}
@@ -0,0 +1,551 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Immutable;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Agents.ObjectModel.Abstractions;
using Microsoft.Agents.ObjectModel.Exceptions;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx;
public class WorkflowExpressionEngineTests : RecalcEngineTest
{
private static class Variables
{
public const string GlobalValue = nameof(GlobalValue);
public const string BoolValue = nameof(BoolValue);
public const string StringValue = nameof(StringValue);
public const string IntValue = nameof(IntValue);
public const string NumberValue = nameof(NumberValue);
public const string EnumValue = nameof(EnumValue);
public const string ObjectValue = nameof(ObjectValue);
public const string ArrayValue = nameof(ArrayValue);
public const string BlankValue = nameof(BlankValue);
}
public static readonly RecordValue ObjectData = FormulaValue.NewRecordFromFields(new NamedValue(nameof(EnvironmentVariableReference.SchemaName), FormulaValue.New("test")));
public static readonly TableValue TableData = FormulaValue.NewSingleColumnTable(FormulaValue.New("a"), FormulaValue.New("b"));
public WorkflowExpressionEngineTests(ITestOutputHelper output)
: base(output)
{
this.State.Set(Variables.GlobalValue, FormulaValue.New(255), VariableScopeNames.Global);
this.State.Set(Variables.BoolValue, FormulaValue.New(true));
this.State.Set(Variables.StringValue, FormulaValue.New("Hello World"));
this.State.Set(Variables.IntValue, FormulaValue.New(long.MaxValue));
this.State.Set(Variables.NumberValue, FormulaValue.New(33.3));
this.State.Set(Variables.EnumValue, FormulaValue.New(nameof(VariablesToClear.ConversationScopedVariables)));
this.State.Set(Variables.ObjectValue, ObjectData);
this.State.Set(Variables.ArrayValue, TableData);
this.State.Set(Variables.BlankValue, FormulaValue.NewBlank());
this.State.Bind();
}
#region BoolExpression Tests
[Fact]
public void BoolExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<ArgumentNullException>((BoolExpression)null!);
[Fact]
public void BoolExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<InvalidExpressionOutputTypeException>(BoolExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)));
[Fact]
public void BoolExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
BoolExpression.Literal(true),
expectedValue: true);
[Fact]
public void BoolExpressionGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: false);
[Fact]
public void BoolExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
BoolExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue)),
expectedValue: true);
}
[Fact]
public void BoolExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
BoolExpression.Expression("true || false"),
expectedValue: true);
#endregion
#region StringExpression Tests
[Fact]
public void StringExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<ArgumentNullException>((StringExpression)null!);
[Fact]
public void StringExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<InvalidExpressionOutputTypeException>(StringExpression.Variable(PropertyPath.TopicVariable(Variables.BoolValue)));
[Fact]
public void StringExpressionGetValueForStringExpressionBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
StringExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: string.Empty);
[Fact]
public void StringExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
StringExpression.Literal("test"),
expectedValue: "test");
[Fact]
public void StringExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
StringExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)),
expectedValue: "Hello World");
}
[Fact]
public void StringExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
StringExpression.Expression(@"""A"" & ""B"""),
expectedValue: "AB");
[Fact]
public void StringExpressionGetValueForRecord()
{
// Arrange
RecordValue state = FormulaValue.NewRecordFromFields([new NamedValue("test", FormulaValue.New("value"))]);
this.State.Set("TestRecord", state, VariableScopeNames.Global);
this.State.Bind();
// Arrange, Act & Assert
this.EvaluateExpression(
StringExpression.Variable(PropertyPath.Create("Global.TestRecord")),
expectedValue:
"""
{
"test": "value"
}
""".Replace("\n", Environment.NewLine));
}
#endregion
#region IntExpression Tests
[Fact]
public void IntExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<ArgumentNullException>((IntExpression)null!);
[Fact]
public void IntExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<InvalidExpressionOutputTypeException>(IntExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)));
[Fact]
public void IntExpressionGetValueForIntExpressionBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
IntExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: 0);
[Fact]
public void IntExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
IntExpression.Literal(7),
expectedValue: 7);
[Fact]
public void IntExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
IntExpression.Variable(PropertyPath.TopicVariable(Variables.IntValue)),
expectedValue: long.MaxValue);
}
[Fact]
public void IntExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
IntExpression.Expression("1 + 6"),
expectedValue: 7);
#endregion
#region NumberExpression Tests
[Fact]
public void NumberExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<ArgumentNullException>((NumberExpression)null!);
[Fact]
public void NumberExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<InvalidExpressionOutputTypeException>(NumberExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)));
[Fact]
public void NumberExpressionGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
NumberExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: 0);
[Fact]
public void NumberExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
NumberExpression.Literal(3.14),
expectedValue: 3.14);
[Fact]
public void NumberExpressionGetValueForVariable() =>
// Arrange, Act & Assert
this.EvaluateExpression(
NumberExpression.Variable(PropertyPath.TopicVariable(Variables.NumberValue)),
expectedValue: 33.3);
[Fact]
public void NumberExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
NumberExpression.Expression("31.1 + 2.2"),
expectedValue: 33.3);
#endregion
#region DataValueExpression Tests
[Fact]
public void DataValueExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<ArgumentNullException>((ValueExpression)null!);
[Fact]
public void DataValueExpressionGetValueForDataValueExpressionBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ValueExpression.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: DataValue.Blank());
[Fact]
public void DataValueExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ValueExpression.Literal(DataValue.Create("test")),
expectedValue: DataValue.Create("test"));
[Fact]
public void DataValueExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
ValueExpression.Variable(PropertyPath.TopicVariable(Variables.StringValue)),
expectedValue: DataValue.Create("Hello World"));
}
[Fact]
public void DataValueExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ValueExpression.Expression(@"""A"" & ""B"""),
expectedValue: DataValue.Create("AB"));
#endregion
#region EnumExpression Tests
[Fact]
public void EnumExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<VariablesToClearWrapper, ArgumentNullException>((EnumExpression<VariablesToClearWrapper>)null!);
[Fact]
public void EnumExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<VariablesToClearWrapper, InvalidExpressionOutputTypeException>(EnumExpression<VariablesToClearWrapper>.Variable(PropertyPath.TopicVariable(Variables.BoolValue)));
[Fact]
public void EnumExpressionGetValueForLiteral() =>
// Arrange, Act & Assert
this.EvaluateExpression(
EnumExpression<VariablesToClearWrapper>.Literal(VariablesToClearWrapper.Get(VariablesToClear.ConversationScopedVariables)),
expectedValue: VariablesToClear.ConversationScopedVariables);
[Fact]
public void EnumExpressionGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
EnumExpression<VariablesToClearWrapper>.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: VariablesToClear.ConversationScopedVariables);
[Fact]
public void EnumExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
EnumExpression<VariablesToClearWrapper>.Variable(PropertyPath.TopicVariable(Variables.EnumValue)),
expectedValue: VariablesToClear.ConversationScopedVariables);
}
[Fact]
public void EnumExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
EnumExpression<VariablesToClearWrapper>.Expression(@"""ConversationScoped"" & ""Variables"""),
expectedValue: VariablesToClear.ConversationScopedVariables);
#endregion
#region ObjectExpression Tests
[Fact]
public void ObjectExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<RecordDataValue, ArgumentNullException>((ObjectExpression<RecordDataValue>)null!);
[Fact]
public void ObjectExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<RecordDataValue, InvalidExpressionOutputTypeException>(ObjectExpression<RecordDataValue>.Variable(PropertyPath.TopicVariable(Variables.BoolValue)));
[Fact]
public void ObjectExpressionGetValueForLiteral()
{
// Arrange, Act & Assert
RecordDataValue.Builder recordBuilder = new();
recordBuilder.Properties.Add(nameof(EnvironmentVariableReference.SchemaName), new StringDataValue("test"));
RecordDataValue objectRecord = recordBuilder.Build();
_ = new EnvironmentVariableReference.Builder() { SchemaName = "test" }.Build();
this.EvaluateExpression(
ObjectExpression<RecordDataValue>.Literal(objectRecord),
expectedValue: objectRecord);
}
[Fact]
public void ObjectExpressionGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ObjectExpression<RecordDataValue>.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: null);
[Fact]
public void ObjectExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
ObjectExpression<RecordDataValue>.Variable(PropertyPath.TopicVariable(Variables.ObjectValue)),
expectedValue: ObjectData.ToRecord());
}
#endregion
#region ArrayExpression Tests
[Fact]
public void ArrayExpressionGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<string, ArgumentNullException>((ArrayExpression<string>)null!);
[Fact]
public void ArrayExpressionGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<string, InvalidExpressionOutputTypeException>(ArrayExpression<string>.Variable(PropertyPath.TopicVariable(Variables.BoolValue)));
[Fact]
public void ArrayExpressionGetValueForLiteral()
{
// Arrange, Act & Assert
string[] input = ["a", "b"];
this.EvaluateExpression(
ArrayExpression<string>.Literal(input.ToImmutableArray()),
expectedValue: input);
}
[Fact]
public void ArrayExpressionGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpression<string>.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: []);
[Fact]
public void ArrayExpressionGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpression<string>.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)),
expectedValue: ["a", "b"]);
}
[Fact]
public void ArrayExpressionGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpression<string>.Expression(@"[""a"", ""b""]"),
expectedValue: ["a", "b"]);
#endregion
#region ArrayExpressionOnly Tests
[Fact]
public void ArrayExpressionOnlyGetValueForNull() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<string, ArgumentNullException>((ArrayExpressionOnly<string>)null!);
[Fact]
public void ArrayExpressionOnlyGetValueForInvalid() =>
// Arrange, Act & Assert
this.EvaluateInvalidExpression<string, InvalidExpressionOutputTypeException>(ArrayExpressionOnly<string>.Variable(PropertyPath.TopicVariable(Variables.BoolValue)));
[Fact]
public void ArrayExpressionOnlyGetValueForBlank() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpressionOnly<string>.Variable(PropertyPath.TopicVariable(Variables.BlankValue)),
expectedValue: []);
[Fact]
public void ArrayExpressionOnlyGetValueForVariable()
{
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpressionOnly<string>.Variable(PropertyPath.TopicVariable(Variables.ArrayValue)),
expectedValue: ["a", "b"]);
}
[Fact]
public void ArrayExpressionOnlyGetValueForFormula() =>
// Arrange, Act & Assert
this.EvaluateExpression(
ArrayExpressionOnly<string>.Expression(@"[""a"", ""b""]"),
expectedValue: ["a", "b"]);
#endregion
private EvaluationResult<bool> EvaluateExpression(BoolExpression expression, bool expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TException>(BoolExpression expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<string> EvaluateExpression(StringExpression expression, string expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TException>(StringExpression expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<long> EvaluateExpression(IntExpression expression, long expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TException>(IntExpression expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<double> EvaluateExpression(NumberExpression expression, double expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TException>(NumberExpression expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<DataValue> EvaluateExpression(ValueExpression expression, DataValue expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TException>(ValueExpression expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<TEnum> EvaluateExpression<TEnum>(EnumExpression<TEnum> expression, TEnum expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
where TEnum : EnumWrapper
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TEnum, TException>(EnumExpression<TEnum> expression)
where TEnum : EnumWrapper
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<TValue?> EvaluateExpression<TValue>(ObjectExpression<TValue> expression, TValue? expectedValue, SensitivityLevel expectedSensitivity = SensitivityLevel.None)
where TValue : BotElement
=> this.EvaluateExpression((evaluator) => evaluator.GetValue(expression), expectedValue, expectedSensitivity);
private void EvaluateInvalidExpression<TValue, TException>(ObjectExpression<TValue> expression)
where TValue : BotElement
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private ImmutableArray<TValue> EvaluateExpression<TValue>(ArrayExpression<TValue> expression, TValue[] expectedValue)
=> this.EvaluateArrayExpression((evaluator) => evaluator.GetValue(expression), expectedValue);
private void EvaluateInvalidExpression<TValue, TException>(ArrayExpression<TValue> expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private ImmutableArray<TValue> EvaluateExpression<TValue>(ArrayExpressionOnly<TValue> expression, TValue[] expectedValue)
=> this.EvaluateArrayExpression((evaluator) => evaluator.GetValue(expression), expectedValue);
private void EvaluateInvalidExpression<TValue, TException>(ArrayExpressionOnly<TValue> expression)
where TException : Exception
=> this.EvaluateInvalidExpression<TException>((evaluator) => evaluator.GetValue(expression));
private EvaluationResult<TValue> EvaluateExpression<TValue>(
Func<WorkflowExpressionEngine, EvaluationResult<TValue>> evaluator,
TValue? expectedValue,
SensitivityLevel expectedSensitivity = SensitivityLevel.None)
{
// Act
EvaluationResult<TValue> result = evaluator.Invoke(this.State.Evaluator);
// Assert
Assert.Equal(expectedValue, result.Value);
Assert.Equal(expectedSensitivity, result.Sensitivity);
return result;
}
private ImmutableArray<TValue> EvaluateArrayExpression<TValue>(
Func<WorkflowExpressionEngine, ImmutableArray<TValue>> evaluator,
TValue[] expectedValue)
{
// Act
ImmutableArray<TValue> result = evaluator.Invoke(this.State.Evaluator);
// Assert
Assert.Equal(expectedValue.Length, result.Length);
Assert.Equivalent(expectedValue, result);
return result;
}
private void EvaluateInvalidExpression<TException>(Action<WorkflowExpressionEngine> evaluator) where TException : Exception
{
// Act & Assert
Assert.Throws<TException>(() => evaluator.Invoke(this.State.Evaluator));
}
}
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx;
public class WorkflowFormulaStateTests
{
internal WorkflowFormulaState State { get; } = new(RecalcEngineFactory.Create());
[Fact]
public void GetWithImplicitScope()
{
// Arrange
FormulaValue testValue = FormulaValue.New("test");
this.State.Set("key1", testValue);
// Act
FormulaValue result = this.State.Get("key1");
// Assert
Assert.Equal(testValue, result);
}
[Fact]
public void GetWithSpecifiedScope()
{
// Arrange
FormulaValue testValue = FormulaValue.New("test");
this.State.Set("key1", testValue, VariableScopeNames.Global);
// Act
FormulaValue result = this.State.Get("key1", VariableScopeNames.Global);
// Assert
Assert.Equal(testValue, result);
}
[Fact]
public void SetDefaultScope()
{
// Arrange
FormulaValue testValue = FormulaValue.New("test");
// Act
this.State.Set("key1", testValue);
// Assert
FormulaValue result = this.State.Get("key1");
Assert.Equal(testValue, result);
}
[Fact]
public void SetSpecifiedScope()
{
// Arrange
FormulaValue testValue = FormulaValue.New("test");
// Act
this.State.Set("key1", testValue, VariableScopeNames.System);
// Assert
FormulaValue result = this.State.Get("key1", VariableScopeNames.System);
Assert.Equal(testValue, result);
}
[Fact]
public void SetOverwritesExistingValue()
{
// Arrange
FormulaValue initialValue = FormulaValue.New("initial");
FormulaValue newValue = FormulaValue.New("new");
// Act
this.State.Set("key1", initialValue);
this.State.Set("key1", newValue);
// Assert
FormulaValue result = this.State.Get("key1");
Assert.Equal(newValue, result);
}
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
public sealed class TestOutputAdapter(ITestOutputHelper output) : TextWriter, ILogger, ILoggerFactory
{
private readonly Stack<string> _scopes = [];
public override Encoding Encoding { get; } = Encoding.UTF8;
public void AddProvider(ILoggerProvider provider) => throw new NotSupportedException();
public ILogger CreateLogger(string categoryName) => this;
public bool IsEnabled(LogLevel logLevel) => true;
public override void WriteLine(object? value) => this.SafeWrite($"{value}");
public override void WriteLine(string? format, params object?[] arg) => this.SafeWrite(string.Format(format ?? string.Empty, arg));
public override void WriteLine(string? value) => this.SafeWrite(value ?? string.Empty);
public override void Write(object? value) => this.SafeWrite($"{value}");
public override void Write(char[]? buffer) => this.SafeWrite(new string(buffer));
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
this._scopes.Push($"{state}");
return new LoggerScope(() => this._scopes.Pop());
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter(state, exception);
string scope = this._scopes.Count > 0 ? $"[{this._scopes.Peek()}] " : string.Empty;
output.WriteLine($"{scope}{message}");
}
private void SafeWrite(string value)
{
try
{
output.WriteLine(value ?? string.Empty);
}
catch (InvalidOperationException exception) when (exception.Message == "There is no currently active test.")
{
// This exception is thrown when the test output is accessed outside of a test context.
// We can ignore it since we are not in a test context.
}
}
private sealed class LoggerScope(Action action) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (!this._disposed)
{
action.Invoke();
this._disposed = true;
}
}
}
}
@@ -0,0 +1,7 @@
$generatedCodeFiles = Get-ChildItem -Name -Path .\bin\Debug\net10.0\Workflows -Filter *.g.cs
Write-Output "x$($generatedCodeFiles.Count)"
foreach ($file in $generatedCodeFiles) {
$baselineFile = $file -replace '\.g\.cs$', '.cs'
Write-Output $baselineFile
Copy-Item -Path ".\bin\Debug\net10.0\Workflows\$file" -Destination ".\Workflows\$baselineFile" -Force
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Base class for workflow tests.
/// </summary>
public abstract class WorkflowTest : IDisposable
{
public TestOutputAdapter Output { get; }
protected WorkflowTest(ITestOutputHelper output)
{
this.Output = new TestOutputAdapter(output);
Console.SetOut(this.Output);
SetProduct();
}
public void Dispose()
{
this.Dispose(isDisposing: true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
if (isDisposing)
{
this.Output.Dispose();
}
}
protected static void SetProduct()
{
if (!ProductContext.IsLocalScopeSupported())
{
ProductContext.SetContext(Product.Foundry);
}
}
internal static string? FormatOptionalPath(string? variableName, string? scope = null) =>
variableName is null ? null : FormatVariablePath(variableName, scope);
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
}
@@ -0,0 +1,119 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class WorkflowTestRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("workflow_test_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("MyMessage1", UnassignedValue.Instance, "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync("TestInput", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Adds a new message to the specified agent conversation
/// </summary>
internal sealed class AddMessageExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "add_message", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string? conversationId = await context.ReadStateAsync<string>(key: "ConversationId", scopeName: "System").ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(conversationId))
{
throw new DeclarativeActionException($"Conversation identifier must be defined: {this.Id}");
}
ChatMessage newMessage = new(ChatRole.User, await this.GetContentAsync(context).ConfigureAwait(false)) { AdditionalProperties = this.GetMetadata() };
newMessage = await agentProvider.CreateMessageAsync(conversationId, newMessage, cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "MyMessage1", value: newMessage, scopeName: "Local").ConfigureAwait(false);
return default;
}
private async ValueTask<IList<AIContent>> GetContentAsync(IWorkflowContext context)
{
List<AIContent> content = [];
string contentValue1 =
await context.FormatTemplateAsync(
"""
{Local.TestInput}
""");
content.Add(new TextContent(contentValue1));
return content;
}
private AdditionalPropertiesDictionary? GetMetadata()
{
Dictionary<string, object?>? metadata = null;
if (metadata is null)
{
return null;
}
return new AdditionalPropertiesDictionary(metadata);
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
WorkflowTestRootExecutor<TInput> workflowTestRoot = new(options, inputTransform);
DelegateExecutor workflowTest = new(id: "workflow_test", workflowTestRoot.Session);
AddMessageExecutor addMessage = new(workflowTestRoot.Session, options.AgentProvider);
// Define the workflow builder
WorkflowBuilder builder = new(workflowTestRoot);
// Connect executors
builder.AddEdge(workflowTestRoot, workflowTest);
builder.AddEdge(workflowTest, addMessage);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,15 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: AddConversationMessage
id: add_message
message: Local.MyMessage1
role: User
conversationId: =System.ConversationId
content:
- type: Text
value: {Local.TestInput}
@@ -0,0 +1,4 @@
# empty yaml
- id: 1
- id: 2
- id: 3
@@ -0,0 +1,8 @@
kind: Workflow
trigger:
kind: OnConversationStart
actions:
- kind: EndConversation
id: end_all
@@ -0,0 +1,8 @@
kind: ToolDialog
beginDialog:
kind: OnActivity
id: my_workflow
type: Message
actions:
- kind: EndConversation
id: end_all
@@ -0,0 +1,94 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity1Executor(FormulaSession session) : ActionExecutor(id: "send_activity_1", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 1!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor endAllRestart = new(id: "end_all_Restart", myWorkflowRoot.Session);
SendActivity1Executor sendActivity1 = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, endAll);
builder.AddEdge(endAllRestart, sendActivity1);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,13 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: CancelWorkflow
id: end_all
- kind: SendActivity
id: send_activity_1
activity: NEVER 1!
@@ -0,0 +1,29 @@
kind: WORKFLOW
trigger:
kind: onconversationstart
id: my_workflow
actions:
- kind: SETVARIABLE
id: set_input1
variable: Local.TestValue1
value: =3
- kind: setvariable
id: set_input2
variable: Local.TestValue2
value: =4
- kind: ConditionGroup
id: condition_test
conditions:
- id: condition_match
condition: =Local.TestValue1 + Local.TestValue2 = 7
actions:
- kind: EndWorkflow
id: end_when_match
- kind: SendActivity
id: activity_error
activity: Unexpected
@@ -0,0 +1,85 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Reset all the state for the targeted variable scope.
/// </summary>
internal sealed class ClearAllExecutor(FormulaSession session) : ActionExecutor(id: "clear_all", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string? targetScopeName = "Local";
await context.QueueClearScopeAsync(targetScopeName).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
ClearAllExecutor clearAll = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, clearAll);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,11 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: ClearAllVariables
id: clear_all
variables: ConversationScopedVariables
@@ -0,0 +1,201 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("TestValue", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.TestValue" variable.
/// </summary>
internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_test", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("Value(System.LastMessageText)").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Conditional branching similar to an if / elseif / elseif / else chain.
/// </summary>
internal sealed class ConditiongroupTestExecutor(FormulaSession session) : ActionExecutor(id: "conditionGroup_test", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
bool condition0 = await context.EvaluateValueAsync<bool>("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false);
if (condition0)
{
return "conditionItem_odd";
}
bool condition1 = await context.EvaluateValueAsync<bool>("Mod(Local.TestValue, 2) = 0").ConfigureAwait(false);
if (condition1)
{
return "conditionItem_even";
}
return "conditionGroup_testElseActions";
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendactivityOddExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_odd", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
ODD
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendactivityEvenExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_even", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
EVEN
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class ActivityFinalExecutor(FormulaSession session) : ActionExecutor(id: "activity_final", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
All done!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
SetvariableTestExecutor setVariableTest = new(myWorkflowRoot.Session);
ConditiongroupTestExecutor conditionGroupTest = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemOdd = new(id: "conditionItem_odd", myWorkflowRoot.Session);
DelegateExecutor conditionItemEven = new(id: "conditionItem_even", myWorkflowRoot.Session);
DelegateExecutor conditionItemOddactions = new(id: "conditionItem_oddActions", myWorkflowRoot.Session);
SendactivityOddExecutor sendActivityOdd = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemEvenactions = new(id: "conditionItem_evenActions", myWorkflowRoot.Session);
SendactivityEvenExecutor sendActivityEven = new(myWorkflowRoot.Session);
DelegateExecutor conditionGroupTestPost = new(id: "conditionGroup_test_Post", myWorkflowRoot.Session);
ActivityFinalExecutor activityFinal = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemOddPost = new(id: "conditionItem_odd_Post", myWorkflowRoot.Session);
DelegateExecutor conditionItemEvenPost = new(id: "conditionItem_even_Post", myWorkflowRoot.Session);
DelegateExecutor conditionItemOddactionsPost = new(id: "conditionItem_oddActions_Post", myWorkflowRoot.Session);
DelegateExecutor conditionItemEvenactionsPost = new(id: "conditionItem_evenActions_Post", myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, setVariableTest);
builder.AddEdge(setVariableTest, conditionGroupTest);
builder.AddEdge(conditionGroupTest, conditionItemOdd, (object? result) => ActionExecutor.IsMatch("conditionItem_odd", result));
builder.AddEdge(conditionGroupTest, conditionItemEven, (object? result) => ActionExecutor.IsMatch("conditionItem_even", result));
builder.AddEdge(conditionItemOdd, conditionItemOddactions);
builder.AddEdge(conditionItemOddactions, sendActivityOdd);
builder.AddEdge(conditionItemEven, conditionItemEvenactions);
builder.AddEdge(conditionItemEvenactions, sendActivityEven);
builder.AddEdge(conditionGroupTestPost, activityFinal);
builder.AddEdge(conditionItemOddPost, conditionGroupTestPost);
builder.AddEdge(conditionItemEvenPost, conditionGroupTestPost);
builder.AddEdge(sendActivityOdd, conditionItemOddactionsPost);
builder.AddEdge(conditionItemOddactionsPost, conditionItemOddPost);
builder.AddEdge(sendActivityEven, conditionItemEvenactionsPost);
builder.AddEdge(conditionItemEvenactionsPost, conditionItemEvenPost);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,32 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_test
variable: Local.TestValue
value: =Value(System.LastMessageText)
- kind: ConditionGroup
id: conditionGroup_test
conditions:
- id: conditionItem_odd
condition: =Mod(Local.TestValue, 2) = 1
actions:
- kind: SendActivity
id: sendActivity_odd
activity: ODD
- id: conditionItem_even
condition: =Mod(Local.TestValue, 2) = 0
actions:
- kind: SendActivity
id: sendActivity_even
activity: EVEN
- kind: SendActivity
id: activity_final
activity: All done!
@@ -0,0 +1,193 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("TestValue", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.TestValue" variable.
/// </summary>
internal sealed class SetvariableTestExecutor(FormulaSession session) : ActionExecutor(id: "setVariable_test", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("Value(System.LastMessageText)").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "TestValue", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Conditional branching similar to an if / elseif / elseif / else chain.
/// </summary>
internal sealed class ConditiongroupTestExecutor(FormulaSession session) : ActionExecutor(id: "conditionGroup_test", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
bool condition0 = await context.EvaluateValueAsync<bool>("Mod(Local.TestValue, 2) = 1").ConfigureAwait(false);
if (condition0)
{
return "conditionItem_odd";
}
return "conditionGroup_testElseActions";
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendactivityOddExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_odd", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
ODD
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendactivityElseExecutor(FormulaSession session) : ActionExecutor(id: "sendActivity_else", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
EVEN
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class ActivityFinalExecutor(FormulaSession session) : ActionExecutor(id: "activity_final", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
All done!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
SetvariableTestExecutor setVariableTest = new(myWorkflowRoot.Session);
ConditiongroupTestExecutor conditionGroupTest = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemOdd = new(id: "conditionItem_odd", myWorkflowRoot.Session);
DelegateExecutor conditionGroupTestelseactions = new(id: "conditionGroup_testElseActions", myWorkflowRoot.Session);
DelegateExecutor conditionItemOddactions = new(id: "conditionItem_oddActions", myWorkflowRoot.Session);
SendactivityOddExecutor sendActivityOdd = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemOddRestart = new(id: "conditionItem_odd_Restart", myWorkflowRoot.Session);
SendactivityElseExecutor sendActivityElse = new(myWorkflowRoot.Session);
DelegateExecutor conditionGroupTestPost = new(id: "conditionGroup_test_Post", myWorkflowRoot.Session);
ActivityFinalExecutor activityFinal = new(myWorkflowRoot.Session);
DelegateExecutor conditionItemOddPost = new(id: "conditionItem_odd_Post", myWorkflowRoot.Session);
DelegateExecutor conditionItemOddactionsPost = new(id: "conditionItem_oddActions_Post", myWorkflowRoot.Session);
DelegateExecutor conditionGroupTestelseactionsPost = new(id: "conditionGroup_testElseActions_Post", myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, setVariableTest);
builder.AddEdge(setVariableTest, conditionGroupTest);
builder.AddEdge(conditionGroupTest, conditionItemOdd, (object? result) => ActionExecutor.IsMatch("conditionItem_odd", result));
builder.AddEdge(conditionGroupTest, conditionGroupTestelseactions, (object? result) => ActionExecutor.IsMatch("conditionGroup_testElseActions", result));
builder.AddEdge(conditionItemOdd, conditionItemOddactions);
builder.AddEdge(conditionItemOddactions, sendActivityOdd);
builder.AddEdge(conditionItemOddRestart, conditionGroupTestelseactions);
builder.AddEdge(conditionGroupTestelseactions, sendActivityElse);
builder.AddEdge(conditionGroupTestPost, activityFinal);
builder.AddEdge(conditionItemOddPost, conditionGroupTestPost);
builder.AddEdge(sendActivityOdd, conditionItemOddactionsPost);
builder.AddEdge(conditionItemOddactionsPost, conditionItemOddPost);
builder.AddEdge(sendActivityElse, conditionGroupTestelseactionsPost);
builder.AddEdge(conditionGroupTestelseactionsPost, conditionGroupTestPost);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,29 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_test
variable: Local.TestValue
value: =Value(System.LastMessageText)
- kind: ConditionGroup
id: conditionGroup_test
conditions:
- id: conditionItem_odd
condition: =Mod(Local.TestValue, 2) = 1
actions:
- kind: SendActivity
id: sendActivity_odd
activity: ODD
elseActions:
- kind: SendActivity
id: sendActivity_else
activity: EVEN
- kind: SendActivity
id: activity_final
activity: All done!
@@ -0,0 +1,25 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: setVariable_test
variable: Local.TestValue
value: =Value(System.LastMessageText)
- kind: ConditionGroup
id: conditionGroup_test
conditions:
- id: conditionItem_odd
condition: =Mod(Local.TestValue, 2) = 1
actions:
- kind: SendActivity
id: sendActivity_odd
activity: ODD
- kind: SendActivity
id: activity_final
activity: All done!
@@ -0,0 +1,95 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class WorkflowTestRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("workflow_test_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Copies one or more messages into the specified agent conversation.
/// </summary>
internal sealed class CopyMessagesExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "copy_messages", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string? conversationId = await context.ReadStateAsync<string>(key: "ConversationId", scopeName: "System").ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(conversationId))
{
throw new DeclarativeActionException($"Conversation identifier must be defined: {this.Id}");
}
ChatMessage[]? messages = await context.EvaluateValueAsync<ChatMessage[]>("""[UserMessage("Hello, how can I assist you today?")]""").ConfigureAwait(false);
if (messages is not null)
{
foreach (ChatMessage message in messages)
{
await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false);
}
}
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
WorkflowTestRootExecutor<TInput> workflowTestRoot = new(options, inputTransform);
DelegateExecutor workflowTest = new(id: "workflow_test", workflowTestRoot.Session);
CopyMessagesExecutor copyMessages = new(workflowTestRoot.Session, options.AgentProvider);
// Define the workflow builder
WorkflowBuilder builder = new(workflowTestRoot);
// Connect executors
builder.AddEdge(workflowTestRoot, workflowTest);
builder.AddEdge(workflowTest, copyMessages);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,12 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: CopyConversationMessages
id: copy_messages
conversationId: =System.ConversationId
messages: =[UserMessage("Hello, how can I assist you today?")]
@@ -0,0 +1,88 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class WorkflowTestRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("workflow_test_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("PrivateConversationId", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Creates a new conversation and stores the identifier value to the "Local.PrivateConversationId" variable.
/// </summary>
internal sealed class ConversationCreateExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : ActionExecutor(id: "conversation_create", session)
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "PrivateConversationId", value: conversationId, scopeName: "Local").ConfigureAwait(false);
await context.AddEventAsync(new ConversationUpdateEvent(conversationId)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
WorkflowTestRootExecutor<TInput> workflowTestRoot = new(options, inputTransform);
DelegateExecutor workflowTest = new(id: "workflow_test", workflowTestRoot.Session);
ConversationCreateExecutor conversationCreate = new(workflowTestRoot.Session, options.AgentProvider);
// Define the workflow builder
WorkflowBuilder builder = new(workflowTestRoot);
// Connect executors
builder.AddEdge(workflowTestRoot, workflowTest);
builder.AddEdge(workflowTest, conversationCreate);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,10 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: CreateConversation
id: conversation_create
conversationId: Local.PrivateConversationId
@@ -0,0 +1,87 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("MyTable", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.MyTable" variable.
/// </summary>
internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id: "set_var", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("[{id: 3}]").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
SetVarExecutor setVar = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, setVar);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,17 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: set_var
variable: Local.MyTable
value: =[{id: 3}]
- kind: EditTable
id: edit_var
itemsVariable: Local.MyTable
changeType: Add
value: ={id: 7}
@@ -0,0 +1,87 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("MyTable", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.MyTable" variable.
/// </summary>
internal sealed class SetVarExecutor(FormulaSession session) : ActionExecutor(id: "set_var", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("[{id: 3}]").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "MyTable", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
SetVarExecutor setVar = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, setVar);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,18 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: SetVariable
id: set_var
variable: Local.MyTable
value: =[{id: 3}]
- kind: EditTableV2
id: edit_var
itemsVariable: Local.MyTable
changeType:
kind: AddItemOperation
value: ={id: 7}
@@ -0,0 +1,94 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity1Executor(FormulaSession session) : ActionExecutor(id: "send_activity_1", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 1!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor endAllRestart = new(id: "end_all_Restart", myWorkflowRoot.Session);
SendActivity1Executor sendActivity1 = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, endAll);
builder.AddEdge(endAllRestart, sendActivity1);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,13 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: EndConversation
id: end_all
- kind: SendActivity
id: send_activity_1
activity: NEVER 1!
@@ -0,0 +1,94 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity1Executor(FormulaSession session) : ActionExecutor(id: "send_activity_1", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 1!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor endAllRestart = new(id: "end_all_Restart", myWorkflowRoot.Session);
SendActivity1Executor sendActivity1 = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, endAll);
builder.AddEdge(endAllRestart, sendActivity1);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,13 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: EndWorkflow
id: end_all
- kind: SendActivity
id: send_activity_1
activity: NEVER 1!
@@ -0,0 +1,143 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity1Executor(FormulaSession session) : ActionExecutor(id: "send_activity_1", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 1!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity2Executor(FormulaSession session) : ActionExecutor(id: "send_activity_2", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 2!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivity3Executor(FormulaSession session) : ActionExecutor(id: "send_activity_3", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
NEVER 3!
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
DelegateExecutor gotoEnd = new(id: "goto_end", myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor gotoEndRestart = new(id: "goto_end_Restart", myWorkflowRoot.Session);
SendActivity1Executor sendActivity1 = new(myWorkflowRoot.Session);
SendActivity2Executor sendActivity2 = new(myWorkflowRoot.Session);
SendActivity3Executor sendActivity3 = new(myWorkflowRoot.Session);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, gotoEnd);
builder.AddEdge(gotoEnd, endAll);
builder.AddEdge(gotoEndRestart, sendActivity1);
builder.AddEdge(sendActivity1, sendActivity2);
builder.AddEdge(sendActivity2, sendActivity3);
builder.AddEdge(sendActivity3, endAll);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,25 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: GotoAction
id: goto_end
actionId: end_all
- kind: SendActivity
id: send_activity_1
activity: NEVER 1!
- kind: SendActivity
id: send_activity_2
activity: NEVER 2!
- kind: SendActivity
id: send_activity_3
activity: NEVER 3!
- kind: EndConversation
id: end_all
@@ -0,0 +1,15 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: HttpRequestAction
id: http_request
method: GET
url: =Concatenate("https://api.example.test/items/", System.LastMessageText)
headers:
Accept: application/json
response: Local.HttpResult
responseHeaders: Local.HttpHeaders
@@ -0,0 +1,112 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Set environment variables
await this.InitializeEnvironmentAsync(
context,
"MY_STUDENT").ConfigureAwait(false);
}
}
/// <summary>
/// Invokes an agent to process messages and return a response within a conversation context.
/// </summary>
internal sealed class InvokeAgentExecutor(FormulaSession session, ResponseAgentProvider agentProvider) : AgentExecutor(id: "invoke_agent", session, agentProvider)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string? agentName = await context.ReadStateAsync<string>(key: "MY_STUDENT", scopeName: "Env").ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(agentName))
{
throw new DeclarativeActionException($"Agent name must be defined: {this.Id}");
}
string? conversationId = await context.ReadStateAsync<string>(key: "ConversationId", scopeName: "System").ConfigureAwait(false);
bool autoSend = true;
IList<ChatMessage>? inputMessages = await context.EvaluateListAsync<ChatMessage>("[UserMessage(System.LastMessageText)]").ConfigureAwait(false);
AgentResponse agentResponse =
await InvokeAgentAsync(
context,
agentName,
conversationId,
autoSend,
inputMessages,
cancellationToken).ConfigureAwait(false);
if (autoSend)
{
await context.AddEventAsync(new AgentResponseEvent(this.Id, agentResponse)).ConfigureAwait(false);
}
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
InvokeAgentExecutor invokeAgent = new(myWorkflowRoot.Session, options.AgentProvider);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, invokeAgent);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,14 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: InvokeAzureAgent
id: invoke_agent
conversationId: =System.ConversationId
agent:
name: =Env.MY_STUDENT
input:
messages: =[UserMessage(System.LastMessageText)]
@@ -0,0 +1,190 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("Count", UnassignedValue.Instance, "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync("LoopIndex", UnassignedValue.Instance, "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync("LoopValue", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Loops over a list assignign the loop variable to "Local.LoopValue" variable.
/// </summary>
internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session)
{
private int _index;
private object[] _values = [];
public bool HasValue { get; private set; }
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
this._index = 0;
object? evaluatedValue = await context.EvaluateValueAsync<object>("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false);
if (evaluatedValue == null)
{
this._values = [];
this.HasValue = false;
}
else
if (evaluatedValue is IEnumerable evaluatedList)
{
this._values = [.. evaluatedList];
}
else
{
this._values = [evaluatedValue];
}
await this.ResetAsync(context, cancellationToken).ConfigureAwait(false);
return default;
}
public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken)
{
if (this.HasValue = this._index < this._values.Length)
{
object value = this._values[this._index];
await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false);
this._index++;
}
}
public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken)
{
await this.ResetAsync(context, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.Count" variable.
/// </summary>
internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionExecutor(id: "set_variable_inner", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("Local.Count + 1").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivityInnerExecutor(FormulaSession session) : ActionExecutor(id: "send_activity_inner", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
ForeachLoopExecutor foreachLoop = new(myWorkflowRoot.Session);
DelegateExecutor foreachLoopNext = new(id: "foreach_loop_Next", myWorkflowRoot.Session, foreachLoop.TakeNextAsync);
DelegateExecutor foreachLoopPost = new(id: "foreach_loop_Post", myWorkflowRoot.Session);
DelegateExecutor foreachLoopStart = new(id: "foreach_loop_Start", myWorkflowRoot.Session);
DelegateExecutor breakLoopNow = new(id: "break_loop_now", myWorkflowRoot.Session);
DelegateExecutor breakLoopNowRestart = new(id: "break_loop_now_Restart", myWorkflowRoot.Session);
SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session);
SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, foreachLoop);
builder.AddEdge(foreachLoop, foreachLoopNext);
builder.AddEdge(foreachLoopNext, foreachLoopPost, (object? result) => !foreachLoop.HasValue);
builder.AddEdge(foreachLoopNext, foreachLoopStart, (object? result) => foreachLoop.HasValue);
builder.AddEdge(foreachLoopStart, breakLoopNow);
builder.AddEdge(breakLoopNow, foreachLoopPost);
builder.AddEdge(breakLoopNowRestart, setVariableInner);
builder.AddEdge(setVariableInner, sendActivityInner);
builder.AddEdge(foreachLoopPost, endAll);
builder.AddEdge(sendActivityInner, foreachLoopEnd);
builder.AddEdge(foreachLoopEnd, foreachLoopNext);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,28 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: Foreach
id: foreach_loop
items: =["a", "b", "c", "d", "e", "f"]
index: Local.LoopIndex
value: Local.LoopValue
actions:
- kind: BreakLoop
id: break_loop_now
- kind: SetVariable
id: set_variable_inner
variable: Local.Count
value: =Local.Count + 1
- kind: SendActivity
id: send_activity_inner
activity: x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
- kind: EndConversation
id: end_all
@@ -0,0 +1,190 @@
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// </auto-generated>
// ------------------------------------------------------------------------------
#nullable enable
#pragma warning disable IDE0005 // Extra using directive is ok.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
namespace Test.WorkflowProviders;
/// <summary>
/// This class provides a factory method to create a <see cref="Workflow" /> instance.
/// </summary>
/// <remarks>
/// The workflow defined here was generated from a declarative workflow definition.
/// Declarative workflows utilize Power FX for defining conditions and expressions.
/// To learn more about Power FX, see:
/// https://learn.microsoft.com/power-platform/power-fx/formula-reference-copilot-studio
/// </remarks>
public static class WorkflowProvider
{
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
internal sealed class MyWorkflowRootExecutor<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage> inputTransform) :
RootExecutor<TInput>("my_workflow_Root", options, inputTransform)
where TInput : notnull
{
protected override async ValueTask ExecuteAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
// Initialize variables
await context.QueueStateUpdateAsync("Count", UnassignedValue.Instance, "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync("LoopIndex", UnassignedValue.Instance, "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync("LoopValue", UnassignedValue.Instance, "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Loops over a list assignign the loop variable to "Local.LoopValue" variable.
/// </summary>
internal sealed class ForeachLoopExecutor(FormulaSession session) : ActionExecutor(id: "foreach_loop", session)
{
private int _index;
private object[] _values = [];
public bool HasValue { get; private set; }
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
this._index = 0;
object? evaluatedValue = await context.EvaluateValueAsync<object>("""["a", "b", "c", "d", "e", "f"]""").ConfigureAwait(false);
if (evaluatedValue == null)
{
this._values = [];
this.HasValue = false;
}
else
if (evaluatedValue is IEnumerable evaluatedList)
{
this._values = [.. evaluatedList];
}
else
{
this._values = [evaluatedValue];
}
await this.ResetAsync(context, cancellationToken).ConfigureAwait(false);
return default;
}
public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken)
{
if (this.HasValue = this._index < this._values.Length)
{
object value = this._values[this._index];
await context.QueueStateUpdateAsync(key: "LoopValue", value: value, scopeName: "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "LoopIndex", value: this._index, scopeName: "Local").ConfigureAwait(false);
this._index++;
}
}
public async ValueTask CompleteAsync(IWorkflowContext context, object? _, CancellationToken cancellationToken)
{
await this.ResetAsync(context, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ResetAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
await context.QueueStateUpdateAsync(key: "LoopValue", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "LoopIndex", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false);
}
}
/// <summary>
/// Assigns an evaluated expression, other variable, or literal value to the "Local.Count" variable.
/// </summary>
internal sealed class SetVariableInnerExecutor(FormulaSession session) : ActionExecutor(id: "set_variable_inner", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
object? evaluatedValue = await context.EvaluateValueAsync<object>("Local.Count + 1").ConfigureAwait(false);
await context.QueueStateUpdateAsync(key: "Count", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false);
return default;
}
}
/// <summary>
/// Formats a message template and sends an activity event.
/// </summary>
internal sealed class SendActivityInnerExecutor(FormulaSession session) : ActionExecutor(id: "send_activity_inner", session)
{
// <inheritdoc />
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
string activityText =
await context.FormatTemplateAsync(
"""
x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
"""
);
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, response)).ConfigureAwait(false);
return default;
}
}
public static Workflow CreateWorkflow<TInput>(
DeclarativeWorkflowOptions options,
Func<TInput, ChatMessage>? inputTransform = null)
where TInput : notnull
{
// Create root executor to initialize the workflow.
inputTransform ??= (message) => DeclarativeWorkflowBuilder.DefaultTransform(message);
MyWorkflowRootExecutor<TInput> myWorkflowRoot = new(options, inputTransform);
DelegateExecutor myWorkflow = new(id: "my_workflow", myWorkflowRoot.Session);
ForeachLoopExecutor foreachLoop = new(myWorkflowRoot.Session);
DelegateExecutor foreachLoopNext = new(id: "foreach_loop_Next", myWorkflowRoot.Session, foreachLoop.TakeNextAsync);
DelegateExecutor foreachLoopPost = new(id: "foreach_loop_Post", myWorkflowRoot.Session);
DelegateExecutor foreachLoopStart = new(id: "foreach_loop_Start", myWorkflowRoot.Session);
DelegateExecutor continueLoopNow = new(id: "continue_loop_now", myWorkflowRoot.Session);
DelegateExecutor continueLoopNowRestart = new(id: "continue_loop_now_Restart", myWorkflowRoot.Session);
SetVariableInnerExecutor setVariableInner = new(myWorkflowRoot.Session);
SendActivityInnerExecutor sendActivityInner = new(myWorkflowRoot.Session);
DelegateExecutor endAll = new(id: "end_all", myWorkflowRoot.Session);
DelegateExecutor foreachLoopEnd = new(id: "foreach_loop_End", myWorkflowRoot.Session, foreachLoop.CompleteAsync);
// Define the workflow builder
WorkflowBuilder builder = new(myWorkflowRoot);
// Connect executors
builder.AddEdge(myWorkflowRoot, myWorkflow);
builder.AddEdge(myWorkflow, foreachLoop);
builder.AddEdge(foreachLoop, foreachLoopNext);
builder.AddEdge(foreachLoopNext, foreachLoopPost, (object? result) => !foreachLoop.HasValue);
builder.AddEdge(foreachLoopNext, foreachLoopStart, (object? result) => foreachLoop.HasValue);
builder.AddEdge(foreachLoopStart, continueLoopNow);
builder.AddEdge(continueLoopNow, foreachLoopStart);
builder.AddEdge(continueLoopNowRestart, setVariableInner);
builder.AddEdge(setVariableInner, sendActivityInner);
builder.AddEdge(foreachLoopPost, endAll);
builder.AddEdge(sendActivityInner, foreachLoopEnd);
builder.AddEdge(foreachLoopEnd, foreachLoopNext);
// Build the workflow
return builder.Build(validateOrphans: false);
}
}
@@ -0,0 +1,28 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: Foreach
id: foreach_loop
items: =["a", "b", "c", "d", "e", "f"]
index: Local.LoopIndex
value: Local.LoopValue
actions:
- kind: ContinueLoop
id: continue_loop_now
- kind: SetVariable
id: set_variable_inner
variable: Local.Count
value: =Local.Count + 1
- kind: SendActivity
id: send_activity_inner
activity: x{Local.Count} - {Local.LoopIndex}:{Local.LoopValue}
- kind: EndConversation
id: end_all

Some files were not shown because too many files have changed in this diff Show More