Compare commits

...
Author SHA1 Message Date
Jacob AlberandGitHub 3854c2dbc2 Merge branch 'main' into dev/dotnet_workflow/add_unit_tests 2026-04-21 11:57:25 -04:00
Peter IbekweandGitHub adcd2d33f5 .NET: Declarative workflows - Gracefully handle agent scenarios when no response is returned (#5376)
* Gracefully handle agent scenarios when no response is returned

* Make relevant object disposable and improve exception handling.
2026-04-21 15:04:49 +00:00
Jacob Alber 2f12d87cdc fix: Re-add Obsolete attributes
- avoid hard-breaking change
- properly notify users that these attributes get ignored
2026-04-21 10:41:06 -04:00
Jacob Alber 95436c168d test: Suppress CodeCoverage for obsolete names 2026-04-21 10:40:51 -04:00
Jacob Alber 8d4029a271 test: Add FunctionExecutor tests
- also fixes Send and YieldOutput type registration for synchronous output-returning delegates
2026-04-21 10:40:47 -04:00
Jacob Alber 15a5bbab21 test: Add tests for failure when .AsAgent used on a non-ChatProtocol workflow 2026-04-21 10:40:44 -04:00
Jacob Alber 1feb5efb59 fixup: remove unused attribute 2026-04-21 10:40:41 -04:00
Jacob Alber ece4787df7 fix: ChatForwardingExecutor does not use correct role for string messages
- make ChatForwardingExecutor use its configured role for string messages rather than always use ChatRole.User
- add ChatForwardingExecutor tests
2026-04-21 10:40:38 -04:00
Jacob Alber 33f3b02d41 refactor: remove ignore YieldsMessageAttribute
- the correct one to use is YieldsOutputAttribute
- fixes a comment that mistakenly refers to `.YieldsMessage()` which does not exist.
2026-04-21 10:40:35 -04:00
Jacob Alber dfb316ee59 refactor: remove dead code 2026-04-21 10:40:33 -04:00
15 changed files with 757 additions and 60 deletions
@@ -4,7 +4,6 @@ using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
include: null,
cancellationToken).ConfigureAwait(false);
return newItems.AsChatMessages().Single();
ChatMessage[] createdMessages = [.. newItems.AsChatMessages()];
if (createdMessages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}.");
}
return createdMessages[0];
IEnumerable<ResponseItem> GetResponseItems()
{
@@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
{
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsResponseResultItem()];
return items.AsChatMessages().Single();
ChatMessage[] messages = [.. items.AsChatMessages()];
if (messages.Length != 1)
{
throw new InvalidOperationException(
$"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}.");
}
return messages[0];
}
/// <inheritdoc/>
@@ -49,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
}
@@ -85,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
// Attempt to parse the last message as JSON and assign to the response object variable.
try
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
if (!string.IsNullOrEmpty(lastMessageText))
{
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch
{
// Not valid json, skip assignment.
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch (JsonException)
{
// Not valid json, skip assignment.
}
}
if (this.Model.Input?.ExternalLoop?.When is not null)
@@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
string? workflowConversationId = context.GetWorkflowConversation();
if (workflowConversationId is not null)
{
// Input message always defined if values has been extracted.
ChatMessage input = response.Messages.Last();
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
// Input message expected to be defined when values have been extracted, but guard defensively.
ChatMessage? input = response.Messages.LastOrDefault();
if (input is not null)
{
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
}
}
}
@@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
}
}
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
ChatMessage? lastMessage = response.Messages.LastOrDefault();
if (lastMessage is not null)
{
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
}
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
@@ -254,7 +254,7 @@ internal static class SemanticAnalyzer
/// <summary>
/// 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.
/// </summary>
/// <remarks>
@@ -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);
@@ -47,7 +47,7 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
(message, context) => context.SendMessageAsync(new ChatMessage(this._stringMessageChatRole.Value, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
@@ -73,7 +73,14 @@ public class FunctionExecutor<TInput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? 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<TInput, TOutput>(string id,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, out IEnumerable<Type> sentTypes, out IEnumerable<Type> yieldedTypes)
{
if (handlerSync.Method != null)
{
MethodInfo method = handlerSync.Method;
(sentTypes, yieldedTypes) = method.GetAttributeTypes();
}
else
{
sentTypes = yieldedTypes = [];
}
return RunFuncAsync;
ValueTask<TOutput> RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken)
@@ -133,7 +150,14 @@ public class FunctionExecutor<TInput, TOutput>(string id,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? 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)
{
}
}
@@ -20,6 +20,7 @@ internal static class DiagnosticConstants
}
/// <inheritdoc/>
[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<HandoffsWorkflowBuilder>(initialAgent)
@@ -29,6 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[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);
}
}
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[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
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// This attribute indicates that a message handler streams messages during its execution.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class StreamsMessageAttribute : Attribute
{
/// <summary>
/// The type of the message that the handler yields.
/// </summary>
public Type Type { get; }
/// <summary>
/// Indicates that the message handler yields streaming messages during the course of execution.
/// </summary>
public StreamsMessageAttribute(Type type)
{
// This attribute is used to mark executors that yield messages.
this.Type = Throw.IfNull(type);
}
}
@@ -85,6 +85,29 @@ public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) :
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)
@@ -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<TestWorkflowContext> RunForwardMessageTestAsync<TMessage>(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<Task<TestWorkflowContext>> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent);
await action.Should().ThrowAsync<NotSupportedException>();
}
[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<Task<TestWorkflowContext>> 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<NotSupportedException>();
}
}
[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<ChatMessage> testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent),
new(ChatRole.Assistant, "ResponseMessage")];
// Act
TestWorkflowContext testContext
= sendAsIEnumerable
? await this.RunForwardMessageTestAsync<IEnumerable<ChatMessage>>(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<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))
.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]);
}
}
@@ -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>(TMessage message) : WorkflowEvent(message)
{
public TMessage Message => message;
}
internal sealed record ExecutorTestResult(TestWorkflowContext Context, object? CallResult);
internal async ValueTask<ExecutorTestResult> Run_FunctionExecutor_MessageHandlerTestAsync<TMessage>(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<TMessage>(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<TMessage, TOutput>(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<TextMessage> 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<TextMessage, DataMessage> 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<DataMessage> 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<Type>? sendTypes = useAnnotated
? null
: [typeof(TextMessage)];
FunctionExecutor<TextMessage> 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<Type>? sendTypes = useAnnotated
? null
: [typeof(TextMessage)];
FunctionExecutor<TextMessage, DataMessage> 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<DataMessage> MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
async ValueTask<DataMessage> 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<Type>? yieldTypes = useAnnotated
? null
: [typeof(DataMessage)];
FunctionExecutor<TextMessage> 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<Type>? yieldTypes = useAnnotated
? null
: [typeof(DataMessage)];
FunctionExecutor<TextMessage, DataMessage> 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<DataMessage> MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> MessageHandlerAsync(message, context, cancellationToken);
async ValueTask<DataMessage> 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<TInput> 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<TextMessage> 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<TInput> 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<TextMessage, DataMessage> 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<DataMessage> MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(new DataMessage(message));
DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken)
=> new(message);
}
}
@@ -206,6 +206,14 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor
}
}
public class NonChatProtocolExecutor() : Executor<string>(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<Task> action = runAsync
? () => workflowAsAgent.RunStreamingAsync().ToAgentResponseAsync()
: () => workflowAsAgent.RunAsync();
await action.Should().ThrowAsync<InvalidOperationException>();
}
private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync)
{
// Arrange