diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index 66b67bffdf..7fcbdb18ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -254,7 +254,7 @@ internal static class SemanticAnalyzer /// /// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes - /// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol + /// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol /// configuration. /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs index 8c94f4aa85..ef8f800979 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs @@ -9,17 +9,6 @@ namespace Microsoft.Agents.AI.Workflows; internal static class AIAgentsAbstractionsExtensions { - public static ChatMessage ToChatMessage(this AgentResponseUpdate update) => - new() - { - AuthorName = update.AuthorName, - Contents = update.Contents, - Role = update.Role ?? ChatRole.User, - CreatedAt = update.CreatedAt, - MessageId = update.MessageId, - RawRepresentation = update.RawRepresentation ?? update, - }; - public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName) => message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs index 93925dec32..bfea6cff97 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs @@ -47,7 +47,7 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti if (this._stringMessageChatRole.HasValue) { routeBuilder = routeBuilder.AddHandler( - (message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))); + (message, context) => context.SendMessageAsync(new ChatMessage(this._stringMessageChatRole.Value, message))); } routeBuilder.AddHandler(ForwardMessageAsync) 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/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index ba21f9322b..00e030448f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -20,6 +20,7 @@ internal static class DiagnosticConstants } /// +[ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s") [Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] #pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs index 82ca9106b7..87ffb7f89a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs @@ -29,6 +29,7 @@ namespace Microsoft.Agents.AI.Workflows; /// } /// /// +[Obsolete("Use YieldsOutput instead. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")] [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] public sealed class YieldsMessageAttribute : Attribute { @@ -47,3 +48,25 @@ public sealed class YieldsMessageAttribute : Attribute this.Type = Throw.IfNull(type); } } + +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[Obsolete("This attribute does not do anything. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = Throw.IfNull(type); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs deleted file mode 100644 index 43f9d59a5f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Workflows; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = Throw.IfNull(type); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs new file mode 100644 index 0000000000..805128509c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal enum ChatRoleType +{ + None, + User, + Assistant, + Custom +} + +internal static class ChatRoleTestingExtensions +{ + public const string CustomChatRoleName = nameof(CustomChatRole); + + public static ChatRole CustomChatRole { get; } = new(CustomChatRoleName); + + public static ChatRole? ToChatRole(this ChatRoleType type) + => type switch + { + ChatRoleType.None => null, + ChatRoleType.User => ChatRole.User, + ChatRoleType.Assistant => ChatRole.Assistant, + ChatRoleType.Custom => CustomChatRole, + _ => throw new ArgumentOutOfRangeException( + nameof(type), + type, + $"Invalid ChatRoleType {type}; expecting one of {string.Join(",", + [null, + ChatRole.User, + ChatRole.Assistant, + CustomChatRole])}") + }; +} + +public class ChatForwardingExecutorTests +{ + private async Task RunForwardMessageTestAsync(ChatForwardingExecutor executor, TMessage message) + where TMessage : notnull + { + // Ensure that we have constructed the Protocol (and registered the handlers) + _ = executor.Protocol; + + TestWorkflowContext testContext = new(executor.Id); + object? callResult = await executor.ExecuteCoreAsync(message, new TypeId(typeof(TMessage)), testContext); + + callResult.Should().BeNull(); // ChatForwardingExecutor's do not have a return type + + return testContext; + } + + private const string TestMessageContent = nameof(TestMessageContent); + + [Fact] + public async Task Test_ChatForwardingExecutor_DoesNotForwardStringByDefaultAsync() + { + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + + // Act + Func> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent); + await action.Should().ThrowAsync(); + } + + [Theory] + [InlineData(ChatRoleType.None)] + [InlineData(ChatRoleType.User)] + [InlineData(ChatRoleType.Assistant)] + [InlineData(ChatRoleType.Custom)] + internal async Task Test_ChatForwardingExecutor_ForwardsStringIfConfiguredAsync(ChatRoleType chatRoleType) + { + // Arrange + ChatForwardingExecutorOptions options = new() + { + StringMessageChatRole = chatRoleType.ToChatRole() + }; + + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor), options); + + // Act + Func> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent); + + // Assert + if (options.StringMessageChatRole is ChatRole chatRole) + { + TestWorkflowContext testContext = await action(); + + testContext.SentMessages.Should().HaveCount(1) + .And.BeEquivalentTo([new ChatMessage(chatRole, TestMessageContent)]); + } + else + { + await action.Should().ThrowAsync(); + } + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageUnmodifiedAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ChatMessage testMessage = new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent); + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessage); + + // Assert + testContext.SentMessages.Should().ContainSingle(message => ReferenceEquals(message, testMessage)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageListUnmodifiedAsync(bool sendAsIEnumerable) + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + List testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext + = sendAsIEnumerable + ? await this.RunForwardMessageTestAsync>(executor, testMessages) + : await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages)); + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageArrayUnchangedAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ChatMessage[] testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages)); + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsMessageCollectionAsListAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ConcurrentBag testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => !ReferenceEquals(messages, testMessages)) + .And.Subject.Single().Should().BeEquivalentTo(testMessages); + } + + [Theory] + [InlineData(null)] + [InlineData(false)] + [InlineData(true)] + public async Task Test_ChatForwardingExecutor_ForwardsTurnTokenUnmodifiedAsync(bool? emitEvents) + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + TurnToken testTurnToken = new(emitEvents); + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testTurnToken); + + // Assert + testContext.SentMessages.Should().BeEquivalentTo([testTurnToken]); + } +} 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); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 3fa17a34bb..7b9b428871 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -206,6 +206,14 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor } } +public class NonChatProtocolExecutor() : Executor(nameof(NonChatProtocolExecutor)) +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return default; + } +} + public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent @@ -732,6 +740,25 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase .BeEmpty(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_AsAgent_FailsWhenNotChatProtocolAsync(bool runAsync) + { + // Arrange + NonChatProtocolExecutor executor = new(); + executor.DescribeProtocol().IsChatProtocol().Should().BeFalse(); + + Workflow workflow = new WorkflowBuilder(executor).Build(); + AIAgent workflowAsAgent = workflow.AsAIAgent(); + + Func action = runAsync + ? () => workflowAsAgent.RunStreamingAsync().ToAgentResponseAsync() + : () => workflowAsAgent.RunAsync(); + + await action.Should().ThrowAsync(); + } + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) { // Arrange