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