diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs index d9fed2878f..26a6edfc66 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs @@ -73,7 +73,14 @@ public class FunctionExecutor(string id, ExecutorOptions? options = null, IEnumerable? sentMessageTypes = null, IEnumerable? outputTypes = null, - bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable) + bool declareCrossRunShareable = false) : this(id, + WrapAction(handlerSync, + out var attributeSentTypes, + out var attributeYieldTypes), + options, + attributeSentTypes.Concat(sentMessageTypes ?? []), + attributeYieldTypes.Concat(outputTypes ?? []), + declareCrossRunShareable) { } } @@ -96,8 +103,18 @@ public class FunctionExecutor(string id, IEnumerable? outputTypes = null, bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { - internal static Func> WrapFunc(Func handlerSync) + internal static Func> WrapFunc(Func handlerSync, out IEnumerable sentTypes, out IEnumerable yieldedTypes) { + if (handlerSync.Method != null) + { + MethodInfo method = handlerSync.Method; + (sentTypes, yieldedTypes) = method.GetAttributeTypes(); + } + else + { + sentTypes = yieldedTypes = []; + } + return RunFuncAsync; ValueTask RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken) @@ -133,7 +150,14 @@ public class FunctionExecutor(string id, ExecutorOptions? options = null, IEnumerable? sentMessageTypes = null, IEnumerable? outputTypes = null, - bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable) + bool declareCrossRunShareable = false) : this(id, + WrapFunc(handlerSync, + out var attributeSentTypes, + out var attributeYieldTypes), + options, + attributeSentTypes.Concat(sentMessageTypes ?? []), + attributeYieldTypes.Concat(outputTypes ?? []), + declareCrossRunShareable) { } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs new file mode 100644 index 0000000000..164ac58ed8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class ExecutorTestsBase +{ + public sealed record TextMessage(string Text); + + public const string TestMessageContent = nameof(TestMessage); + public static TextMessage TestMessage { get; } = new(TestMessageContent); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Test Object")] + public sealed record DataMessage(string Base64Bytes) + { + private static string ToBase64String(string text, Encoding? expectedEncoding) + { + byte[] bytes = (expectedEncoding ?? Encoding.UTF8).GetBytes(text); + return Convert.ToBase64String(bytes); + } + + public DataMessage(TextMessage textMessage, Encoding? expectedEncoding = null) : this(ToBase64String(textMessage.Text, expectedEncoding)) + { } + } + + public const string DataMessageContent = nameof(DataMessage); + public static DataMessage TestDataMessage { get; } = new(TestMessage); + + public sealed class InvocationEvent(TMessage message) : WorkflowEvent(message) + { + public TMessage Message => message; + } + + internal sealed record ExecutorTestResult(TestWorkflowContext Context, object? CallResult); + + internal async ValueTask Run_FunctionExecutor_MessageHandlerTestAsync(Executor executor, TMessage message, CancellationToken cancellationToken = default) + where TMessage : notnull + { + TestWorkflowContext workflowContext = this.CreateWorkflowContext(executor); + _ = executor.DescribeProtocol(); + + object? result = await executor.ExecuteCoreAsync(message, new(typeof(TMessage)), workflowContext, cancellationToken); + + return new(workflowContext, result); + } + + internal static void CheckInvoked(ExecutorTestResult result, TMessage expectedInput, object? expectedCallResult = null) + where TMessage : class + { + result.CallResult.Should().Be(expectedCallResult); + + result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent + && ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput) + .And.Contain(evt => evt is ExecutorCompletedEvent + && ((ExecutorCompletedEvent)evt).Data == expectedCallResult); + } + + internal static void CheckInvoked(ExecutorTestResult result, TMessage expectedInput, TOutput expectedCallResult) + where TMessage : class + where TOutput : class + { + result.CallResult.Should().Be(expectedCallResult); + + result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent + && ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput) + .And.Contain(evt => evt is ExecutorCompletedEvent + && ((ExecutorCompletedEvent)evt).Data as TOutput == expectedCallResult); + } + + internal TestWorkflowContext CreateWorkflowContext(Executor executor) => new(executor.Id); +} + +public class FunctionExecutorTests : ExecutorTestsBase +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_FunctionExecutor__1_InvokesDelegateSuccessfullyAsync(bool useAsync) + { + // Arrange + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync) + : new(nameof(FunctionExecutor<>), MessageHandler); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + + // Assert + CheckInvoked(result, TestMessage); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => default; + + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) { } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_FunctionExecutor__2_InvokesDelegateSuccessfullyAsync(bool useAsync) + { + // Arrange + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<,>), MessageHandlerAsync) + : new(nameof(FunctionExecutor<,>), MessageHandler); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + + // Assert + CheckInvoked(result, TestMessage, TestDataMessage); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(new DataMessage(message)); + + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(message); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__1_SendTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + IEnumerable? sendTypes = useAnnotated + ? null + : [typeof(TextMessage)]; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, sentMessageTypes: sendTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, sentMessageTypes: sendTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]); + protocol.Yields.Should().BeEmpty(); + + // Helpers + [SendsMessage(typeof(TextMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + [SendsMessage(typeof(TextMessage))] + void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__2_SendTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = false, + AutoYieldOutputHandlerResultObject = false + }; + + IEnumerable? sendTypes = useAnnotated + ? null + : [typeof(TextMessage)]; + + FunctionExecutor executor + = useAsync + ? new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, options, sentMessageTypes: sendTypes) + : new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, options, sentMessageTypes: sendTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]); + protocol.Yields.Should().BeEmpty(); + + // Helpers + [SendsMessage(typeof(TextMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + async ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + await context.SendMessageAsync(message, cancellationToken); + return new(message); + } + + [SendsMessage(typeof(TextMessage))] + DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); + return new(message); + } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__1_YieldTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + IEnumerable? yieldTypes = useAnnotated + ? null + : [typeof(DataMessage)]; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, outputTypes: yieldTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, outputTypes: yieldTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + protocol.Sends.Should().BeEmpty(); + + // Helpers + [YieldsOutput(typeof(DataMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.YieldOutputAsync(new DataMessage(message), cancellationToken); + + [YieldsOutput(typeof(DataMessage))] + void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__2_YieldTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = false, + AutoYieldOutputHandlerResultObject = false + }; + + IEnumerable? yieldTypes = useAnnotated + ? null + : [typeof(DataMessage)]; + + FunctionExecutor executor + = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, options, outputTypes: yieldTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, options, outputTypes: yieldTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + protocol.Sends.Should().BeEmpty(); + + // Helpers + [YieldsOutput(typeof(DataMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + async ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + await context.YieldOutputAsync(new DataMessage(message), cancellationToken); + return new(message); + } + + [YieldsOutput(typeof(DataMessage))] + DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult(); + return new(message); + } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void Test_FunctionExecutor__1_ExecutorOptionsAreNoOp(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue) + { + // Because FunctionExecutor does not have a rail for a returned value, setting up options for it will + // not register any output types + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = autoSendReturnValue, + AutoYieldOutputHandlerResultObject = autoYieldReturnValue + }; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options) + : new(nameof(FunctionExecutor<>), MessageHandler, options); + + ProtocolDescriptor protocol = executor.DescribeProtocol(); + protocol.Sends.Should().BeEmpty(); + protocol.Yields.Should().BeEmpty(); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public async Task Test_FunctionExecutor__2_ExecutorOptionsCauseCorrectRegistration_AndAutoBehaviorAsync(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue) + { + // Arrange + // Because FunctionExecutor does not have a rail for a returned value, setting up options for it will + // not register any output types + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = autoSendReturnValue, + AutoYieldOutputHandlerResultObject = autoYieldReturnValue + }; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options) + : new(nameof(FunctionExecutor<>), MessageHandler, options); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + CheckInvoked(result, TestMessage, TestDataMessage); + if (autoSendReturnValue) + { + protocol.Sends.Should().BeEquivalentTo([typeof(DataMessage)]); + result.Context.SentMessages.Should().ContainEquivalentOf(TestDataMessage); + } + else + { + protocol.Sends.Should().BeEmpty(); + result.Context.SentMessages.Should().NotContainEquivalentOf(TestDataMessage); + } + + if (autoYieldReturnValue) + { + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + result.Context.YieldedOutputs.Should().ContainEquivalentOf(TestDataMessage); + } + else + { + protocol.Yields.Should().BeEmpty(); + result.Context.YieldedOutputs.Should().NotContainEquivalentOf(TestDataMessage); + } + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(new DataMessage(message)); + + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(message); + } +}