test
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
Python - Merge - Tests / paths-filter (push) Has been cancelled
Python - Merge - Tests / Python Tests - Core (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / Python Tests - Azure AI (integration, ubuntu-latest, 3.10) (push) Has been cancelled
Python - Merge - Tests / python-integration-tests-check (push) Has been cancelled
Python - Lab Tests / paths-filter (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (ubuntu-latest, 3.14) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.10) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.11) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.12) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.13) (push) Has been cancelled
Python - Lab Tests / Python Lab Tests (windows-latest, 3.14) (push) Has been cancelled
Check .md links / markdown-link-check (push) Has been cancelled

This commit is contained in:
2026-01-24 03:05:12 +11:00
parent f78f2388b3
commit 539852f81c
2584 changed files with 287471 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
#pragma warning disable SYSLIB1045 // Use GeneratedRegex
#pragma warning disable RCS1186 // Use Regex instance instead of static method
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class AgentWorkflowBuilderTests
{
[Fact]
public void BuildSequential_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!));
Assert.Throws<ArgumentException>("agents", () => AgentWorkflowBuilder.BuildSequential());
}
[Fact]
public void BuildConcurrent_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!));
}
[Fact]
public void BuildHandoffs_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!));
var agent = new DoubleEchoAgent("agent");
var handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent);
Assert.NotNull(handoffs);
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!));
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoffs(null!, new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("from", () => handoffs.WithHandoffs([null!], new DoubleEchoAgent("a2")));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), null!));
Assert.Throws<ArgumentNullException>("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), [null!]));
var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); }));
Assert.Throws<ArgumentException>("to", () => handoffs.WithHandoff(agent, noDescriptionAgent));
}
[Fact]
public void BuildGroupChat_InvalidArguments_Throws()
{
Assert.Throws<ArgumentNullException>("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!));
var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]));
Assert.NotNull(groupChat);
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(null!));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants([null!]));
Assert.Throws<ArgumentNullException>("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!));
Assert.Throws<ArgumentNullException>("agents", () => new RoundRobinGroupChatManager(null!));
}
[Fact]
public void GroupChatManager_MaximumIterationCount_Invalid_Throws()
{
var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]);
const int DefaultMaxIterations = 40;
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = 0);
Assert.Throws<ArgumentOutOfRangeException>("value", void () => manager.MaximumIterationCount = -1);
Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount);
manager.MaximumIterationCount = 30;
Assert.Equal(30, manager.MaximumIterationCount);
manager.MaximumIterationCount = 1;
Assert.Equal(1, manager.MaximumIterationCount);
manager.MaximumIterationCount = int.MaxValue;
Assert.Equal(int.MaxValue, manager.MaximumIterationCount);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildSequential_AgentsRunInOrderAsync(int numAgents)
{
var workflow = AgentWorkflowBuilder.BuildSequential(
from i in Enumerable.Range(1, numAgents)
select new DoubleEchoAgent($"agent{i}"));
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[numAgents + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < numAgents + 1; i++)
{
string id = $"agent{((i - 1) % numAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
private class DoubleEchoAgent(string name) : AIAgent
{
public override string Name => name;
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new DoubleEchoAgentThread());
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var contents = messages.SelectMany(m => m.Contents).ToList();
string id = Guid.NewGuid().ToString("N");
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id };
}
}
private sealed class DoubleEchoAgentThread() : InMemoryAgentThread();
[Fact]
public async Task BuildConcurrent_AgentsRunInParallelAsync()
{
StrongBox<TaskCompletionSource<bool>> barrier = new();
StrongBox<int> remaining = new();
var workflow = AgentWorkflowBuilder.BuildConcurrent(
[
new DoubleEchoAgentWithBarrier("agent1", barrier, remaining),
new DoubleEchoAgentWithBarrier("agent2", barrier, remaining),
]);
for (int iter = 0; iter < 3; iter++)
{
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
// TODO: https://github.com/microsoft/agent-framework/issues/784
// These asserts are flaky until we guarantee message delivery order.
Assert.Single(Regex.Matches(updateText, "agent1"));
Assert.Single(Regex.Matches(updateText, "agent2"));
Assert.Equal(4, Regex.Matches(updateText, "abc").Count);
Assert.Equal(2, result.Count);
}
}
[Fact]
public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
return new(new ChatMessage(ChatRole.Assistant, "Hello from agent1"));
}));
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, new ChatClientAgent(new MockChatClient(delegate
{
Assert.Fail("Should never be invoked.");
return new();
}), description: "nop"))
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent1", updateText);
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("Hello from agent1", result[1].Text);
}
[Fact]
public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent2"))),
name: "nextAgent",
description: "The second agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, nextAgent)
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent2", updateText);
Assert.NotNull(result);
Assert.Equal(4, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("Hello from agent2", result[3].Text);
Assert.Contains("nextAgent", result[3].AuthorName);
}
[Fact]
public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Only a handoff function call.
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
// Second agent should receive the conversation so far (including previous assistant + tool messages eventually).
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "secondAgent", description: "The second agent");
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
// User + (assistant empty + tool) for each of first two agents + final assistant with text.
Assert.Equal(6, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("", result[3].Text);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Equal(ChatRole.Tool, result[4].Role);
Assert.Contains("secondAgent", result[4].AuthorName);
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Equal("Hello from agent3", result[5].Text);
Assert.Contains("thirdAgent", result[5].AuthorName);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations)
{
const int NumAgents = 3;
var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations })
.AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2"))
.AddParticipants(new DoubleEchoAgent("agent3"))
.Build();
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Null(result[0].AuthorName);
Assert.Equal(UserInput, result[0].Text);
string[] texts = new string[maxIterations + 1];
texts[0] = UserInput;
string expectedTotal = string.Empty;
for (int i = 1; i < maxIterations + 1; i++)
{
string id = $"agent{((i - 1) % NumAgents) + 1}";
texts[i] = $"{id}{Double(string.Concat(texts.Take(i)))}";
Assert.Equal(ChatRole.Assistant, result[i].Role);
Assert.Equal(id, result[i].AuthorName);
Assert.Equal(texts[i], result[i].Text);
expectedTotal += texts[i];
}
Assert.Equal(expectedTotal, updateText);
Assert.Equal(UserInput + expectedTotal, string.Concat(result));
static string Double(string s) => s + s;
}
}
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
{
StringBuilder sb = new();
IWorkflowExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment();
await using StreamingRun run = await environment.StreamAsync(workflow, input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
WorkflowOutputEvent? output = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is AgentResponseUpdateEvent executorComplete)
{
sb.Append(executorComplete.Data);
}
else if (evt is WorkflowOutputEvent e)
{
output = e;
break;
}
}
return (sb.ToString(), output?.As<List<ChatMessage>>());
}
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
{
barrier.Value!.SetResult(true);
}
await barrier.Value!.Task.ConfigureAwait(false);
await foreach (var update in base.RunCoreStreamingAsync(messages, thread, options, cancellationToken))
{
await Task.Yield();
yield return update;
}
}
}
private sealed class MockChatClient(Func<IEnumerable<ChatMessage>, ChatOptions?, ChatResponse> responseFactory) : IChatClient
{
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(responseFactory(messages, options));
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates())
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal static class TextMessageStreamingExtensions
{
public static IEnumerable<AIContent> ToContentStream(this string? message)
{
if (string.IsNullOrEmpty(message))
{
return [];
}
string[] splits = message.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += " ";
}
return splits.Select(text => (AIContent)new TextContent(text) { RawRepresentation = text });
}
public static AgentResponseUpdate ToResponseUpdate(this AIContent content, string? messageId = null, DateTimeOffset? createdAt = null, string? responseId = null, string? agentId = null, string? authorName = null) =>
new()
{
Role = ChatRole.Assistant,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
MessageId = messageId ?? Guid.NewGuid().ToString("N"),
ResponseId = responseId,
AgentId = agentId,
AuthorName = authorName,
Contents = [content],
};
public static IEnumerable<AgentResponseUpdate> ToAgentRunStream(this string message, DateTimeOffset? createdAt = null, string? messageId = null, string? responseId = null, string? agentId = null, string? authorName = null)
{
messageId ??= Guid.NewGuid().ToString("N");
IEnumerable<AIContent> contents = message.ToContentStream();
return contents.Select(content => content.ToResponseUpdate(messageId, createdAt, responseId, agentId, authorName));
}
public static ChatMessage ToChatMessage(this IEnumerable<AIContent> contents, string? messageId = null, DateTimeOffset? createdAt = null, string? responseId = null, string? agentId = null, string? authorName = null, string? rawRepresentation = null) =>
new(ChatRole.Assistant, contents is List<AIContent> contentsList ? contentsList : contents.ToList())
{
AuthorName = authorName,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
MessageId = messageId ?? Guid.NewGuid().ToString("N"),
RawRepresentation = rawRepresentation,
};
public static IEnumerable<AgentResponseUpdate> StreamMessage(this ChatMessage message, string? responseId = null, string? agentId = null)
{
responseId ??= Guid.NewGuid().ToString("N");
string messageId = message.MessageId ?? Guid.NewGuid().ToString("N");
return message.Contents.Select(content => content.ToResponseUpdate(messageId, message.CreatedAt, responseId: responseId, agentId: agentId, authorName: message.AuthorName));
}
public static IEnumerable<AgentResponseUpdate> StreamMessages(this List<ChatMessage> messages, string? agentId = null) =>
messages.SelectMany(message => message.StreamMessage(agentId));
public static List<ChatMessage> ToChatMessages(this IEnumerable<string> messages, string? authorName = null)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
ChatMessage ToMessage(string text)
{
return new(ChatRole.Assistant, text.ToContentStream().ToList())
{
AuthorName = authorName,
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTimeOffset.UtcNow,
};
}
return result;
}
}

View File

@@ -0,0 +1,243 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Tests for <see cref="ChatProtocolExecutor"/> to verify message routing behavior.
/// </summary>
public class ChatProtocolExecutorTests
{
private sealed class TestChatProtocolExecutor : ChatProtocolExecutor
{
public List<ChatMessage> ReceivedMessages { get; } = [];
public int TurnCount { get; private set; }
public TestChatProtocolExecutor(string id = "test-executor", ChatProtocolExecutorOptions? options = null)
: base(id, options)
{
}
protected override async ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
{
this.ReceivedMessages.AddRange(messages);
this.TurnCount++;
// Send messages back to context so they can be collected
await context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
}
[Fact]
public void ChatProtocolExecutor_DescribedProtocol_IsChatProtocol()
{
// Arrange
TestChatProtocolExecutor executor = new();
ProtocolDescriptor protocol = executor.DescribeProtocol();
// Act & Assert
protocol.Should().Match<ProtocolDescriptor>(protocol => protocol.IsChatProtocol());
}
[Fact]
public async Task ChatProtocolExecutor_Handles_ListOfChatMessagesAsync()
{
// Arrange
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.User, "World")
];
// Act - Send List<ChatMessage> via ExecuteAsync
await executor.ExecuteAsync(messages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
executor.ReceivedMessages.Should().HaveCount(2);
executor.ReceivedMessages[0].Text.Should().Be("Hello");
executor.ReceivedMessages[1].Text.Should().Be("World");
executor.TurnCount.Should().Be(1);
}
[Fact]
public async Task ChatProtocolExecutor_Handles_ArrayOfChatMessagesAsync()
{
// Arrange
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
ChatMessage[] messages =
[
new ChatMessage(ChatRole.System, "System message"),
new ChatMessage(ChatRole.User, "User query"),
new ChatMessage(ChatRole.Assistant, "Agent reply")
];
// Act - Send as ChatMessage[]
await executor.ExecuteAsync(messages, new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
executor.ReceivedMessages.Should().HaveCount(3);
executor.ReceivedMessages[0].Role.Should().Be(ChatRole.System);
executor.ReceivedMessages[1].Role.Should().Be(ChatRole.User);
executor.ReceivedMessages[2].Role.Should().Be(ChatRole.Assistant);
executor.TurnCount.Should().Be(1);
}
[Fact]
public async Task ChatProtocolExecutor_Handles_SingleChatMessageAsync()
{
// Arrange
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
var message = new ChatMessage(ChatRole.User, "Single message");
// Act - Send as single ChatMessage
await executor.ExecuteAsync(message, new TypeId(typeof(ChatMessage)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
executor.ReceivedMessages.Should().HaveCount(1);
executor.ReceivedMessages[0].Text.Should().Be("Single message");
executor.TurnCount.Should().Be(1);
}
[Fact]
public async Task ChatProtocolExecutor_AccumulatesAndClearsMessagesPerTurnAsync()
{
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
// Send multiple message batches before taking a turn
await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context);
await executor.ExecuteAsync(new List<ChatMessage>
{
new(ChatRole.User, "Message 2"),
new(ChatRole.User, "Message 3")
}, new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteAsync(new ChatMessage[] { new(ChatRole.User, "Message 4") }, new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(4);
executor.ReceivedMessages.Select(m => m.Text).Should().Equal("Message 1", "Message 2", "Message 3", "Message 4");
executor.TurnCount.Should().Be(1);
executor.ReceivedMessages.Clear();
// Second turn should process new messages only
await executor.ExecuteAsync(new List<ChatMessage>
{
new(ChatRole.User, "Second batch")
}, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
executor.ReceivedMessages[0].Text.Should().Be("Second batch");
executor.TurnCount.Should().Be(2);
}
[Fact]
public async Task ChatProtocolExecutor_WithStringRole_ConvertsStringToMessageAsync()
{
TestChatProtocolExecutor executor = new(
options: new ChatProtocolExecutorOptions
{
StringMessageChatRole = ChatRole.User
});
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync("String message", new TypeId(typeof(string)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
executor.ReceivedMessages[0].Role.Should().Be(ChatRole.User);
executor.ReceivedMessages[0].Text.Should().Be("String message");
}
[Fact]
public async Task ChatProtocolExecutor_EmptyCollection_HandledCorrectlyAsync()
{
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage>(), new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteAsync(Array.Empty<ChatMessage>(), new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().BeEmpty();
executor.TurnCount.Should().Be(1);
}
[Theory]
[InlineData(typeof(List<ChatMessage>))]
[InlineData(typeof(ChatMessage[]))]
public async Task ChatProtocolExecutor_RoutesCollectionTypesAsync(Type collectionType)
{
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
var sourceMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
object messagesToSend = collectionType == typeof(List<ChatMessage>) ? sourceMessages.ToList() : sourceMessages;
await executor.ExecuteAsync(messagesToSend, new TypeId(collectionType), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
executor.ReceivedMessages[0].Text.Should().Be("Test message");
}
[Fact]
public async Task ChatProtocolExecutor_MultipleTurns_EachTurnProcessesSeparatelyAsync()
{
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage> { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Turn 2"), new TypeId(typeof(ChatMessage)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(2);
executor.ReceivedMessages[0].Text.Should().Be("Turn 1");
executor.ReceivedMessages[1].Text.Should().Be("Turn 2");
executor.TurnCount.Should().Be(2);
}
[Fact]
public async Task ChatProtocolExecutor_InitialWorkflowMessages_RoutedCorrectlyAsync()
{
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
List<ChatMessage> initialMessages = [new ChatMessage(ChatRole.User, "Kick off the workflow")];
await executor.ExecuteAsync(initialMessages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().NotBeEmpty();
executor.ReceivedMessages.Should().HaveCount(1);
executor.ReceivedMessages[0].Text.Should().Be("Kick off the workflow");
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class EdgeMapSmokeTests
{
[Fact]
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
Dictionary<string, HashSet<Edge>> workflowEdges = [];
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
Edge fanInEdge = new(edgeData);
workflowEdges["executor1"] = [fanInEdge];
workflowEdges["executor2"] = [fanInEdge];
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
mapping.Should().BeNull();
mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part2", "executor2"));
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(2).And.AllSatisfy(delivery => delivery.TargetId.Should().Be("executor3"));
HashSet<string> expectedMessages = ["part1", "part2"];
foreach (MessageDelivery delivery in deliveries)
{
string message = delivery.Envelope.As<string>()!;
expectedMessages.Remove(message);
}
}
}

View File

@@ -0,0 +1,196 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class EdgeRunnerTests
{
private static async Task CreateAndRunDirectedEdgeTestAsync(bool? conditionMatch = null, bool? targetMatch = null)
{
const string MessageVariant1 = "test";
const string MessageVariant2 = "something else";
Func<object?, bool>? condition
= conditionMatch.HasValue
? message => message is string value && value.Equals(conditionMatch.Value
? MessageVariant1
: MessageVariant2, StringComparison.Ordinal)
: null;
string? targetId
= targetMatch.HasValue
? (targetMatch.Value ? "executor2" : "executor1")
: null;
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
DirectEdgeRunner runner = new(runContext, edgeData);
MessageEnvelope envelope = new(MessageVariant1, "executor1", targetId: targetId);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
bool expectMessage = (!conditionMatch.HasValue || conditionMatch.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
if (expectMessage)
{
mapping.Should().NotBeNull();
mapping.CheckDeliveries(["executor2"], [MessageVariant1]);
}
else
{
mapping.Should().BeNull();
}
}
[Fact]
public async Task Test_DirectEdgeRunnerAsync()
{
// Test matrix:
// NoCondition vs Condition(=> true) vs Condition(=> false)
// Untargeted vs Targeted(matching) vs Targeted(not matching)
await CreateAndRunDirectedEdgeTestAsync(); // NoCondition, Untargeted
await CreateAndRunDirectedEdgeTestAsync(targetMatch: true); // NoCondition, Targeted
await CreateAndRunDirectedEdgeTestAsync(targetMatch: false); // NoCondition, Targeted(not matching)
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: true); // Condition(=> true), Untargeted
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: false); // Condition(=> false), Untargeted
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: true, targetMatch: true); // Condition(=> true), Targeted(matching)
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: true, targetMatch: false); // Condition(=> true), Targeted(not matching)
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: false, targetMatch: true); // Condition(=> false), Targeted(matching)
await CreateAndRunDirectedEdgeTestAsync(conditionMatch: false, targetMatch: false); // Condition(=> false), Targeted(not matching)
}
private static async Task CreateAndRunFanOutEdgeTestAsync(bool? assignerSelectsEmpty = null, bool? targetMatch = null)
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
Func<object?, int, IEnumerable<int>>? assigner
= assignerSelectsEmpty.HasValue
? (message, count) => assignerSelectsEmpty.Value ? [] : [0]
: null;
string? targetId
= targetMatch.HasValue
? (targetMatch.Value ? "executor2" : "executor1")
: null;
FanOutEdgeData edgeData = new("executor1", ["executor2", "executor3"], new EdgeId(0), assigner);
FanOutEdgeRunner runner = new(runContext, edgeData);
MessageEnvelope envelope = new("test", "executor1", targetId: targetId);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
bool expectForwardFrom2 = (!assignerSelectsEmpty.HasValue || !assignerSelectsEmpty.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
bool expectForwardFrom3 = !assignerSelectsEmpty.HasValue && !targetMatch.HasValue; // if there is a target, it is never executor3
HashSet<string> expectedReceivers = [];
if (expectForwardFrom2)
{
expectedReceivers.Add("executor2");
}
if (expectForwardFrom3)
{
expectedReceivers.Add("executor3");
}
if (!expectForwardFrom2 && !expectForwardFrom3)
{
mapping.Should().BeNull();
}
else
{
mapping.Should().NotBeNull();
mapping.CheckDeliveries(expectedReceivers, ["test"]);
}
}
[Fact]
public async Task Test_FanOutEdgeRunnerAsync()
{
// Test matrix:
// NoAssigned vs Assigner(includes output) vs Assigner(does not include output)
// Untargeted vs Targeted(matching) vs Targeted(not matching)
await CreateAndRunFanOutEdgeTestAsync(); // NoAssigner, Untargeted
await CreateAndRunFanOutEdgeTestAsync(targetMatch: true); // NoAssigner, Targeted(matching)
await CreateAndRunFanOutEdgeTestAsync(targetMatch: false); // NoAssigner, Targeted(not matching)
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: false); // Assigner(includes output), Untargeted
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: true); // Assigner(does not include output), Untargeted
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: false, targetMatch: true); // Assigner(includes output), Targeted(matching)
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: false, targetMatch: false); // Assigner(includes output), Targeted(not matching)
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: true, targetMatch: true); // Assigner(does not include output), Targeted(matching)
await CreateAndRunFanOutEdgeTestAsync(assignerSelectsEmpty: true, targetMatch: false); // Assigner(does not include output), Targeted(not matching)
}
[Fact]
public async Task Test_FanInEdgeRunnerAsync()
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
// Step 1: Send message from executor1, should not forward yet.
// Step 2: Send targeted message to executor1 from executor2, should not forward
// Step 3: Send message from executor1, should not forward yet.
// Step 4: Send message from executor2, should forward now.
await RunIterationAsync();
// Repeat the same sequence, to ensure state is properly reset inside of FanInEdgeState.
runContext.QueuedMessages.Clear();
await RunIterationAsync();
async ValueTask RunIterationAsync()
{
//await runner.ChaseAsync("executor1", new("part1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(new("part1", "executor1"), stepTracer: null);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor2", new("part-for-1", targetId: "executor1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part-for-1", "executor2", targetId: "executor1"), stepTracer: null);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor1", new("part2", targetId: "executor3"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part2", "executor1", targetId: "executor3"), stepTracer: null);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor2", new("final part"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor3", ["part1", "part2", "final part"]));
mapping = await runner.ChaseEdgeAsync(new("final part", "executor2"), stepTracer: null);
mapping.Should().NotBeNull();
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal static class ExecutionExtensions
{
public static IWorkflowExecutionEnvironment ToWorkflowExecutionEnvironment(this ExecutionEnvironment environment)
{
return environment switch
{
ExecutionEnvironment.InProcess_OffThread => InProcessExecution.OffThread,
ExecutionEnvironment.InProcess_Lockstep => InProcessExecution.Lockstep,
ExecutionEnvironment.InProcess_Concurrent => InProcessExecution.Concurrent,
_ => throw new InvalidOperationException($"Unknown execution environment {environment}")
};
}
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class ForwardMessageExecutor<TMessage>(string id) : Executor(id) where TMessage : notnull
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TMessage>((message, ctx) => ctx.SendMessageAsync(message));
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class InMemoryJsonStore : JsonCheckpointStore
{
private readonly Dictionary<string, RunCheckpointCache<JsonElement>> _store = [];
private RunCheckpointCache<JsonElement> EnsureRunStore(string runId)
{
if (!this._store.TryGetValue(runId, out RunCheckpointCache<JsonElement>? runStore))
{
runStore = this._store[runId] = new();
}
return runStore;
}
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(string runId, JsonElement value, CheckpointInfo? parent = null)
{
return new(this.EnsureRunStore(runId).Add(runId, value));
}
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string runId, CheckpointInfo key)
{
if (!this.EnsureRunStore(runId).TryGet(key, out JsonElement result))
{
throw new KeyNotFoundException("Could not retrieve checkpoint with id {key.CheckpointId} for run {runId}");
}
return new(result);
}
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string runId, CheckpointInfo? withParent = null)
{
return new(this.EnsureRunStore(runId).Index);
}
}

View File

@@ -0,0 +1,196 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Tests for InProcessExecution to verify streaming and non-streaming execution behavior.
/// </summary>
public class InProcessExecutionTests
{
/// <summary>
/// The non-streaming version (RunAsync) should execute the workflow and produce events,
/// similar to the streaming version (StreamAsync + TrySendMessageAsync).
/// </summary>
[Fact]
public async Task RunAsyncShouldExecuteWorkflowAsync()
{
// Arrange: Create a simple agent that responds to messages
var agent = new SimpleTestAgent("test-agent");
var workflow = AgentWorkflowBuilder.BuildSequential(agent);
var inputMessage = new ChatMessage(ChatRole.User, "Hello");
// Act: Execute using non-streaming RunAsync
Run run = await InProcessExecution.RunAsync(workflow, new List<ChatMessage> { inputMessage });
// Assert: The workflow should have executed and produced events
RunStatus status = await run.GetStatusAsync();
status.Should().Be(RunStatus.Idle, "workflow should complete execution");
// The run should have events (at minimum, a WorkflowOutputEvent)
run.OutgoingEvents.Should().NotBeEmpty("workflow should produce events during execution");
// Check that we have an agent execution event
var agentEvents = run.OutgoingEvents.OfType<AgentResponseUpdateEvent>().ToList();
agentEvents.Should().NotBeEmpty("agent should have executed and produced update events");
// Check that we have output events
var outputEvents = run.OutgoingEvents.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().NotBeEmpty("workflow should produce output events");
}
/// <summary>
/// This test shows that the streaming version works correctly when TurnToken is sent following a message.
/// </summary>
[Fact]
public async Task StreamAsyncWithTurnTokenShouldExecuteWorkflowAsync()
{
// Arrange: Create a simple agent that responds to messages
var agent = new SimpleTestAgent("test-agent");
var workflow = AgentWorkflowBuilder.BuildSequential(agent);
var inputMessage = new ChatMessage(ChatRole.User, "Hello");
// Act: Execute using streaming version with TurnToken
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new List<ChatMessage> { inputMessage });
// Send TurnToken to actually trigger execution (this is the key step)
bool messageSent = await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
messageSent.Should().BeTrue("TurnToken should be accepted");
// Collect events
List<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert: The workflow should have executed and produced events
RunStatus status = await run.GetStatusAsync();
status.Should().Be(RunStatus.Idle, "workflow should complete execution");
events.Should().NotBeEmpty("workflow should produce events during execution");
// Check that we have agent execution events
var agentEvents = events.OfType<AgentResponseUpdateEvent>().ToList();
agentEvents.Should().NotBeEmpty("agent should have executed and produced update events");
// Check that we have output events
var outputEvents = events.OfType<WorkflowOutputEvent>().ToList();
outputEvents.Should().NotBeEmpty("workflow should produce output events");
}
/// <summary>
/// This test compares the behavior of RunAsync vs StreamAsync to highlight the difference.
/// Both should produce similar results, but as of issue #1315, RunAsync fails to execute.
/// </summary>
[Fact]
public async Task RunAsyncAndStreamAsyncShouldProduceSimilarResultsAsync()
{
// Arrange: Create the same workflow for both tests
var agent1 = new SimpleTestAgent("test-agent-1");
var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1);
var agent2 = new SimpleTestAgent("test-agent-2");
var workflow2 = AgentWorkflowBuilder.BuildSequential(agent2);
var inputMessage = new ChatMessage(ChatRole.User, "Test message");
// Act 1: Execute using RunAsync (non-streaming)
Run nonStreamingRun = await InProcessExecution.RunAsync(workflow1, new List<ChatMessage> { inputMessage });
var nonStreamingEvents = nonStreamingRun.OutgoingEvents.ToList();
// Act 2: Execute using StreamAsync (streaming) with TurnToken
await using StreamingRun streamingRun = await InProcessExecution.StreamAsync(workflow2, new List<ChatMessage> { inputMessage });
await streamingRun.TrySendMessageAsync(new TurnToken(emitEvents: true));
List<WorkflowEvent> streamingEvents = [];
await foreach (WorkflowEvent evt in streamingRun.WatchStreamAsync())
{
streamingEvents.Add(evt);
}
// Assert: Both should have produced events
// The streaming version works (we know this from the issue report)
streamingEvents.Should().NotBeEmpty("streaming version should produce events");
// The non-streaming version should also produce events (this is the bug being tested)
nonStreamingEvents.Should().NotBeEmpty("non-streaming version should also produce events");
// Both should have similar types of events
var streamingAgentEvents = streamingEvents.OfType<AgentResponseUpdateEvent>().Count();
var nonStreamingAgentEvents = nonStreamingEvents.OfType<AgentResponseUpdateEvent>().Count();
nonStreamingAgentEvents.Should().Be(streamingAgentEvents,
"both versions should produce the same number of agent events");
}
/// <summary>
/// Simple test agent that echoes back the input message.
/// </summary>
private sealed class SimpleTestAgent : AIAgent
{
public SimpleTestAgent(string name)
{
this.Name = name;
}
public override string Name { get; }
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) => new(new SimpleTestAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(System.Text.Json.JsonElement serializedThread,
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => new(new SimpleTestAgentThread());
protected override Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var lastMessage = messages.LastOrDefault();
var responseMessage = new ChatMessage(ChatRole.Assistant, $"Echo: {lastMessage?.Text ?? "no message"}");
return Task.FromResult(new AgentResponse(responseMessage));
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
var lastMessage = messages.LastOrDefault();
var responseText = $"Echo: {lastMessage?.Text ?? "no message"}";
string messageId = Guid.NewGuid().ToString("N");
// Yield role first
yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name)
{
AuthorName = this.Name,
MessageId = messageId
};
// Then yield content
yield return new AgentResponseUpdate(ChatRole.Assistant, responseText)
{
AuthorName = this.Name,
MessageId = messageId
};
}
}
/// <summary>
/// Simple thread implementation for SimpleTestAgent.
/// </summary>
private sealed class SimpleTestAgentThread : InMemoryAgentThread;
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class InProcessStateTests
{
private sealed class TurnToken
{
public int Count { get; }
public TurnToken() : this(0)
{ }
private TurnToken(int count)
{
this.Count = count;
}
public TurnToken Next => new(this.Count + 1);
}
private sealed class StateTestExecutor<TState> : TestingExecutor<TurnToken, TurnToken>
{
private static Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[] WrapActions(ScopeKey stateKey, Func<TState?, TState?>[] stateActions)
{
Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[] result
= new Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>>[stateActions.Length];
for (int i = 0; i < stateActions.Length; i++)
{
result[i] = CreateWrapper(stateActions[i]);
}
return result;
Func<TurnToken, IWorkflowContext, CancellationToken, ValueTask<TurnToken>> CreateWrapper(Func<TState?, TState?> action)
{
return
async (turn, context, cancellation) =>
{
TState? state = await context.ReadStateAsync<TState>(stateKey.Key, stateKey.ScopeId.ScopeName, cancellation)
.ConfigureAwait(false);
state = action(state);
await context.QueueStateUpdateAsync(stateKey.Key, state, stateKey.ScopeId.ScopeName, cancellation);
return turn.Next;
};
}
}
public ScopeKey StateKey { get; }
public StateTestExecutor(ScopeKey stateKey, bool loop = false, params Func<TState?, TState?>[] stateActions)
: base(stateKey.ScopeId.ExecutorId, loop, WrapActions(stateKey, stateActions))
{
this.StateKey = stateKey;
}
}
private static Func<int?, int?> CreateOrIncrement(int defaultValue = default)
=> currState => currState.HasValue ? currState + 1 : defaultValue;
private static Func<int?, int?> ValidateState(int expectedValue, string? because = null, params object[] becauseArgs)
=> currState =>
{
currState.Should().Be(expectedValue, because, becauseArgs);
return currState;
};
private static Func<object?, bool> MaxTurns(int maxTurns)
=> maybeTurn => maybeTurn is not TurnToken turn || turn.Count < maxTurns;
[Fact]
public async Task InProcessRun_StateShouldPersist_NotCheckpointedAsync()
{
StateTestExecutor<int?> writer = new(
new ScopeKey("Writer", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement(),
CreateOrIncrement()
);
StateTestExecutor<int?> validator = new(
new ScopeKey("Validator", "TestScope", "TestKey"),
loop: false,
ValidateState(0),
ValidateState(1)
);
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build();
Run run = await InProcessExecution.RunAsync<TurnToken>(workflow, new());
RunStatus status = await run.GetStatusAsync();
status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
}
[Fact]
public async Task InProcessRun_StateShouldPersist_CheckpointedAsync()
{
StateTestExecutor<int?> writer = new(
new ScopeKey("Writer", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement(),
CreateOrIncrement()
);
StateTestExecutor<int?> validator = new(
new ScopeKey("Validator", "TestScope", "TestKey"),
loop: false,
ValidateState(0),
ValidateState(1)
);
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build();
Checkpointed<Run> checkpointed = await InProcessExecution.RunAsync<TurnToken>(workflow, new(), CheckpointManager.Default);
checkpointed.Checkpoints.Should().HaveCount(4);
RunStatus status = await checkpointed.Run.GetStatusAsync();
status.Should().Be(RunStatus.Idle);
writer.Completed.Should().BeTrue();
validator.Completed.Should().BeTrue();
}
[Fact]
public async Task InProcessRun_StateShouldError_TwoExecutorsAsync()
{
ForwardMessageExecutor<TurnToken> forward = new(nameof(ForwardMessageExecutor<>));
using StateTestExecutor<int?> testExecutor = new(
new ScopeKey("StateTestExecutor", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement()
);
using StateTestExecutor<int?> testExecutor2 = new(
new ScopeKey("StateTestExecutor2", "TestScope", "TestKey"),
loop: false,
CreateOrIncrement()
);
Workflow workflow =
new WorkflowBuilder(forward)
.AddFanOutEdge(forward, targets: [testExecutor, testExecutor2])
.Build();
Run runWithFailure = await InProcessExecution.RunAsync(workflow, new TurnToken());
bool hadFailure = false;
foreach (WorkflowEvent evt in runWithFailure.NewEvents)
{
if (evt is WorkflowErrorEvent errorEvent)
{
hadFailure.Should().BeFalse("There can be only one!");
hadFailure = true;
errorEvent.Data.Should().BeOfType<InvalidOperationException>()
.Subject.Message.Should().Contain("TestKey");
}
}
hadFailure.Should().BeTrue();
//var act = async () => await InProcessExecution.RunAsync(workflow, new TurnToken());
//var result = await act.Should()
// .ThrowAsync("multiple writers to the same shared scope key");
}
}

View File

@@ -0,0 +1,675 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class JsonSerializationTests
{
private static JsonSerializerOptions TestCustomSerializedJsonOptions
{
get
{
JsonSerializerOptions options = new(TestJsonContext.Default.Options);
options.MakeReadOnly();
return options;
}
}
private static int s_nextEdgeId;
private static EdgeId TakeEdgeId() => new(Interlocked.Increment(ref s_nextEdgeId));
internal static T RunJsonRoundtrip<T>(T value, JsonSerializerOptions? externalOptions = null, Expression<Func<T, bool>>? predicate = null)
{
JsonMarshaller marshaller = new(externalOptions);
JsonElement element = marshaller.Marshal(value);
T deserialized = marshaller.Marshal<T>(element);
if (deserialized is not null)
{
if (predicate is not null)
{
deserialized.Should().Match(predicate);
}
return deserialized;
}
Debug.Fail($"Could not roundtrip type '{typeof(T).Name}'. JSON = '{element}'.");
throw new NotSupportedException($"Could not roundtrip type '{typeof(T).Name}'.");
}
[Fact]
public void Test_EdgeConnection_JsonRoundtrip()
{
EdgeConnection connection = new(["Source1", "Source2"], ["Sink1", "Sink2"]);
RunJsonRoundtrip(connection, predicate: connection.CreateValidator());
}
[Fact]
public void Test_TypeId_JsonRoundtrip()
{
TypeId type = new(typeof(Type));
RunJsonRoundtrip(type, predicate: CreateValidator());
Expression<Func<TypeId, bool>> CreateValidator()
{
return deserialized => deserialized.AssemblyName == type.AssemblyName &&
deserialized.TypeName == type.TypeName &&
deserialized.IsMatch<Type>();
}
}
[Fact]
public void Test_ExecutorInfo_JsonRoundtrip()
{
ExecutorInfo executorInfo = new(new(typeof(ForwardMessageExecutor<string>)), "ForwardString");
RunJsonRoundtrip(executorInfo, predicate: CreateValidator());
Expression<Func<ExecutorInfo, bool>> CreateValidator()
{
return deserialized => deserialized.ExecutorId == executorInfo.ExecutorId &&
// Rely on the TypeId test to probe TypeId serialization - just validate that we got a functional TypeId
deserialized.ExecutorType.IsMatch<ForwardMessageExecutor<string>>();
}
}
private static RequestPort TestPort => RequestPort.Create<string, int>("StringToInt");
private static RequestPortInfo TestPortInfo => TestPort.ToPortInfo();
[Fact]
public void Test_RequestPortInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestPortInfo, predicate: TestPort.CreatePortInfoValidator());
}
private static DirectEdgeInfo TestDirectEdgeInfo_NoCondition => new(new("SourceExecutor", "TargetExecutor", TakeEdgeId(), condition: null));
private static DirectEdgeInfo TestDirectEdgeInfo_Condition => new(new("SourceExecutor", "TargetExecutor", TakeEdgeId(), condition: msg => msg is not null));
[Fact]
public void Test_DirectEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestDirectEdgeInfo_NoCondition, predicate: TestDirectEdgeInfo_NoCondition.CreateValidator());
RunJsonRoundtrip(TestDirectEdgeInfo_Condition, predicate: TestDirectEdgeInfo_Condition.CreateValidator());
}
private static FanOutEdgeInfo TestFanOutEdgeInfo_NoAssigner => new(new("SourceExecutor", ["TargetExecutor1", "TargetExecutor2"], TakeEdgeId(), assigner: null));
private static FanOutEdgeInfo TestFanOutEdgeInfo_Assigner => new(new("SourceExecutor", ["TargetExecutor1", "TargetExecutor2"], TakeEdgeId(), assigner: (msg, count) => []));
[Fact]
public void Test_FanOutEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestFanOutEdgeInfo_NoAssigner, predicate: TestFanOutEdgeInfo_NoAssigner.CreateValidator());
RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator());
}
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null);
private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData);
[Fact]
public void Test_FanInEdgeInfo_JsonRoundtrip()
{
RunJsonRoundtrip(TestFanInEdgeInfo, predicate: TestFanInEdgeInfo.CreateValidator());
}
private static EdgeInfo TestEdgeInfo_DirectNoCondition { get; } = TestDirectEdgeInfo_NoCondition;
private static EdgeInfo TestEdgeInfo_DirectCondition { get; } = TestDirectEdgeInfo_Condition;
private static EdgeInfo TestEdgeInfo_FanOutNoAssigner { get; } = TestFanOutEdgeInfo_NoAssigner;
private static EdgeInfo TestEdgeInfo_FanOutAssigner { get; } = TestFanOutEdgeInfo_Assigner;
private static EdgeInfo TestEdgeInfo_FanIn { get; } = TestFanInEdgeInfo;
[Fact]
public void Test_EdgeInfoPolymorphism_JsonRoundtrip()
{
RunJsonRoundtrip(TestEdgeInfo_DirectNoCondition, predicate: TestEdgeInfo_DirectNoCondition.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_DirectCondition, predicate: TestEdgeInfo_DirectCondition.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanOutNoAssigner, predicate: TestEdgeInfo_FanOutNoAssigner.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanOutAssigner, predicate: TestEdgeInfo_FanOutAssigner.CreatePolyValidator());
RunJsonRoundtrip(TestEdgeInfo_FanIn, predicate: TestEdgeInfo_FanIn.CreatePolyValidator());
}
private const string ForwardStringId = nameof(s_forwardString);
private const string ForwardIntId = nameof(s_forwardInt);
private static readonly ExecutorIdentity s_forwardString = new() { Id = ForwardStringId };
private static readonly ExecutorIdentity s_forwardInt = new() { Id = ForwardIntId };
private const string IntToStringId = nameof(IntToString);
private const string StringToIntId = nameof(StringToInt);
private static RequestPortInfo IntToString => RequestPort.Create<int, string>(IntToStringId).ToPortInfo();
private static RequestPortInfo StringToInt => RequestPort.Create<string, int>(StringToIntId).ToPortInfo();
private static Workflow CreateTestWorkflow()
{
ForwardMessageExecutor<string> forwardString = new(ForwardStringId);
ForwardMessageExecutor<int> forwardInt = new(ForwardIntId);
RequestPort stringToInt = RequestPort.Create<string, int>(StringToIntId);
RequestPort intToString = RequestPort.Create<int, string>(IntToStringId);
WorkflowBuilder builder = new(forwardString);
builder.AddEdge(forwardString, stringToInt)
.AddEdge(stringToInt, forwardInt)
.AddEdge(forwardInt, intToString)
.AddEdge(intToString, StreamingAggregators.Last<int>().BindAsExecutor("Aggregate"));
return builder.Build();
}
internal static WorkflowInfo CreateTestWorkflowInfo()
{
Workflow testWorkflow = CreateTestWorkflow();
return testWorkflow.ToWorkflowInfo();
}
private static void ValidateWorkflowInfo(WorkflowInfo actual, WorkflowInfo prototype)
{
ValidateExecutorDictionary(prototype.Executors, prototype.Edges, actual.Executors, actual.Edges);
ValidateRequestPorts(prototype.RequestPorts, actual.RequestPorts);
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count)
.And.AllSatisfy(id => prototype.OutputExecutorIds.Contains(id));
void ValidateExecutorDictionary(Dictionary<string, ExecutorInfo> expected,
Dictionary<string, List<EdgeInfo>> expectedEdges,
Dictionary<string, ExecutorInfo> actual,
Dictionary<string, List<EdgeInfo>> actualEdges)
{
actual.Should().HaveCount(expected.Count);
actualEdges.Should().HaveCount(expectedEdges.Count);
foreach (string key in expected.Keys)
{
actual.Should().ContainKey(key);
ExecutorInfo actualValue = actual[key];
ExecutorInfo expectedValue = expected[key];
actualValue.Should().Match(expectedValue.CreateValidator());
if (expectedEdges.TryGetValue(key, out List<EdgeInfo>? expectedEdgeList))
{
List<EdgeInfo>? actualEdgeList = actualEdges.Should().ContainKey(key).WhoseValue;
actualEdgeList.Should().NotBeNull();
ValidateExecutorEdges(expectedEdgeList, actualEdgeList);
}
}
}
void ValidateExecutorEdges(List<EdgeInfo> expected, List<EdgeInfo> actual)
{
actual.Should().HaveCount(expected.Count);
foreach (EdgeInfo expectedEdge in expected)
{
actual.Should().ContainSingle(edge => edge.CreatePolyValidator().Compile()(edge));
}
}
void ValidateRequestPorts(HashSet<RequestPortInfo> expected, HashSet<RequestPortInfo> actual)
=> actual.Should().HaveCount(expected.Count).And.IntersectWith(expected);
}
[Fact]
public async Task Test_WorkflowInfo_JsonRoundtripAsync()
{
WorkflowInfo prototype = CreateTestWorkflowInfo();
JsonMarshaller marshaller = new();
JsonElement jsonElement = marshaller.Marshal(prototype);
WorkflowInfo deserialized = marshaller.Marshal<WorkflowInfo>(jsonElement);
ValidateWorkflowInfo(deserialized, prototype);
}
private static ExecutorIdentity TestIdentity => new() { Id = "Executor1" };
[Fact]
public void Test_ExecutorIdentity_JsonRoundtrip()
{
RunJsonRoundtrip(TestIdentity, predicate: TestIdentity.CreateValidator());
RunJsonRoundtrip(ExecutorIdentity.None, predicate: ExecutorIdentity.None.CreateValidator());
}
private static ScopeId TestScopeId_Private => new("Executor1", null);
private static ScopeId TestScopeId_Public => new("Executor1", "Scope1");
[Fact]
public void Test_ScopeId_JsonRoundtrip()
{
RunJsonRoundtrip(TestScopeId_Private, predicate: TestScopeId_Private.CreateValidator());
RunJsonRoundtrip(TestScopeId_Public, predicate: TestScopeId_Public.CreateValidator());
}
private static ScopeKey TestScopeKey_Private => new(TestScopeId_Private, "Key1");
private static ScopeKey TestScopeKey_Public => new(TestScopeId_Public, "Key1");
[Fact]
public void Test_ScopeKey_JsonRoundtrip()
{
RunJsonRoundtrip(TestScopeKey_Private, predicate: TestScopeKey_Private.CreateValidator());
RunJsonRoundtrip(TestScopeKey_Public, predicate: TestScopeKey_Public.CreateValidator());
}
private static ExternalRequest TestExternalRequest => ExternalRequest.Create(TestPort, "Request1", "TestData");
[Fact]
public void SanityCheck_JsonTypeInfo()
{
JsonTypeInfo? info = WorkflowsJsonUtilities.JsonContext.Default.GetTypeInfo(typeof(string));
info.Should().NotBeNull();
}
[Fact]
public void Test_PortableValue_JsonRoundtrip_BuiltInType()
{
PortableValue value = new("TestString");
PortableValue result = RunJsonRoundtrip(value);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
string? extracted = result.As<string>();
extracted.Should().Be("TestString");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
[Fact]
public void Test_PortableValue_JsonRoundTrip_InternalType()
{
ChatMessage message = new(ChatRole.User, "Hello, world!");
PortableValue value = new(message);
PortableValue result = RunJsonRoundtrip(value);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
ChatMessage? chatMessage = result.As<ChatMessage>();
chatMessage.Should().NotBeNull();
chatMessage.Role.Should().Be(ChatRole.User);
chatMessage.Text.Should().Be("Hello, world!");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
[Fact]
public void Test_PortableValue_JsonRoundTrip_CustomType()
{
TestJsonSerializable test = new() { Id = 42, Name = "Test" };
PortableValue value = new(test);
PortableValue result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
result.Should().Be(value);
// Also validate that we can extract the value as the correct type
TestJsonSerializable? extracted = result.As<TestJsonSerializable>();
extracted.Should().NotBeNull();
extracted.Id.Should().Be(42);
extracted.Name.Should().Be("Test");
// And that we can't extract it as an incorrect type
result.Is<int>().Should().BeFalse();
}
private static void ValidateExternalRequest(ExternalRequest actual, ExternalRequest expected)
{
bool isIdEqual = actual.RequestId == expected.RequestId;
bool isPortEqual = actual.PortInfo == expected.PortInfo;
bool isDataEqual = actual.Data == expected.Data;
isIdEqual.Should().BeTrue();
isPortEqual.Should().BeTrue();
isDataEqual.Should().BeTrue();
}
[Fact]
public void Test_ExternalRequest_JsonRoundtrip()
{
ExternalRequest result = RunJsonRoundtrip(TestExternalRequest);
ValidateExternalRequest(result, TestExternalRequest);
}
private static ExternalResponse TestExternalResponse => TestExternalRequest.CreateResponse(123);
[Fact]
public void Test_ExternalResponse_JsonRoundtrip()
{
ExternalResponse result = RunJsonRoundtrip(TestExternalResponse);
bool isIdEqual = result.RequestId == TestExternalResponse.RequestId;
bool isPortEqual = result.PortInfo == TestExternalResponse.PortInfo;
bool isDataEqual = result.Data == TestExternalResponse.Data;
isIdEqual.Should().BeTrue();
isPortEqual.Should().BeTrue();
isDataEqual.Should().BeTrue();
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_BuiltInType()
{
const string Message = "TestMessage";
MessageEnvelope envelope = new(Message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
reconstructed.Message.Should().Be(envelope.Message);
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_InternalType()
{
ChatMessage message = new(ChatRole.User, "Hello, world!");
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
// Unfortunately, ChatMessage does not contain an "equality" comparer, so we need to explicitly pull it out
// Simulate what PortableValue does in .Equals()
Type expectedType = envelope.Message.GetType();
object? maybeReconstructedMessage = ((PortableValue)reconstructed.Message)!.AsType(expectedType);
maybeReconstructedMessage.Should().NotBeNull()
.And.BeOfType<ChatMessage>()
.And.Match(message.CreateValidatorCheckingText());
}
[Fact]
public void Test_PortableMessageEnvelope_JsonRoundtrip_CustomType()
{
TestJsonSerializable message = new() { Id = 42, Name = "Test" };
MessageEnvelope envelope = new(message, "Source1", new TypeId(typeof(object)), targetId: "Target1");
PortableMessageEnvelope value = new(envelope);
PortableMessageEnvelope result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
bool isTypeEqual = result.MessageType == value.MessageType;
bool isTargetEqual = result.TargetId == value.TargetId;
bool isMessageEqual = result.Message == value.Message;
isTypeEqual.Should().BeTrue();
isTargetEqual.Should().BeTrue();
isMessageEqual.Should().BeTrue();
MessageEnvelope reconstructed = result.ToMessageEnvelope();
reconstructed.MessageType.Should().Be(envelope.MessageType);
reconstructed.TargetId.Should().Be(envelope.TargetId);
reconstructed.Message.Should().Be(envelope.Message);
}
private static RunnerStateData TestRunnerStateData
{
get
{
return new(
[ForwardStringId, ForwardIntId],
CreateQueuedMessages(),
outstandingRequests: [TestExternalRequest]
);
static Dictionary<string, List<PortableMessageEnvelope>> CreateQueuedMessages()
{
Dictionary<string, List<PortableMessageEnvelope>> result = [];
MessageEnvelope internalEnvelope = new("InternalMessage", "TestExecutor1");
result.Add("TestExecutor2", [new(internalEnvelope)]);
return result;
}
}
}
private static void ValidateRunnerStateData(RunnerStateData result, RunnerStateData prototype)
{
Assert.Collection(result.InstantiatedExecutors,
prototype.InstantiatedExecutors.Select(
prototype =>
(Action<string>)(actual => actual.Should().Be(prototype))).ToArray());
result.QueuedMessages.Should().HaveCount(prototype.QueuedMessages.Count);
foreach (string key in prototype.QueuedMessages.Keys)
{
result.QueuedMessages.Should().ContainKey(key);
List<PortableMessageEnvelope> actualList = result.QueuedMessages[key];
List<PortableMessageEnvelope> expectedList = prototype.QueuedMessages[key];
actualList.Should().HaveCount(expectedList.Count);
for (int i = 0; i < expectedList.Count; i++)
{
PortableMessageEnvelope actual = actualList[i];
PortableMessageEnvelope expected = expectedList[i];
actual.MessageType.Should().Be(expected.MessageType);
actual.TargetId.Should().Be(expected.TargetId);
actual.Message.Should().Be(expected.Message);
}
}
result.OutstandingRequests.Should().HaveCount(prototype.OutstandingRequests.Count);
Assert.Collection(result.OutstandingRequests,
prototype.OutstandingRequests.Select(
expected =>
(Action<ExternalRequest>)(actual => ValidateExternalRequest(actual, expected))).ToArray());
}
[Fact]
public void Test_RunnerStateData_JsonRoundtrip()
{
RunnerStateData prototype = TestRunnerStateData;
RunnerStateData result = RunJsonRoundtrip(prototype);
ValidateRunnerStateData(result, prototype);
}
private static FanInEdgeState TestFanInEdgeState => new(TestFanInEdgeData);
private static PortableValue CreateEdgeState<TMessage>(TMessage message) where TMessage : notnull
{
FanInEdgeState state = TestFanInEdgeState;
_ = state.ProcessMessage("SourceExecutor1", new MessageEnvelope(message, "SourceExecutor1", typeof(TMessage)));
return new(state);
}
private static TestJsonSerializable TestCustomSerializable => new() { Id = 42, Name = nameof(TestCustomSerializable) };
private static Dictionary<EdgeId, PortableValue> TestEdgeState
{
get
{
return new()
{
[TakeEdgeId()] = CreateEdgeState("Hello, world!"),
[TakeEdgeId()] = CreateEdgeState(TestExternalResponse),
[TakeEdgeId()] = CreateEdgeState(TestCustomSerializable)
};
}
}
private static void ValidateEdgeStateData(Dictionary<EdgeId, PortableValue> result, Dictionary<EdgeId, PortableValue> prototype)
{
result.Should().HaveCount(prototype.Count);
foreach (EdgeId id in prototype.Keys)
{
result.Should().ContainKey(id)
.And.Subject[id].Should().Be(prototype[id])
.And.Subject.As<PortableValue>()
.As<FanInEdgeState>().Should().NotBeNull()
.And.Match(CreateValidator(prototype[id].As<FanInEdgeState>()!));
}
Expression<Func<FanInEdgeState, bool>> CreateValidator(FanInEdgeState prototype)
{
return actual => actual.Unseen.SetEquals(prototype.Unseen) &&
actual.SourceIds.SequenceEqual(prototype.SourceIds) &&
actual.PendingMessages.Zip(prototype.PendingMessages,
(actualMessage, expectedMessage) => actualMessage.MessageType == expectedMessage.MessageType &&
actualMessage.TargetId == expectedMessage.TargetId &&
actualMessage.Message.Equals(expectedMessage.Message)).All(v => v);
}
}
[Fact]
public void Test_EdgeStateData_JsonRoundtrip()
{
Dictionary<EdgeId, PortableValue> value = TestEdgeState;
Dictionary<EdgeId, PortableValue> result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
ValidateEdgeStateData(result, value);
}
private static ScopeKey TestScopeKey1 => new(StringToIntId, null, "Key1");
private static ScopeKey TestScopeKey2 => new(StringToIntId, "Shared", "Key2");
private static ScopeKey TestScopeKey3 => new(IntToStringId, "Shared", "Key3");
private static ChatMessage TestUserMessage => new(ChatRole.User, "Hello");
private static Dictionary<ScopeKey, PortableValue> TestStateData
{
get
{
return new()
{
[TestScopeKey1] = new("Lorem Ipsum"),
[TestScopeKey2] = new(TestUserMessage),
[TestScopeKey3] = new(TestCustomSerializable)
};
}
}
private static void ValidateStateData(Dictionary<ScopeKey, PortableValue> result, Dictionary<ScopeKey, PortableValue> prototype)
{
result.Should().HaveCount(prototype.Count);
foreach (ScopeKey key in prototype.Keys)
{
PortableValue state =
result.Should().ContainKey(key)
.And.Subject[key].Should().Be(prototype[key])
.And.Subject.As<PortableValue>();
switch (key.Key)
{
case "Key1":
state.As<string>().Should().Be("Lorem Ipsum");
break;
case "Key2":
ChatMessage? maybeMessage = state.As<ChatMessage>();
maybeMessage.Should().NotBeNull()
.And.Match(TestUserMessage.CreateValidatorCheckingText());
break;
case "Key3":
state.As<TestJsonSerializable>().Should().Be(TestCustomSerializable);
break;
default:
throw new NotImplementedException($"Missing validation for key '{key.Key}'");
}
}
}
[Fact]
public void Test_ExecutorStateData_JsonRoundTrip()
{
Dictionary<ScopeKey, PortableValue> value = TestStateData;
Dictionary<ScopeKey, PortableValue> result = RunJsonRoundtrip(value, TestCustomSerializedJsonOptions);
ValidateStateData(result, value);
}
private static readonly string s_runId = Guid.NewGuid().ToString("N");
private static readonly string s_parentCheckpointId = Guid.NewGuid().ToString("N");
private static CheckpointInfo TestParentCheckpointInfo => new(s_runId, s_parentCheckpointId);
private static void ValidateCheckpoint(Checkpoint result, Checkpoint prototype)
{
result.Should().Match((Checkpoint checkpoint) => checkpoint.StepNumber == prototype.StepNumber);
result.Parent.Should().Be(prototype.Parent);
ValidateWorkflowInfo(result.Workflow, prototype.Workflow);
ValidateRunnerStateData(result.RunnerData, prototype.RunnerData);
ValidateStateData(result.StateData, prototype.StateData);
ValidateEdgeStateData(result.EdgeStateData, prototype.EdgeStateData);
}
[Fact]
public async Task Test_Checkpoint_JsonRoundTripAsync()
{
WorkflowInfo testWorkflowInfo = CreateTestWorkflowInfo();
Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
Checkpoint result = RunJsonRoundtrip(prototype, TestCustomSerializedJsonOptions);
ValidateCheckpoint(result, prototype);
}
[Fact]
public async Task Test_InMemoryCheckpointManager_JsonRoundTripAsync()
{
WorkflowInfo testWorkflowInfo = CreateTestWorkflowInfo();
Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
string runId = Guid.NewGuid().ToString("N");
InMemoryCheckpointManager manager = new();
CheckpointInfo checkpointInfo = await manager.CommitCheckpointAsync(runId, prototype);
InMemoryCheckpointManager result = RunJsonRoundtrip(manager, TestCustomSerializedJsonOptions);
Checkpoint? retrievedCheckpoint = await result.LookupCheckpointAsync(runId, checkpointInfo);
ValidateCheckpoint(retrievedCheckpoint, prototype);
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal static class MessageDeliveryValidation
{
public static void CheckDeliveries(this DeliveryMapping mapping, HashSet<string> receiverIds, HashSet<object> messages)
{
HashSet<string> unseenReceivers = [.. receiverIds];
HashSet<object> unseenMessages = [.. messages];
foreach (IGrouping<string, MessageDelivery> grouping in mapping.Deliveries.GroupBy(delivery => delivery.TargetId))
{
string receiverId = grouping.Key;
receiverIds.Should().Contain(receiverId);
unseenReceivers.Remove(grouping.Key);
foreach (MessageDelivery delivery in grouping)
{
object messageValue;
if (delivery.Envelope.Message is PortableValue portableValue)
{
portableValue.IsDelayedDeserialization.Should().BeFalse();
messageValue = portableValue.Value;
}
else
{
messageValue = delivery.Envelope.Message;
}
messages.Should().Contain(messageValue);
unseenMessages.Remove(messageValue);
}
}
unseenReceivers.Should().BeEmpty();
unseenMessages.Should().BeEmpty();
}
public static void CheckForwarded(Dictionary<string, List<MessageEnvelope>> queuedMessages, params (string expectedSender, List<string> expectedMessages)[] expectedForwards)
{
queuedMessages.Should().HaveCount(expectedForwards.Length);
IEnumerable<Action<string>> perSenderValidations = expectedForwards.Select(
(forward) =>
{
(string expectedSender, List<string> expectedMessages) = forward;
return (Action<string>)(
senderId =>
{
senderId.Should().Be(expectedSender);
queuedMessages[senderId].Should().HaveCount(expectedMessages.Count);
Action<MessageEnvelope>[] validations
= expectedMessages.Select(message => (Action<MessageEnvelope>)(envelope => envelope!.Message.Should().Be(message)))
.ToArray();
Assert.Collection(queuedMessages[senderId], validations);
});
}
);
Assert.Collection(queuedMessages.Keys, perSenderValidations.ToArray());
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class MessageMergerTests
{
public static string TestAgentId1 => "TestAgent1";
public static string TestAgentId2 => "TestAgent2";
public static string TestAuthorName1 => "Assistant1";
public static string TestAuthorName2 => "Assistant2";
[Fact]
public void Test_MessageMerger_AssemblesMessage()
{
DateTimeOffset creationTime = DateTimeOffset.UtcNow;
string responseId = Guid.NewGuid().ToString("N");
string messageId = Guid.NewGuid().ToString("N");
MessageMerger merger = new();
foreach (AgentResponseUpdate update in "Hello Agent Framework Workflows!".ToAgentRunStream(authorName: TestAuthorName1, agentId: TestAgentId1, messageId: messageId, createdAt: creationTime, responseId: responseId))
{
merger.AddUpdate(update);
}
AgentResponse response = merger.ComputeMerged(responseId);
response.Messages.Should().HaveCount(1);
response.Messages[0].Role.Should().Be(ChatRole.Assistant);
response.Messages[0].AuthorName.Should().Be(TestAuthorName1);
response.AgentId.Should().Be(TestAgentId1);
response.CreatedAt.Should().NotBe(creationTime);
response.Messages[0].CreatedAt.Should().Be(creationTime);
response.Messages[0].Contents.Should().HaveCount(1);
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// These tests ensure that OpenTelemetry Activity traces are properly created for workflow monitoring.
/// Tests are run in a collection to avoid parallel execution since ActivityListener is global.
/// Each test creates a new instance of ObservabilityTests and runs in serial within the collection.
/// This prevents interference between tests due to the global nature of ActivityListener.
/// </summary>
[Collection("ObservabilityTests")]
public sealed class ObservabilityTests : IDisposable
{
private readonly ActivityListener _activityListener;
private readonly ConcurrentBag<Activity> _capturedActivities = [];
private bool _isDisposed;
public ObservabilityTests()
{
// Set up activity listener to capture activities from workflow
// This is global and captures ALL workflow activities from ANY test in the same process!
this._activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name.Contains(typeof(Workflow).Namespace!),
Sample = (ref options) => ActivitySamplingResult.AllData,
ActivityStarted = activity => this._capturedActivities.Add(activity),
};
ActivitySource.AddActivityListener(this._activityListener);
}
/// <summary>
/// Create a sample workflow for testing.
/// </summary>
/// <remarks>
/// This workflow is expected to create 8 activities that will be captured by the tests
/// - ActivityNames.WorkflowBuild
/// - ActivityNames.WorkflowRun
/// -- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (UppercaseExecutor)
/// --- ActivityNames.MessageSend
/// ---- ActivityNames.EdgeGroupProcess
/// -- ActivityNames.ExecutorProcess (ReverseTextExecutor)
/// --- ActivityNames.MessageSend
/// </remarks>
/// <returns>The created workflow.</returns>
private static Workflow CreateWorkflow()
{
// Create the executors
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
Func<string, string> reverseFunc = s => new string(s.Reverse().ToArray());
var reverse = reverseFunc.BindAsExecutor("ReverseTextExecutor");
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
return builder.Build();
}
private static Dictionary<string, int> GetExpectedActivityNameCounts() =>
new()
{
{ ActivityNames.WorkflowBuild, 1 },
{ ActivityNames.WorkflowRun, 1 },
{ ActivityNames.EdgeGroupProcess, 2 },
{ ActivityNames.ExecutorProcess, 2 },
{ ActivityNames.MessageSend, 2 }
};
private static InProcessExecutionEnvironment GetExecutionEnvironment(string name) =>
name switch
{
"Default" => InProcessExecution.Default,
"Lockstep" => InProcessExecution.Lockstep,
"OffThread" => InProcessExecution.OffThread,
"Concurrent" => InProcessExecution.Concurrent,
_ => throw new ArgumentException($"Unknown execution environment name: {name}")
};
public void Dispose()
{
if (!this._isDisposed)
{
this._activityListener?.Dispose();
this._isDisposed = true;
}
}
private async Task TestWorkflowEndToEndActivitiesAsync(string executionEnvironmentName)
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
var workflow = CreateWorkflow();
var executionEnvironment = GetExecutionEnvironment(executionEnvironmentName);
Run run = await executionEnvironment.RunAsync(workflow, "Hello, World!");
await run.DisposeAsync();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(8, "Exactly 8 activities should be created.");
// Make sure all expected activities exist and have the correct count
foreach (var kvp in GetExpectedActivityNameCounts())
{
var activityName = kvp.Key;
var expectedCount = kvp.Value;
var actualCount = capturedActivities.Count(a => a.OperationName == activityName);
actualCount.Should().Be(expectedCount, $"Activity '{activityName}' should occur {expectedCount} times.");
}
// Verify WorkflowRun activity events include workflow lifecycle events
var workflowRunActivity = capturedActivities.First(a => a.OperationName == ActivityNames.WorkflowRun);
var activityEvents = workflowRunActivity.Events.ToList();
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowStarted, "activity should have workflow started event");
activityEvents.Should().Contain(e => e.Name == EventNames.WorkflowCompleted, "activity should have workflow completed event");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Default");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_OffThreadAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("OffThread");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_ConcurrentAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Concurrent");
}
[Fact]
public async Task CreatesWorkflowEndToEndActivities_WithCorrectName_LockstepAsync()
{
await this.TestWorkflowEndToEndActivitiesAsync("Lockstep");
}
[Fact]
public async Task CreatesWorkflowActivities_WithCorrectNameAsync()
{
// Arrange
// Create a test activity to correlate captured activities
using var testActivity = new Activity("ObservabilityTest").Start();
// Act
CreateWorkflow();
await Task.Delay(100); // Allow time for activities to be captured
// Assert
var capturedActivities = this._capturedActivities.Where(a => a.RootId == testActivity.RootId).ToList();
capturedActivities.Should().HaveCount(1, "Exactly 1 activity should be created.");
capturedActivities[0].OperationName.Should().Be(ActivityNames.WorkflowBuild,
"The activity should have the correct operation name for workflow build.");
var events = capturedActivities[0].Events.ToList();
events.Should().Contain(e => e.Name == EventNames.BuildStarted, "activity should have build started event");
events.Should().Contain(e => e.Name == EventNames.BuildValidationCompleted, "activity should have build validation completed event");
events.Should().Contain(e => e.Name == EventNames.BuildCompleted, "activity should have build completed event");
var tags = capturedActivities[0].Tags.ToDictionary(t => t.Key, t => t.Value);
tags.Should().ContainKey(Tags.WorkflowId);
tags.Should().ContainKey(Tags.WorkflowDefinition);
}
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class PortableValueTests
{
[SuppressMessage("Performance", "CA1812", Justification = "This is used as a Never/Bottom type.")]
private sealed class Never
{
private Never() { }
}
[Theory]
[InlineData("string")]
[InlineData(42)]
[InlineData(true)]
[InlineData(3.14)]
public async Task Test_PortableValueRoundtripAsync<T>(T value)
{
value.Should().NotBeNull();
PortableValue portableValue = new(value);
portableValue.Is<Never>(out _).Should().BeFalse();
portableValue.Is(out T? returnedValue).Should().BeTrue();
returnedValue.Should().Be(value);
}
[Fact]
public async Task Test_PortableValueRoundtripObjectAsync()
{
ChatMessage value = new(ChatRole.User, "Hello?");
PortableValue portableValue = new(value);
portableValue.Is<Never>(out _).Should().BeFalse();
portableValue.Is(out ChatMessage? returnedValue).Should().BeTrue();
returnedValue.Should().Be(value);
}
[Theory]
[InlineData("string")]
[InlineData(42)]
[InlineData(true)]
[InlineData(3.14)]
public async Task Test_DelayedSerializationRoundtripAsync<T>(T value)
{
value.Should().NotBeNull();
TestDelayedDeserialization<T> delayed = new(value);
PortableValue portableValue = new(delayed);
portableValue.Is<Never>(out _).Should().BeFalse();
portableValue.Is(out object? obj).Should().BeTrue();
obj.Should().NotBeOfType<T>();
obj.Should().BeOfType<PortableValue>()
.And.Subject.As<PortableValue>()
.As<T>().Should().Be(value);
portableValue.Is(out T? returnedValue).Should().BeTrue();
returnedValue.Should().Be(value);
}
[Fact]
public async Task Test_DelayedSerializationRoundtripObjectAsync()
{
ChatMessage value = new(ChatRole.User, "Hello?");
TestDelayedDeserialization<ChatMessage> delayed = new(value);
PortableValue portableValue = new(delayed);
portableValue.Is<Never>(out _).Should().BeFalse();
portableValue.Is(out object? obj).Should().BeTrue();
obj.Should().NotBeOfType<ChatMessage>();
obj.Should().BeOfType<PortableValue>()
.And.Subject.As<PortableValue>()
.As<ChatMessage>().Should().Be(value);
portableValue.Is(out ChatMessage? returnedValue).Should().BeTrue();
returnedValue.Should().Be(value);
}
private sealed class TestDelayedDeserialization<T> : IDelayedDeserialization
{
[NotNull]
public T Value { get; }
public TestDelayedDeserialization([DisallowNull] T value)
{
this.Value = value;
}
public TValue Deserialize<TValue>()
{
if (typeof(TValue) == typeof(object))
{
return (TValue)(object)new PortableValue(this.Value);
}
if (this.Value is TValue value)
{
return value;
}
throw new InvalidOperationException();
}
public object? Deserialize(Type targetType)
{
if (targetType == typeof(object))
{
return new PortableValue(this.Value);
}
if (targetType.IsInstanceOfType(this.Value))
{
return this.Value;
}
return null;
}
}
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Reflection;
using Moq;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class BaseTestExecutor<TActual>(string id) : ReflectingExecutor<TActual>(id) where TActual : ReflectingExecutor<TActual>
{
protected void OnInvokedHandler() => this.InvokedHandler = true;
public bool InvokedHandler
{
get;
private set;
}
}
public class DefaultHandler() : BaseTestExecutor<DefaultHandler>(nameof(DefaultHandler)), IMessageHandler<object>
{
public ValueTask HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this.OnInvokedHandler();
return this.Handler(message, context);
}
public Func<object, IWorkflowContext, ValueTask> Handler
{
get;
set;
} = (message, context) => default;
}
public class TypedHandler<TInput>() : BaseTestExecutor<TypedHandler<TInput>>(nameof(TypedHandler<>)), IMessageHandler<TInput>
{
public ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this.OnInvokedHandler();
return this.Handler(message, context);
}
public Func<TInput, IWorkflowContext, ValueTask> Handler
{
get;
set;
} = (message, context) => default;
}
public class TypedHandlerWithOutput<TInput, TResult>() : BaseTestExecutor<TypedHandlerWithOutput<TInput, TResult>>(nameof(TypedHandlerWithOutput<,>)), IMessageHandler<TInput, TResult>
{
public ValueTask<TResult> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
this.OnInvokedHandler();
return this.Handler(message, context);
}
public Func<TInput, IWorkflowContext, ValueTask<TResult>> Handler
{
get;
set;
} = (message, context) => default;
}
public class RoutingReflectionTests
{
private static async ValueTask<CallResult?> RunTestReflectAndRouteMessageAsync<TInput, TE>(BaseTestExecutor<TE> executor, TInput? input = default) where TInput : new() where TE : ReflectingExecutor<TE>
{
MessageRouter router = executor.Router;
Assert.NotNull(router);
input ??= new();
Assert.True(router.CanHandle(input.GetType()));
Assert.True(router.CanHandle(input));
CallResult? result = await router.RouteMessageAsync(input, Mock.Of<IWorkflowContext>());
Assert.True(executor.InvokedHandler);
return result;
}
[Fact]
public async Task Test_ReflectAndExecute_DefaultHandlerAsync()
{
DefaultHandler executor = new();
CallResult? result = await RunTestReflectAndRouteMessageAsync<object, DefaultHandler>(executor);
Assert.NotNull(result);
Assert.True(result.IsSuccess);
Assert.True(result.IsVoid);
}
[Fact]
public async Task Test_ReflectAndExecute_HandlerReturnsVoidAsync()
{
TypedHandler<int> executor = new();
CallResult? result = await RunTestReflectAndRouteMessageAsync<object, TypedHandler<int>>(executor, 3);
Assert.NotNull(result);
Assert.True(result.IsSuccess);
Assert.True(result.IsVoid);
}
[Fact]
public async Task Test_ReflectAndExecute_HandlerReturnsValueAsync()
{
TypedHandlerWithOutput<int, string> executor = new()
{
Handler = (message, context) => new ValueTask<string>($"{message}")
};
const string Expected = "3";
CallResult? result = await RunTestReflectAndRouteMessageAsync<object, TypedHandlerWithOutput<int, string>>(executor, int.Parse(Expected));
Assert.NotNull(result);
Assert.True(result.IsSuccess);
Assert.False(result.IsVoid);
Assert.Equal(Expected, result.Result);
}
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Sample;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class RepresentationTests
{
private sealed class TestExecutor() : Executor("TestExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder;
}
private sealed class TestAgent : AIAgent
{
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
private static RequestPort TestRequestPort =>
RequestPort.Create<FunctionCallContent, FunctionResultContent>("ExternalFunction");
private static async ValueTask RunExecutorBindingInfoMatchTestAsync(ExecutorBinding binding)
{
ExecutorInfo info = binding.ToExecutorInfo();
info.IsMatch(await binding.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
}
[Fact]
public async Task Test_ExecutorBinding_InfosAsync()
{
int testsRun = 0;
await RunExecutorBindingTestAsync(new TestExecutor());
await RunExecutorBindingTestAsync(TestRequestPort);
await RunExecutorBindingTestAsync(new TestAgent());
await RunExecutorBindingTestAsync(Step1EntryPoint.WorkflowInstance.BindAsExecutor(nameof(Step1EntryPoint)));
Func<int, IWorkflowContext, CancellationToken, ValueTask> function = MessageHandlerAsync;
await RunExecutorBindingTestAsync(function.BindAsExecutor("FunctionExecutor"));
Type bindingBaseType = typeof(ExecutorBinding);
Assembly workflowAssembly = bindingBaseType.Assembly;
int expectedTests = workflowAssembly.GetTypes()
.Count(type => type != bindingBaseType
&& bindingBaseType.IsAssignableFrom(type));
expectedTests.Should().BePositive();
if (expectedTests > testsRun + 1)
{
Assert.Fail("Not all ExecutorBinding types were tested.");
}
async ValueTask RunExecutorBindingTestAsync(ExecutorBinding binding)
{
await RunExecutorBindingInfoMatchTestAsync(binding);
testsRun++;
}
async ValueTask MessageHandlerAsync(int message, IWorkflowContext workflowContext, CancellationToken cancellationToken = default)
{
}
}
[Fact]
public async Task Test_SpecializedExecutor_InfosAsync()
{
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
}
private static string Source(int id) => $"Source/{id}";
private static string Sink(int id) => $"Sink/{id}";
private static Func<object?, bool> Condition() => Condition<object>();
private static Func<TIn?, bool> Condition<TIn>() => _ => true;
private static Func<object?, int, IEnumerable<int>> EdgeAssigner() => EdgeAssigner<object>();
private static Func<TIn?, int, IEnumerable<int>> EdgeAssigner<TIn>() => (_, _) => [];
[Fact]
public void Test_EdgeInfos()
{
int edgeId = 0;
// Direct Edges
Edge directEdgeNoCondition = new(new DirectEdgeData(Source(1), Sink(2), TakeEdgeId()));
RunEdgeInfoMatchTest(directEdgeNoCondition);
Edge directEdgeNoCondition2 = new(new DirectEdgeData(Source(1), Sink(2), TakeEdgeId()));
RunEdgeInfoMatchTest(directEdgeNoCondition, directEdgeNoCondition2);
Edge directEdgeNoCondition3 = new(new DirectEdgeData(Source(3), Sink(4), TakeEdgeId()));
RunEdgeInfoMatchTest(directEdgeNoCondition, directEdgeNoCondition3, expect: false);
Edge directEdgeWithCondition = new(new DirectEdgeData(Source(3), Sink(4), TakeEdgeId(), Condition()));
RunEdgeInfoMatchTest(directEdgeWithCondition);
RunEdgeInfoMatchTest(directEdgeNoCondition2, directEdgeWithCondition, expect: false);
RunEdgeInfoMatchTest(directEdgeNoCondition3, directEdgeWithCondition, expect: false);
// FanOut Edges
Edge fanOutEdgeNoAssigner = new(new FanOutEdgeData(Source(1), [Sink(2), Sink(3), Sink(4)], TakeEdgeId()));
RunEdgeInfoMatchTest(fanOutEdgeNoAssigner);
Edge fanOutEdgeNoAssigner2 = new(new FanOutEdgeData(Source(1), [Sink(2), Sink(3), Sink(4)], TakeEdgeId()));
RunEdgeInfoMatchTest(fanOutEdgeNoAssigner, fanOutEdgeNoAssigner2);
Edge fanOutEdgeNoAssigner3 = new(new FanOutEdgeData(Source(1), [Sink(3), Sink(4), Sink(2)], TakeEdgeId()));
RunEdgeInfoMatchTest(fanOutEdgeNoAssigner, fanOutEdgeNoAssigner3, expect: false); // Order matters (though without Assigner maybe it shouldn't?)
Edge fanOutEdgeNoAssigner4 = new(new FanOutEdgeData(Source(1), [Sink(2), Sink(3), Sink(5)], TakeEdgeId()));
Edge fanOutEdgeNoAssigner5 = new(new FanOutEdgeData(Source(2), [Sink(2), Sink(3), Sink(4)], TakeEdgeId()));
RunEdgeInfoMatchTest(fanOutEdgeNoAssigner, fanOutEdgeNoAssigner4, expect: false); // Identity matters
RunEdgeInfoMatchTest(fanOutEdgeNoAssigner, fanOutEdgeNoAssigner5, expect: false);
Edge fanOutEdgeWithAssigner = new(new FanOutEdgeData(Source(1), [Sink(2), Sink(3), Sink(4)], TakeEdgeId(), EdgeAssigner()));
RunEdgeInfoMatchTest(fanOutEdgeWithAssigner);
// FanIn Edges
Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge);
Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge2);
Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge3, expect: false); // Order matters (though for FanIn maybe it shouldn't?)
Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId(), null));
Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge4, expect: false); // Identity matters
RunEdgeInfoMatchTest(fanInEdge, fanInEdge5, expect: false);
static void RunEdgeInfoMatchTest(Edge edge, Edge? comparatorEdge = null, bool expect = true)
{
comparatorEdge ??= edge;
EdgeInfo info = edge.ToEdgeInfo();
info.IsMatch(comparatorEdge).Should().Be(expect);
}
EdgeId TakeEdgeId() => new(edgeId++);
}
[Fact]
public async Task Test_Sample_WorkflowInfosAsync()
{
RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step2EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step3EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step4EntryPoint.WorkflowInstance);
// Step 5 reuses the workflow from Step 4, so we don't need to test it separately.
RunWorkflowInfoMatchTest(Step6EntryPoint.CreateWorkflow(maxTurns: 2));
// Step 7 reuses the workflow from Step 6, so we don't need to test it separately.
RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance, Step2EntryPoint.WorkflowInstance, expect: false);
static void RunWorkflowInfoMatchTest(Workflow workflow, Workflow? comparator = null, bool expect = true)
{
comparator ??= workflow;
WorkflowInfo info = workflow.ToWorkflowInfo();
info.IsMatch(comparator).Should().Be(expect);
}
}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step1EntryPoint
{
public static Workflow WorkflowInstance
{
get
{
UppercaseExecutor uppercase = new();
ReverseTextExecutor reverse = new();
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
return builder.Build();
}
}
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
StreamingRun run = await environment.StreamAsync(WorkflowInstance, input: "Hello, World!").ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is ExecutorCompletedEvent executorCompleted)
{
writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
}
}
}
}
internal sealed class UppercaseExecutor() : ReflectingExecutor<UppercaseExecutor>("UppercaseExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
{
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
message.ToUpperInvariant();
}
internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExecutor>("ReverseTextExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
{
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string result = string.Concat(message.Reverse());
await context.YieldOutputAsync(result, cancellationToken).ConfigureAwait(false);
return result;
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using System.IO;
using System.Threading.Tasks;
using static Microsoft.Agents.AI.Workflows.Sample.Step1EntryPoint;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step1aEntryPoint
{
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
Run run = await environment.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
Assert.Equal(RunStatus.Idle, await run.GetStatusAsync());
foreach (WorkflowEvent evt in run.NewEvents)
{
if (evt is ExecutorCompletedEvent executorCompleted)
{
writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
}
}
}
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step2EntryPoint
{
public static Workflow WorkflowInstance
{
get
{
string[] spamKeywords = ["spam", "advertisement", "offer"];
DetectSpamExecutor detectSpam = new("DetectSpam", spamKeywords);
RespondToMessageExecutor respondToMessage = new("RespondToMessage");
RemoveSpamExecutor removeSpam = new("RemoveSpam");
return new WorkflowBuilder(detectSpam)
.AddEdge(detectSpam, respondToMessage, (bool isSpam) => !isSpam) // If not spam, respond
.AddEdge(detectSpam, removeSpam, (bool isSpam) => isSpam) // If spam, remove
.WithOutputFrom(respondToMessage, removeSpam)
.Build();
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, string input = "This is a spam message.")
{
StreamingRun handle = await environment.StreamAsync(WorkflowInstance, input: input).ConfigureAwait(false);
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
{
switch (evt)
{
case WorkflowOutputEvent workflowOutputEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowOutputEvt.As<string>()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompletedEvt:
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
break;
}
}
throw new InvalidOperationException("Workflow failed to yield an output.");
}
}
internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords) :
ReflectingExecutor<DetectSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<string, bool>
{
public async ValueTask<bool> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0);
}
internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<RespondToMessageExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
{
public const string ActionResult = "Message processed successfully.";
public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (message)
{
// This is SPAM, and should not have been routed here
throw new InvalidOperationException("Received a spam message that should not be getting a reply.");
}
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay
await context.YieldOutputAsync(ActionResult, cancellationToken)
.ConfigureAwait(false);
}
}
internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor<RemoveSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
{
public const string ActionResult = "Spam message removed.";
public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!message)
{
// This is NOT SPAM, and should not have been routed here
throw new InvalidOperationException("Received a non-spam message that should not be getting removed.");
}
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay
await context.YieldOutputAsync(ActionResult, cancellationToken)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,132 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step3EntryPoint
{
public static Workflow WorkflowInstance
{
get
{
GuessNumberExecutor guessNumber = new("GuessNumber", 1, 100);
JudgeExecutor judge = new("Judge", 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
.AddEdge(guessNumber, judge)
.AddEdge(judge, guessNumber)
.WithOutputFrom(guessNumber)
.Build();
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
StreamingRun run = await environment.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
switch (evt)
{
case WorkflowOutputEvent workflowOutputEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowOutputEvt.As<string>()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompletedEvt:
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
break;
}
}
throw new InvalidOperationException("Workflow failed to yield an output.");
}
}
internal sealed record TryCount(int Tries);
internal sealed record NumberBounds(int LowerBound, int UpperBound)
{
public int CurrGuess => (this.LowerBound + this.UpperBound) / 2;
public NumberBounds ForAboveHint() => this with { UpperBound = this.CurrGuess - 1 };
public NumberBounds ForBelowHint() => this with { LowerBound = this.CurrGuess + 1 };
}
internal enum NumberSignal
{
Init,
Above,
Below,
Matched
}
internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecutor>, IMessageHandler<NumberSignal, int>
{
private readonly int _initialLowerBound;
private readonly int _initialUpperBound;
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }, declareCrossRunShareable: true)
{
if (lowerBound >= upperBound)
{
throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be less than upper bound.");
}
this._initialLowerBound = lowerBound;
this._initialUpperBound = upperBound;
}
public async ValueTask<int> HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
NumberBounds bounds = await context.ReadStateAsync<NumberBounds>(nameof(NumberBounds), cancellationToken: cancellationToken)
.ConfigureAwait(false)
?? new NumberBounds(this._initialLowerBound, this._initialUpperBound);
switch (message)
{
case NumberSignal.Matched:
await context.YieldOutputAsync($"Guessed the number: {bounds.CurrGuess}", cancellationToken)
.ConfigureAwait(false);
break;
case NumberSignal.Above:
bounds = bounds.ForAboveHint();
break;
case NumberSignal.Below:
bounds = bounds.ForBelowHint();
break;
}
await context.QueueStateUpdateAsync(nameof(NumberBounds), bounds, cancellationToken: cancellationToken).ConfigureAwait(false);
return bounds.CurrGuess;
}
}
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>
{
private readonly int _targetNumber;
public JudgeExecutor(string id, int targetNumber) : base(id, declareCrossRunShareable: true)
{
this._targetNumber = targetNumber;
}
public async ValueTask<NumberSignal> HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// This works properly because the default when unset is 0, and we increment before use.
int tries = await context.ReadStateAsync<int>("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) + 1;
await context.YieldOutputAsync(new TryCount(tries), cancellationToken);
return
message == this._targetNumber ? NumberSignal.Matched :
message < this._targetNumber ? NumberSignal.Below :
NumberSignal.Above;
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step4EntryPoint
{
internal const string JudgeId = "Judge";
public static Workflow CreateWorkflowInstance(out JudgeExecutor judge)
{
RequestPort guessNumber = RequestPort.Create<NumberSignal, int>("GuessNumber");
judge = new(JudgeId, 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
.AddEdge(guessNumber, judge)
.AddEdge(judge, guessNumber, (NumberSignal signal) => signal != NumberSignal.Matched)
.WithOutputFrom(judge)
.Build();
}
public static Workflow WorkflowInstance
{
get
{
return CreateWorkflowInstance(out _);
}
}
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, IWorkflowExecutionEnvironment environment)
{
NumberSignal signal = NumberSignal.Init;
string? prompt = UpdatePrompt(null, signal);
Workflow workflow = WorkflowInstance;
StreamingRun handle = await environment.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
List<ExternalRequest> requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
{
switch (evt)
{
case WorkflowOutputEvent outputEvent:
switch (outputEvent.SourceId)
{
case JudgeId:
if (outputEvent.Is(out NumberSignal newSignal))
{
prompt = UpdatePrompt(prompt, signal = newSignal);
}
else if (!outputEvent.Is<TryCount>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
break;
}
break;
case RequestInfoEvent requestInputEvt:
requests.Add(requestInputEvt.Request);
break;
case SuperStepCompletedEvent stepCompletedEvent:
foreach (ExternalRequest request in requests)
{
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
await handle.SendResponseAsync(response).ConfigureAwait(false);
}
requests.Clear();
break;
case ExecutorCompletedEvent executorCompletedEvt:
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
break;
}
}
writer.WriteLine($"Result: {prompt}");
return prompt!;
}
private static ExternalResponse ExecuteExternalRequest(
ExternalRequest request,
Func<string, int> userGuessCallback,
string? runningState)
{
object result = request.PortInfo.PortId switch
{
"GuessNumber" => userGuessCallback(runningState ?? "Guess the number."),
_ => throw new NotSupportedException($"Request {request.PortInfo.PortId} is not supported")
};
return request.CreateResponse(result);
}
/// <summary>
/// This converts the incoming <see cref="NumberSignal"/> from the judge to a status text that can be displayed
/// to the user.
/// </summary>
/// <param name="runningResult"></param>
/// <param name="signal"></param>
/// <returns></returns>
internal static string? UpdatePrompt(string? runningResult, NumberSignal signal)
{
return signal switch
{
NumberSignal.Matched => "You guessed correctly! You Win!",
NumberSignal.Above => "Your guess was too high. Try again.",
NumberSignal.Below => "Your guess was too low. Try again.",
_ => runningResult
};
}
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step5EntryPoint
{
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, IWorkflowExecutionEnvironment environment, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null)
{
Dictionary<CheckpointInfo, (NumberSignal signal, string? prompt)> checkpointedOutputs = [];
NumberSignal signal = NumberSignal.Init;
string? prompt = Step4EntryPoint.UpdatePrompt(null, signal);
checkpointManager ??= CheckpointManager.Default;
Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge);
Checkpointed<StreamingRun> checkpointed =
await environment.StreamAsync(workflow, NumberSignal.Init, checkpointManager)
.ConfigureAwait(false);
List<CheckpointInfo> checkpoints = [];
CancellationTokenSource cancellationSource = new();
StreamingRun handle = checkpointed.Run;
string? result = await RunStreamToHaltOrMaxStepAsync(maxStep: 6).ConfigureAwait(false);
result.Should().BeNull();
checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step");
CheckpointInfo targetCheckpoint = checkpoints[2];
Console.WriteLine($"Restoring to checkpoint {targetCheckpoint} from run {targetCheckpoint.RunId}");
if (rehydrateToRestore)
{
await handle.DisposeAsync().ConfigureAwait(false);
checkpointed = await environment.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, cancellationToken: CancellationToken.None)
.ConfigureAwait(false);
handle = checkpointed.Run;
}
else
{
await checkpointed.RestoreCheckpointAsync(checkpoints[2], CancellationToken.None).ConfigureAwait(false);
}
(signal, prompt) = checkpointedOutputs[targetCheckpoint];
cancellationSource.Dispose();
cancellationSource = new();
checkpoints.Clear();
result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false);
result.Should().NotBeNull();
// Depending on the timing of the response with respect to the underlying workflow
// we may end up with an extra superstep in between.
checkpoints.Should().HaveCountGreaterThanOrEqualTo(6)
.And.HaveCountLessThanOrEqualTo(7);
cancellationSource.Dispose();
return result;
async ValueTask<string?> RunStreamToHaltOrMaxStepAsync(int? maxStep = null)
{
List<ExternalRequest> requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync(cancellationSource.Token).ConfigureAwait(false))
{
Console.WriteLine($"!!! Processing event: {evt}");
switch (evt)
{
case WorkflowOutputEvent outputEvent:
switch (outputEvent.SourceId)
{
case Step4EntryPoint.JudgeId:
if (outputEvent.Is(out NumberSignal newSignal))
{
prompt = Step4EntryPoint.UpdatePrompt(prompt, signal = newSignal);
}
// TODO: We should make some well-defined way to avoid this kind of
// if/elseif chain, because .Is() chains are slow
else if (!outputEvent.Is<TryCount>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
break;
}
break;
case RequestInfoEvent requestInputEvt:
Console.WriteLine($"!!! Queuing request: {requestInputEvt.Request}");
requests.Add(requestInputEvt.Request);
break;
case SuperStepCompletedEvent stepCompletedEvt:
Console.WriteLine($"*** Step {stepCompletedEvt.StepNumber} completed.");
CheckpointInfo? checkpoint = stepCompletedEvt.CompletionInfo!.Checkpoint;
Console.WriteLine($"*** Checkpoint: {checkpoint}");
if (checkpoint is not null)
{
checkpoints.Add(checkpoint);
checkpointedOutputs[checkpoint] = (signal, prompt);
}
if (maxStep.HasValue && stepCompletedEvt.StepNumber >= maxStep.Value - 1)
{
Console.WriteLine($"*** Max step {maxStep} reached, cancelling.");
cancellationSource.Cancel();
return null;
}
Console.WriteLine($"*** Processing {requests.Count} queued requests.");
foreach (ExternalRequest request in requests)
{
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
Console.WriteLine($"!!! Sending response: {response}");
await handle.SendResponseAsync(response).ConfigureAwait(false);
}
requests.Clear();
Console.WriteLine("*** Completed processing requests.");
break;
case ExecutorCompletedEvent executorCompleteEvt:
writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}");
break;
}
Console.WriteLine($"!!! Completed processing event: {evt.GetType()}");
}
if (cancellationSource.IsCancellationRequested)
{
return null;
}
writer.WriteLine($"Result: {prompt}");
return prompt!;
}
}
private static ExternalResponse ExecuteExternalRequest(
ExternalRequest request,
Func<string, int> userGuessCallback,
string? runningState)
{
object result = request.PortInfo.PortId switch
{
"GuessNumber" => userGuessCallback(runningState ?? "Guess the number."),
_ => throw new NotSupportedException($"Request {request.PortInfo.PortId} is not supported")
};
return request.CreateResponse(result);
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.UnitTests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step6EntryPoint
{
public const string EchoAgentId = "echo";
public const string EchoPrefix = "You said: ";
public static Workflow CreateWorkflow(int maxTurns) =>
AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns })
.AddParticipants(new HelloAgent(), new TestEchoAgent(id: EchoAgentId, prefix: EchoPrefix))
.Build();
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2)
{
Workflow workflow = CreateWorkflow(maxSteps);
StreamingRun run = await environment.StreamAsync(workflow, Array.Empty<ChatMessage>())
.ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is ExecutorCompletedEvent executorCompleted)
{
Debug.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
}
else if (evt is AgentResponseUpdateEvent update)
{
AgentResponse response = update.AsResponse();
foreach (ChatMessage message in response.Messages)
{
writer.WriteLine($"{update.ExecutorId}: {message.Text}");
}
}
}
}
}
internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
{
public const string Greeting = "Hello World!";
public const string DefaultId = nameof(HelloAgent);
protected override string? IdCore => id;
public override string? Name => id;
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new HelloAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new HelloAgentThread());
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
IEnumerable<AgentResponseUpdate> update = [
await this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.SingleAsync(cancellationToken)
.ConfigureAwait(false)];
return update.ToAgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new(ChatRole.Assistant, "Hello World!")
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = Guid.NewGuid().ToString("N"),
};
}
}
internal sealed class HelloAgentThread() : InMemoryAgentThread();

View File

@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step7EntryPoint
{
public static string EchoAgentId => Step6EntryPoint.EchoAgentId;
public static string EchoPrefix => Step6EntryPoint.EchoPrefix;
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2, int numIterations = 2)
{
Workflow workflow = Step6EntryPoint.CreateWorkflow(maxSteps);
AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent");
for (int i = 0; i < numIterations; i++)
{
AgentThread thread = await agent.GetNewThreadAsync();
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false))
{
if (update.RawRepresentation is WorkflowEvent)
{
// Skip workflow status updates
continue;
}
string updateText = $"{update.AuthorName
?? update.AgentId
?? update.Role.ToString()
?? ChatRole.Assistant.ToString()}: {update.Text}";
writer.WriteLine(updateText);
}
}
}
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal sealed record class TextProcessingRequest(string Text, string TaskId);
internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount);
//internal sealed class AllTasksCompletedEvent(IEnumerable<TextProcessingResult> results) : WorkflowEvent(results);
internal static class Step8EntryPoint
{
public static List<string> TextsToProcess => [
"Hello world! This is a simple test.",
"Python is a powerful programming language used for many applications.",
"Short text.",
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.",
"",
" Spaces around text ",
];
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List<string> textsToProcess)
{
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> processTextAsyncFunc = ProcessTextAsync;
ExecutorBinding processText = processTextAsyncFunc.BindAsExecutor("TextProcessor", threadsafe: true);
Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
ExecutorBinding textProcessor = subWorkflow.BindAsExecutor("TextProcessor");
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id));
var orchestrator = createOrchestrator.BindExecutor();
Workflow workflow = new WorkflowBuilder(orchestrator)
.AddEdge(orchestrator, textProcessor)
.AddEdge(textProcessor, orchestrator)
.WithOutputFrom(orchestrator)
.Build();
Run workflowRun = await environment.RunAsync(workflow, textsToProcess);
RunStatus status = await workflowRun.GetStatusAsync();
status.Should().Be(RunStatus.Idle);
WorkflowOutputEvent? maybeOutput = workflowRun.OutgoingEvents.OfType<WorkflowOutputEvent>()
.SingleOrDefault();
maybeOutput.Should().NotBeNull("the workflow should have produced an output event");
List<TextProcessingResult>? maybeResults = maybeOutput.As<List<TextProcessingResult>>();
maybeResults.Should().NotBeNull("the output event should contain the results");
List<TextProcessingResult> results = maybeResults;
results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId));
return results;
}
private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
int wordCount = 0;
int charCount = 0;
if (request.Text.Length != 0)
{
wordCount = request.Text.Split([' '], StringSplitOptions.RemoveEmptyEntries).Length;
charCount = request.Text.Length;
}
return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken);
}
private sealed class TextProcessingOrchestrator(string id)
: StatefulExecutor<TextProcessingOrchestrator.State>(id, () => new(), declareCrossRunShareable: false)
{
internal sealed class State
{
public List<TextProcessingResult> Results { get; } = [];
public HashSet<string> PendingTaskIds { get; } = [];
public bool IsComplete => this.PendingTaskIds.Count == 0;
public void AddPending(string taskId) => this.PendingTaskIds.Add(taskId);
public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<string>>(this.StartProcessingAsync)
.AddHandler<TextProcessingResult>(this.CollectResultAsync);
}
private async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context, CancellationToken cancellationToken)
{
await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken);
async ValueTask<State?> QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
{
foreach (TextProcessingRequest request in texts.Select((value, index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}")))
{
state.PendingTaskIds.Add(request.TaskId);
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return state;
}
}
private async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken);
async ValueTask<State?> CollectResultAndCheckCompletionAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
{
if (state.PendingTaskIds.Remove(result.TaskId))
{
state.Results.Add(result);
}
if (state.PendingTaskIds.Count == 0)
{
await context.YieldOutputAsync(state.Results, cancellationToken).ConfigureAwait(false);
}
return state;
}
}
}
}

View File

@@ -0,0 +1,547 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal sealed record class UserRequest(string RequestType, string Type, int Amount, string Id, string? Priority = null, string? PolicyType = null)
{
internal static int RequestCount;
public static string CreateId()
{
string result = Interlocked.Increment(ref RequestCount).ToString();
Console.Error.WriteLine($"Got Id: {result}");
return result;
}
public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal")
{
UserRequest request = new("resource", resourceType, amount, Priority: priority, Id: CreateId());
Console.Error.WriteLine($"\t{request}");
return request;
}
public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota")
{
UserRequest request = new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId());
Console.Error.WriteLine($"\t{request}");
return request;
}
public ResourceResponse CreateResourceResponse(int allocated, string source)
=> new(this.Id, this.Type, allocated, source);
public PolicyResponse CreatePolicyResponse(bool approved, string reason)
=> new(this.Id, approved, reason);
public RequestFinished CreateExpected(ResourceResponse response)
=> new(this.Id, RequestType: "resource", ResourceResponse: response with { Id = this.Id });
public RequestFinished CreateExpectedResourceResponse(int allocated, string source)
=> this.CreateExpected(this.CreateResourceResponse(allocated, source));
public RequestFinished CreateExpected(PolicyResponse response)
=> new(this.Id, RequestType: "policy", PolicyResponse: response with { Id = this.Id });
public RequestFinished CreateExpectedPolicyResponse(bool approved, string reason)
=> this.CreateExpected(this.CreatePolicyResponse(approved, reason));
}
internal sealed record class ResourceRequest(string Id, string ResourceType = "cpu", int Amount = 1, string Priority = "normal");
internal sealed record class PolicyCheckRequest(string Id, string ResourceType, int Amount = 0, string PolicyType = "quota");
internal sealed record class ResourceResponse(string Id, string ResourceType, int Allocated, string Source);
internal sealed record class PolicyResponse(string Id, bool Approved, string Reason);
internal sealed record class RequestFinished(string Id, string RequestType, ResourceResponse? ResourceResponse = null, PolicyResponse? PolicyResponse = null);
internal static class Step9EntryPoint
{
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding filter, string? id = null)
{
id ??= typeof(TRequest).Name;
var requestPort = RequestPort.Create<TRequest, TResponse>(id);
return builder.ForwardMessage<ExternalRequest>(source, targets: [filter], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalRequest>(filter, targets: [requestPort], condition: message => message.DataIs<TRequest>())
.ForwardMessage<ExternalResponse>(requestPort, targets: [filter], condition: message => message.DataIs<TResponse>())
.ForwardMessage<ExternalResponse>(filter, targets: [source], condition: message => message.DataIs<TResponse>());
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string? id = null)
=> builder.AddExternalRequest(source, out RequestPort<TRequest, TResponse> _, id);
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
{
id ??= $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
inputPort = RequestPort.Create<TRequest, TResponse>(id);
return builder.AddExternalRequest(source, inputPort);
}
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, RequestPort<TRequest, TResponse> inputPort)
{
return builder.ForwardMessage<TRequest>(source, [inputPort])
.ForwardMessage<ExternalRequest>(source, [inputPort])
.ForwardMessage<TResponse>(inputPort, [source])
.ForwardMessage<ExternalResponse>(inputPort, [source]);
}
public static Workflow CreateSubWorkflow()
{
ResourceRequestor requestor = new();
return new WorkflowBuilder(requestor)
.AddExternalRequest<ResourceRequest, ResourceResponse>(source: requestor)
.AddExternalRequest<PolicyCheckRequest, PolicyResponse>(source: requestor)
.WithOutputFrom(requestor)
.Build();
}
public static Workflow CreateWorkflow()
{
Coordinator coordinator = new();
ResourceCache cache = new();
QuotaPolicyEngine policyEngine = new();
ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow");
return new WorkflowBuilder(coordinator)
.AddChain(coordinator, [subworkflow, coordinator], allowRepetition: true)
.AddPassthroughRequestHandler<ResourceRequest, ResourceResponse>(subworkflow, cache)
.AddPassthroughRequestHandler<PolicyCheckRequest, PolicyResponse>(subworkflow, policyEngine)
.WithOutputFrom(coordinator)
.Build();
}
public static Workflow WorkflowInstance => CreateWorkflow();
public static UserRequest ResourceHitRequest1 = UserRequest.CreateResourceRequest(resourceType: "cpu", amount: 2, priority: "normal");
public static RequestFinished ResourceHitResponse1 = ResourceHitRequest1.CreateExpectedResourceResponse(allocated: 2, "cache");
public static UserRequest ResourceHitRequest2 = UserRequest.CreateResourceRequest(resourceType: "memory", amount: 15, priority: "normal");
public static RequestFinished ResourceHitResponse2 = ResourceHitRequest2.CreateExpectedResourceResponse(allocated: 15, "cache");
public static UserRequest PolicyHitRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 3, policyType: "quota");
public static RequestFinished PolicyHitResponse1 = PolicyHitRequest1.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (5)");
public static UserRequest PolicyHitRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "disk", amount: 500, policyType: "quota");
public static RequestFinished PolicyHitResponse2 = PolicyHitRequest2.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (1000)");
public static UserRequest ResourceMissRequest = UserRequest.CreateResourceRequest(resourceType: "gpu", amount: 2, priority: "high");
public static RequestFinished ResourceMissResponse = ResourceMissRequest.CreateExpectedResourceResponse(allocated: 1, "external");
public static UserRequest PolicyMissRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "memory", amount: 100, policyType: "quota");
public static RequestFinished PolicyMissResponse1 = PolicyMissRequest1.CreateExpectedPolicyResponse(approved: false, reason: "External Rejection");
public static UserRequest PolicyMissRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 1, policyType: "security");
public static RequestFinished PolicyMissResponse2 = PolicyMissRequest2.CreateExpectedPolicyResponse(approved: true, reason: "External Approval");
public static HashSet<string> PolicyMissIds = [PolicyMissRequest1.Id, PolicyMissRequest2.Id];
public static HashSet<string> ResourceMissIds = [ResourceMissRequest.Id];
public static Dictionary<string, RequestFinished> Part1FinishedResponses = new()
{
{ ResourceHitRequest1.Id, ResourceHitResponse1 },
{ ResourceHitRequest2.Id, ResourceHitResponse2 },
{ PolicyHitRequest1.Id, PolicyHitResponse1 },
{ PolicyHitRequest2.Id, PolicyHitResponse2 },
};
public static Dictionary<string, RequestFinished> Part2FinishedResponses = new()
{
{ ResourceMissRequest.Id, ResourceMissResponse},
{ PolicyMissRequest1.Id, PolicyMissResponse1 },
{ PolicyMissRequest2.Id, PolicyMissResponse2 },
};
public static UserRequest[] RequestsToProcess => [
ResourceHitRequest1,
PolicyHitRequest1,
ResourceHitRequest2,
PolicyMissRequest1, // miss
ResourceMissRequest, // miss
PolicyHitRequest2,
PolicyMissRequest2, // miss
];
public static List<RequestFinished> ExpectedResponsesPart1 =>
[.. RequestsToProcess.Where(request => Part1FinishedResponses.ContainsKey(request.Id))
.Select(request => Part1FinishedResponses[request.Id])
.OrderBy(request => request.Id)];
public static RequestFinished[] ExpectedResponsesPart2 =>
[.. RequestsToProcess.Where(request => Part2FinishedResponses.ContainsKey(request.Id))
.Select(request => Part2FinishedResponses[request.Id])
.OrderBy(request => request.Id)];
public static async ValueTask<List<RequestFinished>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
RunStatus runStatus;
List<RequestFinished> results = [];
Run workflowRun = await environment.RunAsync(WorkflowInstance, RequestsToProcess.ToList());
RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle;
runStatus = await workflowRun.GetStatusAsync();
runStatus.Should().Be(part1Status);
List<RequestFinished> finishedRequests = [];
List<ExternalRequest> resourceRequests = [];
List<ExternalRequest> policyRequests = [];
foreach (WorkflowEvent evt in workflowRun.NewEvents)
{
if (evt is WorkflowOutputEvent outputEvent && outputEvent.Data is RequestFinished finishedRequest)
{
finishedRequests.Add(finishedRequest);
}
else if (evt is RequestInfoEvent requestInfoEvent)
{
if (requestInfoEvent.Request.DataIs<ResourceRequest>())
{
resourceRequests.Add(requestInfoEvent.Request);
}
else if (requestInfoEvent.Request.DataIs<PolicyCheckRequest>())
{
policyRequests.Add(requestInfoEvent.Request);
}
}
else if (evt is WorkflowErrorEvent error)
{
Assert.Fail(((Exception)error.Data!).ToString());
Console.Error.WriteLine(error.Data);
}
}
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
finishedRequests.Should().HaveCount(ExpectedResponsesPart1.Count)
.And.ContainInOrder(ExpectedResponsesPart1);
int externalResourceRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.ResourceResponse != null);
int externalPolicyRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.PolicyResponse != null);
resourceRequests.Should().HaveCount(externalResourceRequests);
policyRequests.Should().HaveCount(externalPolicyRequests);
List<ExternalResponse> responses = [];
foreach (ExternalRequest request in resourceRequests)
{
ResourceRequest resourceRequest = request.DataAs<ResourceRequest>()!;
resourceRequest.Id.Should().BeOneOf(ResourceMissIds);
responses.Add(request.CreateResponse(Part2FinishedResponses[resourceRequest.Id].ResourceResponse!));
}
foreach (ExternalRequest request in policyRequests)
{
PolicyCheckRequest policyRequest = request.DataAs<PolicyCheckRequest>()!;
policyRequest.Id.Should().BeOneOf(PolicyMissIds);
responses.Add(request.CreateResponse(Part2FinishedResponses[policyRequest.Id].PolicyResponse!));
}
if (ExpectedResponsesPart2.Length == 0)
{
responses.Should().BeEmpty();
return results;
}
await workflowRun.ResumeAsync(responses: responses).ConfigureAwait(false);
runStatus = await workflowRun.GetStatusAsync();
runStatus.Should().Be(RunStatus.Idle);
results = finishedRequests;
finishedRequests = workflowRun.NewEvents.OfType<WorkflowOutputEvent>()
.Select(outputEvent => outputEvent.Data)
.Where(value => value is not null)
.OfType<RequestFinished>()
.ToList();
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
finishedRequests.Should().HaveCount(ExpectedResponsesPart2.Length)
.And.ContainInOrder(ExpectedResponsesPart2);
results.AddRange(finishedRequests);
return results;
}
}
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.RequestResourcesAsync)
.AddHandler<UserRequest>(InvokeResourceRequestAsync)
.AddHandler<ResourceResponse>(this.HandleResponseAsync)
.AddHandler<PolicyResponse>(this.HandleResponseAsync);
// For some reason, using a lambda here causes the analyzer to generate a spurious
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
// to a variable, or passing it to another method"
ValueTask InvokeResourceRequestAsync(UserRequest request, IWorkflowContext context)
=> this.RequestResourcesAsync([request], context);
}
private async ValueTask RequestResourcesAsync(List<UserRequest> requests, IWorkflowContext context)
{
foreach (UserRequest request in requests)
{
switch (request.RequestType)
{
case "resource":
await context.SendMessageAsync(new ResourceRequest(Id: request.Id, ResourceType: request.Type, Amount: request.Amount, Priority: request.Priority ?? "normal"))
.ConfigureAwait(false);
break;
case "policy":
await context.SendMessageAsync(new PolicyCheckRequest(Id: request.Id, PolicyType: request.PolicyType ?? "quota", ResourceType: request.Type, Amount: request.Amount))
.ConfigureAwait(false);
break;
}
}
}
private async ValueTask HandleResponseAsync(ResourceResponse response, IWorkflowContext context)
{
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "resource", ResourceResponse: response));
}
private async ValueTask HandleResponseAsync(PolicyResponse response, IWorkflowContext context)
{
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response));
}
}
internal sealed class ResourceCache()
: StatefulExecutor<Dictionary<string, int>>(nameof(ResourceCache),
InitializeResourceCache,
declareCrossRunShareable: true)
{
private static Dictionary<string, int> InitializeResourceCache()
=> new()
{
["cpu"] = 10,
["memory"] = 50,
["disk"] = 100,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
// Note the disbalance here - we could also handle ExternalResponse here instead, but we would have
// to do the exact same type check on it, so we might as well handle
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectResultAsync);
}
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (request.DataIs(out ResourceRequest? resourceRequest))
{
ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context, cancellationToken)
.ConfigureAwait(false);
if (response != null)
{
await context.SendMessageAsync(request.CreateResponse(response), cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
// Cache does not have enough resources, forward the request to the external system
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private async ValueTask<ResourceResponse?> TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.Error.WriteLine($"Handling Resource Request {request.Id}");
Dictionary<string, int> availableResources = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
.ConfigureAwait(false);
Console.Error.WriteLine($"Available Resources: {availableResources}");
try
{
if (availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount)
{
// Cache has enough resources, allocate from cache
availableResources[request.ResourceType] -= request.Amount;
Console.Error.WriteLine($"Handled Resource Request {request.Id}");
return new(request.Id, request.ResourceType, request.Amount, Source: "cache");
}
}
finally
{
await this.QueueStateUpdateAsync(availableResources, context, cancellationToken)
.ConfigureAwait(false);
}
Console.Error.WriteLine($"Could not handle Resource Request {request.Id}");
return null;
}
private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs<ResourceResponse>())
{
// Normally we'd update the cache according to whatever logic we want here.
return context.SendMessageAsync(response);
}
return default;
}
}
internal sealed class QuotaPolicyEngine()
: StatefulExecutor<Dictionary<string, int>>(nameof(QuotaPolicyEngine),
InitializePolicyQuotas,
declareCrossRunShareable: true)
{
private static Dictionary<string, int> InitializePolicyQuotas()
=> new()
{
["cpu"] = 5,
["memory"] = 20,
["disk"] = 1000,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectAndForwardAsync);
}
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context)
{
if (request.DataIs(out PolicyCheckRequest? policyRquest))
{
PolicyResponse? response = await this.TryHandlePolicyCheckRequestAsync(policyRquest, context)
.ConfigureAwait(false);
if (response != null)
{
await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false);
}
else
{
// QuotaPolicyEngine cannot approve the request, forward to external system
await context.SendMessageAsync(request).ConfigureAwait(false);
}
}
}
private async ValueTask<PolicyResponse?> TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.Error.WriteLine($"Handling Policy Request {request.Id}");
Dictionary<string, int> quotas = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
.ConfigureAwait(false);
Console.Error.WriteLine($"Policy Quotas: {quotas}");
try
{
if (request.PolicyType == "quota" &&
quotas.TryGetValue(request.ResourceType, out int quota) &&
request.Amount <= quota)
{
Console.Error.WriteLine($"Handled Policy Request {request.Id}");
return new(request.Id, Approved: true, Reason: $"Within quota ({quota})");
}
Console.Error.WriteLine($"Could not handle Policy Request {request.Id}");
return null;
}
finally
{
await this.QueueStateUpdateAsync(quotas, context, cancellationToken).ConfigureAwait(false);
}
}
private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context)
{
if (response.DataIs<PolicyResponse>())
{
return context.SendMessageAsync(response);
}
return default;
}
}
internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCrossRunShareable: true)
{
private const string StateKey = nameof(StateKey);
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.StartAsync)
.AddHandler<UserRequest>(InvokeStartAsync)
.AddHandler<RequestFinished>(this.HandleFinishedRequestAsync);
// For some reason, using a lambda here causes the analyzer to generate a spurious
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
// to a variable, or passing it to another method"
ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context, CancellationToken cancellationToken)
=> this.StartAsync([request], context, cancellationToken);
}
private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
{
await context.YieldOutputAsync(finished, cancellationToken).ConfigureAwait(false);
return state - 1;
}
}
private ValueTask StartAsync(List<UserRequest> requests, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
{
foreach (UserRequest req in requests)
{
await context.SendMessageAsync(req, cancellationToken: cancellationToken).ConfigureAwait(false);
}
return state + requests.Count;
}
}
internal async ValueTask RunWorkflowHandleEventsAsync<TInput>(Workflow workflow, TInput input) where TInput : notnull
{
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case ExecutorInvokedEvent invoked:
Console.WriteLine($"Executor invoked: {invoked.ExecutorId}");
break;
case ExecutorCompletedEvent completed:
Console.WriteLine($"Executor completed: {completed.ExecutorId}");
break;
// Other event types can be handled here as needed
default:
break;
}
}
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.UnitTests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step10EntryPoint
{
public static Workflow CreateWorkflow()
{
TestEchoAgent echoAgent = new("echo", "Echo");
return AgentWorkflowBuilder.BuildSequential(echoAgent);
}
public static Workflow WorkflowInstance => CreateWorkflow();
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
{
AIAgent hostAgent = WorkflowInstance.AsAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
AgentThread thread = await hostAgent.GetNewThreadAsync();
foreach (string input in inputs)
{
AgentResponse response;
ResponseContinuationToken? continuationToken = null;
do
{
response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken });
} while ((continuationToken = response.ContinuationToken) is { });
foreach (ChatMessage message in response.Messages)
{
writer.WriteLine($"{message.AuthorName}: {message.Text}");
}
}
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.UnitTests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step11EntryPoint
{
public const int AgentCount = 2;
public const string EchoAgentIdPrefix = "echo-";
public const string EchoAgentNamePrefix = "Echo";
public static string ExpectedOutputForInput(string input, int agentNumber)
=> $"{EchoAgentNamePrefix}{agentNumber}: {input}";
public static Workflow CreateWorkflow()
{
TestEchoAgent[] echoAgents = Enumerable.Range(1, AgentCount)
.Select(i => new TestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}"))
.ToArray();
return AgentWorkflowBuilder.BuildConcurrent(echoAgents);
}
public static Workflow WorkflowInstance => CreateWorkflow();
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
{
AIAgent hostAgent = WorkflowInstance.AsAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
AgentThread thread = await hostAgent.GetNewThreadAsync();
foreach (string input in inputs)
{
AgentResponse response;
ResponseContinuationToken? continuationToken = null;
do
{
response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken });
} while ((continuationToken = response.ContinuationToken) is { });
foreach (ChatMessage message in response.Messages)
{
writer.WriteLine($"{message.AuthorName}: {message.Text}");
}
}
}
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.UnitTests;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal sealed class HandoffTestEchoAgent(string id, string name, string prefix = "")
: TestEchoAgent(id, name, prefix)
{
protected override IEnumerable<ChatMessage> GetEpilogueMessages(AgentRunOptions? options = null)
{
if (options is ChatClientAgentRunOptions chatClientOptions &&
chatClientOptions.ChatOptions != null)
{
IEnumerable<AITool>? handoffs = chatClientOptions.ChatOptions
.Tools?
.Where(tool => tool.Name?.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix,
StringComparison.OrdinalIgnoreCase) is true);
if (handoffs != null)
{
AITool? handoff = handoffs.FirstOrDefault();
if (handoff != null)
{
return [new(ChatRole.Assistant, [new FunctionCallContent(Guid.NewGuid().ToString("N"), handoff.Name)])
{
AuthorName = this.Name ?? this.Id,
MessageId = Guid.NewGuid().ToString("N"),
CreatedAt = DateTime.UtcNow
}];
}
}
}
return base.GetEpilogueMessages(options);
}
}
internal static class Step12EntryPoint
{
public const int AgentCount = 2;
public const string EchoAgentIdPrefix = "echo-";
public const string EchoAgentNamePrefix = "Echo";
public static string EchoPrefixForAgent(int agentNumber)
=> $"{agentNumber}:";
public static Workflow CreateWorkflow()
{
TestEchoAgent[] echoAgents = Enumerable.Range(1, AgentCount)
.Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i)))
.ToArray();
return new HandoffsWorkflowBuilder(echoAgents[0])
.WithHandoff(echoAgents[0], echoAgents[1])
.Build();
}
public static Workflow WorkflowInstance => CreateWorkflow();
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
{
AIAgent hostAgent = WorkflowInstance.AsAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
AgentThread thread = await hostAgent.GetNewThreadAsync();
foreach (string input in inputs)
{
AgentResponse response;
ResponseContinuationToken? continuationToken = null;
do
{
response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken });
} while ((continuationToken = response.ContinuationToken) is { });
foreach (ChatMessage message in response.Messages)
{
writer.WriteLine(message.Text);
}
}
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Sample;
internal static class Step13EntryPoint
{
public static Workflow SubworkflowInstance
{
get
{
OutputMessagesExecutor output = new(new ChatProtocolExecutorOptions() { StringMessageChatRole = ChatRole.User });
return new WorkflowBuilder(output).WithOutputFrom(output).Build();
}
}
public static Workflow WorkflowInstance
{
get
{
ExecutorBinding subworkflow = SubworkflowInstance.BindAsExecutor("EchoSubworkflow");
return new WorkflowBuilder(subworkflow).WithOutputFrom(subworkflow).Build();
}
}
public static async ValueTask<AgentThread> RunAsAgentAsync(TextWriter writer, string input, IWorkflowExecutionEnvironment environment, AgentThread? thread)
{
AIAgent hostAgent = WorkflowInstance.AsAgent("echo-workflow", "EchoW", executionEnvironment: environment, includeWorkflowOutputsInResponse: true);
thread ??= await hostAgent.GetNewThreadAsync();
AgentResponse response;
ResponseContinuationToken? continuationToken = null;
do
{
response = await hostAgent.RunAsync(input, thread, new AgentRunOptions { ContinuationToken = continuationToken });
} while ((continuationToken = response.ContinuationToken) is { });
foreach (ChatMessage message in response.Messages)
{
writer.WriteLine($"{message.AuthorName}: {message.Text}");
}
return thread;
}
public static async ValueTask<CheckpointInfo> RunAsync(TextWriter writer, string input, IWorkflowExecutionEnvironment environment, CheckpointManager checkpointManager, CheckpointInfo? resumeFrom)
{
await using Checkpointed<StreamingRun> checkpointed = await BeginAsync();
StreamingRun run = checkpointed.Run;
await run.TrySendMessageAsync(new TurnToken());
CheckpointInfo? lastCheckpoint = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is WorkflowOutputEvent output)
{
if (output.Data is List<ChatMessage> messages)
{
foreach (ChatMessage message in messages)
{
writer.WriteLine($"{output.SourceId}: {message.Text}");
}
}
else
{
Debug.Fail($"Unexpected output type: {(output.Data == null ? "null" : output.Data?.GetType().Name)}");
}
}
else if (evt is SuperStepCompletedEvent stepCompleted)
{
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
}
}
return lastCheckpoint!;
async ValueTask<Checkpointed<StreamingRun>> BeginAsync()
{
if (resumeFrom == null)
{
return await environment.StreamAsync(WorkflowInstance, input, checkpointManager);
}
Checkpointed<StreamingRun> checkpointed = await environment.ResumeStreamAsync(WorkflowInstance, resumeFrom, checkpointManager);
await checkpointed.Run.TrySendMessageAsync(input);
return checkpointed;
}
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Workflows.Sample;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
// Checkpointing Types
[JsonSerializable(typeof(NumberSignal))]
[ExcludeFromCodeCoverage]
internal sealed partial class SampleJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,463 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Sample;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal enum ExecutionEnvironment
{
InProcess_Lockstep,
InProcess_OffThread,
InProcess_Concurrent
}
public class SampleSmokeTest
{
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step1Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step1EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
const string INPUT = "Hello, World!";
Assert.Collection(lines,
line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line),
line => Assert.Contains($"ReverseTextExecutor: {new string(INPUT.ToUpperInvariant().Reverse().ToArray())}", line)
);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step1aAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step1aEntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
const string INPUT = "Hello, World!";
Assert.Collection(lines,
line => Assert.Contains($"UppercaseExecutor: {INPUT.ToUpperInvariant()}", line),
line => Assert.Contains($"ReverseTextExecutor: {string.Concat(INPUT.ToUpperInvariant().Reverse())}", line)
);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step2Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
string spamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult);
string nonSpamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), "This is a valid message.");
Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step3Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
string guessResult = await Step3EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("Guessed the number: 42", guessResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step4Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
VerifyingPlaybackResponder<string, int> responder = new(
("Guess the number.", 50),
("Your guess was too high. Try again.", 23),
("Your guess was too low. Try again.", 42));
string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
VerifyingPlaybackResponder<string, int> responder = new(
// Iteration 1
("Guess the number.", 50),
("Your guess was too high. Try again.", 23),
// Iteration 2
("Your guess was too high. Try again.", 23),
("Your guess was too low. Try again.", 42)
);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment());
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
VerifyingPlaybackResponder<string, int> responder = new(
// Iteration 1
("Guess the number.", 50),
("Your guess was too high. Try again.", 23),
// Iteration 2
("Your guess was too high. Try again.", 23),
("Your guess was too low. Try again.", 42)
);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true);
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment)
{
using StringWriter writer = new();
VerifyingPlaybackResponder<string, int> responder = new(
// Iteration 1
("Guess the number.", 50),
("Your guess was too high. Try again.", 23),
// Iteration 2
("Your guess was too high. Try again.", 23),
("Your guess was too low. Try again.", 42)
);
JsonSerializerOptions options = new(SampleJsonContext.Default.Options);
options.MakeReadOnly();
CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options);
string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager);
Assert.Equal("You guessed correctly! You Win!", guessResult);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step6Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step6EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
Assert.Collection(lines,
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
line => Assert.Contains($"{Step6EntryPoint.EchoAgentId}: {Step6EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line)
);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step7Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
await Step7EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
Assert.Collection(lines,
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line),
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line)
);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step8Async(ExecutionEnvironment environment)
{
List<string> textsToProcess = [
"Hello world! This is a simple test.",
"Python is a powerful programming language used for many applications.",
"Short text.",
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.",
"",
" Spaces around text ",
];
using StringWriter writer = new();
List<TextProcessingResult> results = await Step8EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), textsToProcess);
Assert.Equal(textsToProcess.Count, results.Count);
Assert.Collection(results,
textsToProcess.Select(CreateValidator).ToArray());
Action<TextProcessingResult> CreateValidator(string textToProcess, int index)
{
return result =>
{
TextProcessingResult expected = new(
TaskId: $"Task{index}",
Text: textToProcess,
WordCount: textToProcess.Split([' '], StringSplitOptions.RemoveEmptyEntries).Length,
ChatCount: textToProcess.Length
);
result.Should().Be(expected);
};
}
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step9Async(ExecutionEnvironment environment)
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step10Async(ExecutionEnvironment environment)
{
List<string> inputs = ["1", "2", "3"];
using StringWriter writer = new();
await Step10EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), inputs);
string[] lines = writer.ToString().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
Assert.Collection(lines,
inputs.Select(CreateValidator).ToArray());
Action<string> CreateValidator(string expected) => actual => actual.Should().Be($"Echo: {expected}");
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step11Async(ExecutionEnvironment environment)
{
List<string> inputs = ["1", "2", "3"];
using StringWriter writer = new();
await Step11EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), inputs);
string[] lines = writer.ToString().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
Array.Sort(lines, StringComparer.OrdinalIgnoreCase);
string[] expected = Enumerable.Range(1, Step11EntryPoint.AgentCount)
.SelectMany(agentNumber => inputs.Select(input => Step11EntryPoint.ExpectedOutputForInput(input, agentNumber)))
.ToArray();
Array.Sort(expected, StringComparer.OrdinalIgnoreCase);
Assert.Collection(lines,
expected.Select(CreateValidator).ToArray());
Action<string> CreateValidator(string expected) => actual => actual.Should().Be(expected);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step12Async(ExecutionEnvironment environment)
{
List<string> inputs = ["1", "2", "3"];
using StringWriter writer = new();
await Step12EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), inputs);
string[] lines = writer.ToString().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
// The expectation is that each agent will echo each input along with every echo from previous agents
// E.g.:
// (user): 1
// (a1): 1:1
// (a2): 2:1
// (a2): 2:1:1
// If there were three agents, it would then be followed by:
// (a3): 3:1
// (a3): 3:1:1
// (a3): 3:2:1
// (a3): 3:2:1:1
string[] expected = inputs.SelectMany(input => EchoesForInput(input)).ToArray();
Console.Error.WriteLine("Expected lines: ");
foreach (string expectedLine in expected)
{
Console.Error.WriteLine($"\t{expectedLine}");
}
Console.Error.WriteLine("Actual lines: ");
foreach (string line in lines)
{
Console.Error.WriteLine($"\t{line}");
}
Assert.Collection(lines,
expected.Select(CreateValidator).ToArray());
IEnumerable<string> EchoesForInput(string input)
{
List<string> echoes = [$"{Step12EntryPoint.EchoPrefixForAgent(1)}{input}"];
for (int i = 2; i <= Step12EntryPoint.AgentCount; i++)
{
string agentPrefix = Step12EntryPoint.EchoPrefixForAgent(i);
List<string> newEchoes = [$"{agentPrefix}{input}", .. echoes.Select(echo => $"{agentPrefix}{echo}")];
echoes.AddRange(newEchoes);
}
return echoes;
}
Action<string> CreateValidator(string expected) => actual => actual.Should().Be(expected);
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step13Async(ExecutionEnvironment environment)
{
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
CheckpointInfo? resumeFrom = null;
await RunAndValidateAsync(1);
// this should crash before fix
await RunAndValidateAsync(2);
async ValueTask RunAndValidateAsync(int step)
{
using StringWriter writer = new();
string input = $"[{step}] Hello, World!";
resumeFrom = await Step13EntryPoint.RunAsync(writer, input, executionEnvironment, checkpointManager, resumeFrom);
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
const string ExpectedSource = "EchoSubworkflow";
Assert.Collection(lines,
line => Assert.Contains($"{ExpectedSource}: {input}", line)
);
}
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Concurrent)]
internal async Task Test_RunSample_Step13aAsync(ExecutionEnvironment environment)
{
IWorkflowExecutionEnvironment executionEnvironment = environment.ToWorkflowExecutionEnvironment();
AgentThread? thread = null;
await RunAndValidateAsync(1);
// this should crash before fix
await RunAndValidateAsync(2);
async ValueTask RunAndValidateAsync(int step)
{
using StringWriter writer = new();
string input = $"[{step}] Hello, World!";
thread = await Step13EntryPoint.RunAsAgentAsync(writer, input, executionEnvironment, thread);
string result = writer.ToString();
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
// We expect to get the message that was passed in directly; since we are passing it in as a string, there is no associated
// author information. The ExpectedSource is empty string.
const string ExpectedSource = "";
Assert.Collection(lines,
line => Assert.Contains($"{ExpectedSource}: {input}", line)
);
}
}
}
internal sealed class VerifyingPlaybackResponder<TInput, TResponse>
{
public (TInput input, TResponse response)[] Responses { get; }
private int _position;
public VerifyingPlaybackResponder(params (TInput input, TResponse response)[] responses)
{
this.Responses = responses;
}
public int Remaining => Math.Max(0, this.Responses.Length - this._position);
public TResponse InvokeNext(TInput input)
{
Assert.True(this.Remaining > 0);
(TInput expectedInput, TResponse expectedResponse) = this.Responses[this._position++];
Assert.Equal(expectedInput, input);
return expectedResponse;
}
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class SpecializedExecutorSmokeTests
{
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
static ChatMessage ToMessage(string text)
{
if (string.IsNullOrEmpty(text))
{
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
}
string[] splits = text.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += ' ';
}
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
return new(ChatRole.Assistant, contents)
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.UtcNow,
};
}
return result;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public static TestAIAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new AgentResponse(this.Messages)
{
AgentId = this.Id,
ResponseId = Guid.NewGuid().ToString("N")
});
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
{
foreach (AIContent content in message.Contents)
{
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
{
string? currentMessageId = null;
if (candidateMessages is not null)
{
foreach (ChatMessage message in candidateMessages)
{
if (currentMessageId is null)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
return candidateMessages;
}
}
public sealed class TestAgentThread() : InMemoryAgentThread();
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
{
private readonly StateManager _stateManager = new();
public List<ChatMessage> Updates { get; } = [];
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) =>
default;
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) =>
default;
public ValueTask RequestHaltAsync() =>
default;
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName));
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> value is null
? this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName), key)
: this._stateManager.WriteStateAsync(new ScopeId(executorId, scopeName), key, value);
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ReadStateAsync<T>(new ScopeId(executorId, scopeName), key);
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this._stateManager.ReadKeysAsync(new ScopeId(executorId, scopeName));
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
if (message is List<ChatMessage> messages)
{
this.Updates.AddRange(messages);
}
else if (message is ChatMessage chatMessage)
{
this.Updates.Add(chatMessage);
}
return default;
}
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
return (await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false))
?? initialStateFactory();
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
public bool ConcurrentRunsEnabled => concurrentRunsEnabled;
}
[Fact]
public async Task Test_AIAgentStreamingMessage_AggregationAsync()
{
string[] MessageStrings = [
"",
"Hello world!",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
];
List<ChatMessage> expected = TestAIAgent.ToChatMessages(MessageStrings);
TestAIAgent agent = new(expected);
AIAgentHostExecutor host = new(agent);
TestWorkflowContext collectingContext = new(host.Id);
await host.TakeTurnAsync(new TurnToken(emitEvents: true), collectingContext);
// The first empty message is skipped.
collectingContext.Updates.Should().HaveCount(MessageStrings.Length - 1);
for (int i = 1; i < MessageStrings.Length; i++)
{
string expectedText = MessageStrings[i];
ChatMessage collected = collectingContext.Updates[i - 1];
collected.Text.Should().Be(expectedText);
}
}
[Fact]
public async Task Test_AIAgent_ExecutorId_Use_Agent_NameAsync()
{
const string AgentAName = "TestAgentAName";
const string AgentBName = "TestAgentBName";
TestAIAgent agentA = new(name: AgentAName);
TestAIAgent agentB = new(name: AgentBName);
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
// Verify that the agent host executor registration IDs in the workflow definition
// match the agent names when agent names are provided.
// The property DisplayName falls back to using the agent ID when Name is not set.
agentA.GetDescriptiveId().Should().Contain(AgentAName);
agentB.GetDescriptiveId().Should().Contain(AgentBName);
definition.Executors[agentA.GetDescriptiveId()].ExecutorId.Should().Be(agentA.GetDescriptiveId());
definition.Executors[agentB.GetDescriptiveId()].ExecutorId.Should().Be(agentB.GetDescriptiveId());
// This will create an instance of the start agent and verify that the ID
// of the executor instance matches the ID of the registration.
var protocolDescriptor = await workflow.DescribeProtocolAsync();
protocolDescriptor.Accepts.Should().Contain(typeof(ChatMessage));
}
[Fact]
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
{
TestAIAgent agentA = new();
TestAIAgent agentB = new();
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
// Verify that the agent host executor registration IDs in the workflow definition
// match the agent IDs when agent names are not provided.
// The property DisplayName falls back to using the agent ID when Name is not set.
agentA.GetDescriptiveId().Should().Contain(agentA.Id);
agentB.GetDescriptiveId().Should().Contain(agentB.Id);
definition.Executors[agentA.GetDescriptiveId()].ExecutorId.Should().Be(agentA.GetDescriptiveId());
definition.Executors[agentB.GetDescriptiveId()].ExecutorId.Should().Be(agentB.GetDescriptiveId());
// This will create an instance of the start agent and verify that the ID
// of the executor instance matches the ID of the registration.
var protocolDescriptor = await workflow.DescribeProtocolAsync();
protocolDescriptor.Accepts.Should().Contain(typeof(ChatMessage));
}
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class StateKeyObjectTests
{
[Fact]
public void Test_ScopeId_Equality()
{
// The rules of ScopeId are simple: Private executor scopes (executorId, scopeId=null) are only equal to
// themselves. Public ScopeIds are equal when their scopeNames are equal, regardless of executorId.
ScopeId privateScope1 = new("executor1", null);
ScopeId privateScope2 = new("executor2", null);
Assert.NotEqual(privateScope1, privateScope2);
Assert.Equal(privateScope1, new ScopeId("executor1", null));
ScopeId sharedScope1 = new("executor1", "sharedScope");
ScopeId sharedScope2 = new("executor2", "sharedScope");
Assert.Equal(sharedScope1, sharedScope2);
Assert.NotEqual(sharedScope1, new ScopeId("executor1", "differentScope"));
Assert.NotEqual(sharedScope1, privateScope1);
}
[Fact]
public void Test_UpdateKey_Equality()
{
// The rules of UpdateKey are different from ScopeId. In the case of "shared scope",
// two update keys with different ExecutorIds are not the same.
const string Key1 = "key1";
const string Key2 = "key2";
UpdateKey privateScope1Key = new("executor1", null, Key1);
UpdateKey privateScope1Key2 = new("executor1", null, Key2);
Assert.NotEqual(privateScope1Key, privateScope1Key2);
UpdateKey privateScope2Key = new("executor2", null, Key1);
Assert.NotEqual(privateScope1Key, privateScope2Key);
UpdateKey scope1Executor1Key = new("executor1", "sharedScope", Key1);
UpdateKey scope1Executor2Key = new("executor2", "sharedScope", Key1);
Assert.NotEqual(scope1Executor1Key, scope1Executor2Key);
}
[Fact]
public void Test_UpdateKey_IsMatchingScope()
{
const string Key1 = "key1";
UpdateKey privateScope1Key = new("executor1", null, Key1);
UpdateKey privateScope2Key = new("executor2", null, Key1);
ScopeId privateScope1 = new("executor1", null);
ScopeId privateScope2 = new("executor2", null);
ValidateMatch(privateScope1Key, privateScope1, expectedStrict: true, expectedLoose: true);
ValidateMatch(privateScope1Key, privateScope2, expectedStrict: false, expectedLoose: false);
ValidateMatch(privateScope2Key, privateScope1, expectedStrict: false, expectedLoose: false);
ValidateMatch(privateScope2Key, privateScope2, expectedStrict: true, expectedLoose: true);
UpdateKey sharedScope1Key = new("executor1", "sharedScope", Key1);
UpdateKey sharedScope2Key = new("executor2", "sharedScope", Key1);
ScopeId sharedScope1 = new("executor1", "sharedScope");
ScopeId sharedScope2 = new("executor2", "sharedScope");
ValidateMatch(sharedScope1Key, sharedScope1, expectedStrict: true, expectedLoose: true);
ValidateMatch(sharedScope1Key, sharedScope2, expectedStrict: false, expectedLoose: true);
ValidateMatch(sharedScope2Key, sharedScope1, expectedStrict: false, expectedLoose: true);
ValidateMatch(sharedScope2Key, sharedScope2, expectedStrict: true, expectedLoose: true);
// Cross checks between private and shared scopes should never match
ValidateMatch(privateScope1Key, sharedScope1, expectedStrict: false, expectedLoose: false);
ValidateMatch(privateScope1Key, sharedScope2, expectedStrict: false, expectedLoose: false);
ValidateMatch(privateScope2Key, sharedScope1, expectedStrict: false, expectedLoose: false);
ValidateMatch(privateScope2Key, sharedScope2, expectedStrict: false, expectedLoose: false);
ValidateMatch(sharedScope1Key, privateScope1, expectedStrict: false, expectedLoose: false);
ValidateMatch(sharedScope1Key, privateScope2, expectedStrict: false, expectedLoose: false);
ValidateMatch(sharedScope2Key, privateScope1, expectedStrict: false, expectedLoose: false);
ValidateMatch(sharedScope2Key, privateScope2, expectedStrict: false, expectedLoose: false);
static void ValidateMatch(UpdateKey key, ScopeId scope, bool expectedStrict, bool expectedLoose)
{
key.IsMatchingScope(scope, strict: true).Should().Be(expectedStrict);
key.IsMatchingScope(scope, strict: false).Should().Be(expectedLoose);
}
}
}

View File

@@ -0,0 +1,571 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class StateManagerTests
{
[Fact]
public async Task Test_SharedScope_ReadKeysAsync()
{
const string? ScopeName = "sharedScope";
await RunScopeKeysTestAsync(ScopeName, isSharedScope: true);
}
[Fact]
public async Task Test_PrivateScope_ReadKeysAsync()
{
const string? ScopeName = null;
await RunScopeKeysTestAsync(ScopeName, isSharedScope: false);
}
private static async Task RunScopeKeysTestAsync(string? scopeName, bool isSharedScope)
{
const string SelfExecutorId = "executor1";
const string OtherExecutorId = "executor2";
const string Key1 = "key1";
HashSet<string> ExpectedAfterWrite = [Key1];
StateManager manager = new();
ScopeId sharedScopeSelfView = new(SelfExecutorId, scopeName);
ScopeId sharedScopeOtherView = new(OtherExecutorId, scopeName);
// Assert baseline: neither executor sees any keys
HashSet<string> selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView);
selfKeys.Should().BeEmpty("there should be no keys in an empty StateManager");
HashSet<string> otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView);
otherKeys.Should().BeEmpty("there should be no keys in an empty StateManager");
// Act 1: Write a key from the self executor's view of the shared scope
await manager.WriteStateAsync(sharedScopeSelfView, Key1, "value1");
// Assert 1: The self executor should see the key immediately, but the other executor should not
selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView);
selfKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("writes should be visible immediately to the writing executor");
otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView);
otherKeys.Should().BeEmpty(isSharedScope ? "writes should not be visible to other executors until published"
: "writes to private scopes should not be visible across executors");
// Act 2: Publish the updates
await manager.PublishUpdatesAsync(tracer: null);
// Assert 2: Both executors should see the key now, if sharedScope
selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView);
selfKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors");
otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView);
if (isSharedScope)
{
otherKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors");
}
else
{
otherKeys.Should().BeEmpty("writes to private scopes should not be visible across executors");
}
// Act 3: Clear the state from the self executor's view of the shared scope
await manager.WriteStateAsync<string?>(sharedScopeSelfView, Key1, null);
// Assert 3: The self executor should not see the key immediately, but the other executor should still see it if sharedScope
selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView);
selfKeys.Should().BeEmpty("deletes should be visible immediately to the writing executor");
otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView);
if (isSharedScope)
{
otherKeys.SetEquals(ExpectedAfterWrite).Should().BeTrue("published writes should be visible to all executors");
}
else
{
otherKeys.Should().BeEmpty("writes to private scopes should not be visible across executors");
}
// Act 4: Publish the updates
await manager.PublishUpdatesAsync(tracer: null);
// Assert 4: Neither executor should see the key now
selfKeys = await manager.ReadKeysAsync(sharedScopeSelfView);
selfKeys.Should().BeEmpty("published deletes should be visible to all executors");
otherKeys = await manager.ReadKeysAsync(sharedScopeOtherView);
otherKeys.Should().BeEmpty(isSharedScope ? "published deletes should be visible to all executors"
: "writes to private scopes should not be visible across executors");
}
[Fact]
public async Task Test_SharedScope_ValueLifecycleAsync()
{
const string? ScopeName = "sharedScope";
await RunValueLifecycleTestAsync(ScopeName, isSharedScope: true);
}
[Fact]
public async Task Test_PrivateScope_ValueLifecycleAsync()
{
const string? ScopeName = null;
await RunValueLifecycleTestAsync(ScopeName, isSharedScope: false);
}
private static async Task RunValueLifecycleTestAsync(string? scopeName, bool isSharedScope)
{
const string SelfExecutorId = "executor1";
const string OtherExecutorId = "executor2";
const string Key1 = "key1", Key2 = "key2";
const string Value1 = "value1", Value2 = "value2";
StateManager manager = new();
ScopeId scopeSelfView = new(SelfExecutorId, scopeName);
ScopeId scopeOtherView = new(OtherExecutorId, scopeName);
isSharedScope.Should().Be(scopeSelfView == scopeOtherView);
// Assert baseline: neither executor sees any keys or values
string? selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
string? selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue1.Should().BeNull("there should be no values in an empty StateManager");
selfValue2.Should().BeNull("there should be no values in an empty StateManager");
string? otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
string? otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue1.Should().BeNull("there should be no values in an empty StateManager");
otherValue2.Should().BeNull("there should be no values in an empty StateManager");
// Act 1: Write a value from the self executor's view of the shared scope
await manager.WriteStateAsync(scopeSelfView, Key1, Value1);
// Assert 1: The self executor should see the value immediately, but the other executor should not
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().Be(Value1, "writes should be visible immediately to the writing executor");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull("uninvolved keys' state/value should not change after a write");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key1: written by self, read by other)"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().BeNull("uninvolved keys' state/value should not change after a write");
// Act 2: Write a value from the other executor's view of the shared scope
await manager.WriteStateAsync(scopeOtherView, Key2, Value2);
// Assert 2: The other executor should see the value immediately, but the self executor should not
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().Be(Value1, "uninvolved keys' state/value should not change after a write");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key2: written by other, read by self)"
: "writes to private scopes should not be visible across executors");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "writes should not be visible to other executors until published (key1: written by self, read by other)"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().Be(Value2, "writes should be visible immediately to the writing executor");
// Act 3: Publish the updates
await manager.PublishUpdatesAsync(tracer: null);
// Assert 3: Both executors should see both values now, if the scope is shared
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().Be(Value1, "published writes should be visible to all executors (key1: written by self, read by self)");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
if (isSharedScope)
{
selfValue2.Should().Be(Value2, "published writes should be visible to all executors (key2: written by other, read by self)");
}
else
{
selfValue2.Should().BeNull("writes to private scopes should not be visible across executors");
}
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
if (isSharedScope)
{
otherValue1.Should().Be(Value1, "published writes should be visible to all executors (key1: written by self, read by other)");
}
else
{
otherValue1.Should().BeNull("writes to private scopes should not be visible across executors");
}
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().Be(Value2, "published writes should be visible to all executors (key2: written by other, read by other)");
// Act 4: Clear the value from the self executor's view of the shared scope
await manager.ClearStateAsync(scopeSelfView);
// Assert 4: The self executor should not see either value immediately, but the other executor should still see both
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().BeNull("clears should be visible immediately to the writing executor");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull(isSharedScope ? "clears should be visible immediately to the writing executor"
: "writes to private scopes should not be visible across executors");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
if (isSharedScope)
{
otherValue1.Should().Be(Value1, "clears should not be visible to other executors until published (key2: written by self, read by other)");
}
else
{
otherValue1.Should().BeNull("writes to private scopes should not be visible across executors");
}
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().Be(Value2, isSharedScope ? "clears should not be visible to other executors until published (key2: written by self, read by other)"
: "writes to private scopes should not be visible across executors");
// Act 5: Publish the updates
await manager.PublishUpdatesAsync(tracer: null);
// Assert 5: Neither executor should see either value now
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().BeNull("published clears should be visible to all executors");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull(isSharedScope ? "published clears should be visible to all executors"
: "writes to private scopes should not be visible across executors");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "published clears should be visible to all executors"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
if (isSharedScope)
{
otherValue2.Should().BeNull("published clears should be visible to all executors");
}
else
{
otherValue2.Should().Be(Value2, "writes to private scopes should not be visible across executors");
}
// Restore the written state of both keys
await manager.WriteStateAsync(scopeSelfView, Key1, Value1);
await manager.WriteStateAsync(scopeOtherView, Key2, Value2);
await manager.PublishUpdatesAsync(tracer: null);
// Act 6: Delete Key1 from the other executor's view of the shared scope
await manager.WriteStateAsync<string?>(scopeOtherView, Key1, null);
// Assert 6: The other executor should not see Key1 immediately, but should still see Key2. The self executor should still see both.
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().Be(Value1, isSharedScope ? "deletes should not be visible to other executors until published (key1: written by other, read by self)"
: "writes to private scopes should not be visible across executors");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
if (isSharedScope)
{
selfValue2.Should().Be(Value2, "uninvolved keys' state/value should not change after a delete");
}
else
{
selfValue2.Should().BeNull("writes to private scopes should not be visible across executors");
}
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().Be(Value2, "uninvolved keys' state/value should not change after a delete");
// Act 7: Delete Key2 from the self executor's view of the shared scope
await manager.WriteStateAsync<string?>(scopeSelfView, Key2, null);
// Assert 7: The self executor should not see Key2 immediately, but should still see Key1.
// The other executor should not see Key1, but should still see Key2.
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
selfValue1.Should().Be(Value1, isSharedScope ? "deletes should not be visible to other executors until published (key1: written by other, read by self)"
: "writes to private scopes should not be visible across executors");
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor"
: "writes to private scopes should not be visible across executors");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "deletes should be visible immediately to the writing executor"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
otherValue2.Should().Be(Value2, isSharedScope ? "deletes should not be visible to other executors until published (key2: written by self, read by other)"
: "writes to private scopes should not be visible across executors");
// Act 8: Publish the updates
await manager.PublishUpdatesAsync(tracer: null);
// Assert 8: Neither executor should see either value now
selfValue1 = await manager.ReadStateAsync<string>(scopeSelfView, Key1);
if (isSharedScope)
{
selfValue1.Should().BeNull("published deletes should be visible to all executors");
}
else
{
selfValue1.Should().Be(Value1, "writes to private scopes should not be visible across executors");
}
selfValue2 = await manager.ReadStateAsync<string>(scopeSelfView, Key2);
selfValue2.Should().BeNull(isSharedScope ? "published deletes should be visible to all executors"
: "writes to private scopes should not be visible across executors");
otherValue1 = await manager.ReadStateAsync<string>(scopeOtherView, Key1);
otherValue1.Should().BeNull(isSharedScope ? "published deletes should be visible to all executors"
: "writes to private scopes should not be visible across executors");
otherValue2 = await manager.ReadStateAsync<string>(scopeOtherView, Key2);
if (isSharedScope)
{
otherValue2.Should().BeNull("published deletes should be visible to all executors");
}
else
{
otherValue2.Should().Be(Value2, "writes to private scopes should not be visible across executors");
}
}
[Fact]
public async Task Test_SharedScope_ConflictingUpdatesAsync()
{
const string? ScopeName = "sharedScope";
await RunConflictingUpdatesTest_WriteVsWriteAsync(ScopeName, isSharedScope: true);
await RunConflictingUpdatesTest_WriteVsDeleteAsync(ScopeName, isSharedScope: true);
await RunConflictingUpdatesTest_WriteVsClearAsync(ScopeName, isSharedScope: true);
}
[Fact]
public async Task Test_PrivateScope_ConflictingUpdatesAsync()
{
const string? ScopeName = null;
await RunConflictingUpdatesTest_WriteVsWriteAsync(ScopeName, isSharedScope: false);
await RunConflictingUpdatesTest_WriteVsDeleteAsync(ScopeName, isSharedScope: false);
await RunConflictingUpdatesTest_WriteVsClearAsync(ScopeName, isSharedScope: false);
}
private static async Task RunConflictingUpdatesTest_WriteVsWriteAsync(string? scopeName, bool isSharedScope)
{
const string SelfExecutorId = "executor1";
const string OtherExecutorId = "executor2";
const string Key1 = "key1";
const string Value1 = "value", Value2 = "value";
// Arrange
StateManager manager = new();
ScopeId scopeSelfView = new(SelfExecutorId, scopeName);
ScopeId scopeOtherView = new(OtherExecutorId, scopeName);
isSharedScope.Should().Be(scopeSelfView == scopeOtherView);
// Act 1: Write a conflicting value from the self executor's view of the shared scope
// Note that conflicting means update to the same key, not that the values are necessarily different.
// We do not have any logic to resolve equivalent updates from different executors as idempotent.
await manager.WriteStateAsync(scopeSelfView, Key1, Value1);
await manager.WriteStateAsync(scopeOtherView, Key1, Value2);
Func<Task> act = async () => await manager.PublishUpdatesAsync(tracer: null);
if (isSharedScope)
{
await act.Should().ThrowAsync<InvalidOperationException>("conflicting writes to the same key should raise an exception when published");
}
else
{
await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors");
}
}
private static async Task RunConflictingUpdatesTest_WriteVsDeleteAsync(string? scopeName, bool isSharedScope)
{
const string SelfExecutorId = "executor1";
const string OtherExecutorId = "executor2";
const string Key1 = "key1", Key2 = "key2";
const string Value1 = "value", Value2 = "value";
// Arrange
StateManager manager = new();
ScopeId scopeSelfView = new(SelfExecutorId, scopeName);
ScopeId scopeOtherView = new(OtherExecutorId, scopeName);
isSharedScope.Should().Be(scopeSelfView == scopeOtherView);
await manager.WriteStateAsync(scopeSelfView, Key1, Value1);
await manager.WriteStateAsync(scopeOtherView, Key2, Value2);
await manager.PublishUpdatesAsync(tracer: null);
// Act: Update the key from one executor and delete it from another
await manager.WriteStateAsync(scopeSelfView, Key1, "newValue");
await manager.ClearStateAsync(scopeOtherView, Key1);
Func<Task> act = async () => await manager.PublishUpdatesAsync(tracer: null);
if (isSharedScope)
{
await act.Should().ThrowAsync<InvalidOperationException>("conflicting writes (update vs delete) should raise an exception when published");
}
else
{
await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors");
}
}
private static async Task RunConflictingUpdatesTest_WriteVsClearAsync(string? scopeName, bool isSharedScope)
{
const string SelfExecutorId = "executor1";
const string OtherExecutorId = "executor2";
const string Key1 = "key1", Key2 = "key2";
const string Value1 = "value", Value2 = "value";
// Arrange
StateManager manager = new();
ScopeId scopeSelfView = new(SelfExecutorId, scopeName);
ScopeId scopeOtherView = new(OtherExecutorId, scopeName);
isSharedScope.Should().Be(scopeSelfView == scopeOtherView);
await manager.WriteStateAsync(scopeSelfView, Key1, Value1);
await manager.WriteStateAsync(scopeOtherView, Key2, Value2);
await manager.PublishUpdatesAsync(tracer: null);
// Act: Update the key from one, and clear the entire scope from another
await manager.WriteStateAsync(scopeSelfView, Key1, "newValue");
await manager.ClearStateAsync(scopeOtherView);
Func<Task> act = async () => await manager.PublishUpdatesAsync(tracer: null);
// Assert
if (isSharedScope)
{
await act.Should().ThrowAsync<InvalidOperationException>("conflicting writes (update vs clear) should raise an exception when published");
}
else
{
await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors");
}
}
private static void VerifyIs<TExpectedType>(PortableValue? candidatePV, TExpectedType value)
{
candidatePV.Should().NotBeNull();
candidatePV.Is(out TExpectedType? candidateValue).Should().BeTrue();
candidateValue.Should().Be(value);
}
private static void VerifyIsNot<TExpectedType>(PortableValue? candidatePV)
{
candidatePV.Should().NotBeNull();
candidatePV.Is(out TExpectedType? _).Should().BeFalse();
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Test_LoadPortableValueStateAsync(bool publishStateUpdates)
{
ScopeId scope = new("executor1");
const string StringValue = "string";
const int IntValue = 42;
ScopeKey ScopeKey = new("executor1", "scope", "key");
PortableValue PortableValueValue = new(StringValue);
// Arrange
StateManager manager = new();
await manager.WriteStateAsync(scope, nameof(StringValue), StringValue);
await manager.WriteStateAsync(scope, nameof(IntValue), IntValue);
await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey);
await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue);
if (publishStateUpdates)
{
await manager.PublishUpdatesAsync(tracer: null);
}
// Act & Assert - Read as the original types
PortableValue? stringAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(StringValue));
VerifyIs(stringAsPV, StringValue);
VerifyIsNot<int>(stringAsPV);
VerifyIsNot<ChatMessage>(stringAsPV);
VerifyIsNot<PortableValue>(stringAsPV);
PortableValue? intAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(IntValue));
VerifyIsNot<string>(intAsPV);
VerifyIs(intAsPV, IntValue);
VerifyIsNot<ChatMessage>(intAsPV);
VerifyIsNot<PortableValue>(intAsPV);
PortableValue? scopeKeyAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(ScopeKey));
VerifyIsNot<string>(scopeKeyAsPV);
VerifyIsNot<int>(scopeKeyAsPV);
VerifyIs(scopeKeyAsPV, ScopeKey);
VerifyIsNot<PortableValue>(scopeKeyAsPV);
PortableValue? pvAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(PortableValueValue));
VerifyIs(pvAsPV, StringValue);
VerifyIsNot<int>(pvAsPV);
VerifyIsNot<ChatMessage>(pvAsPV);
// Check that we don't double-wrap stored PortableValues on the out path
VerifyIsNot<PortableValue>(pvAsPV);
}
[Fact]
public async Task Test_LoadPortableValueState_AfterSerializationAsync()
{
ScopeId scope = new("executor1");
const string StringValue = "string";
const int IntValue = 42;
ScopeKey ScopeKey = new("executor1", "scope", "key");
PortableValue PortableValueValue = new(StringValue);
// Arrange
StateManager manager = new();
await manager.WriteStateAsync(scope, nameof(StringValue), StringValue);
await manager.WriteStateAsync(scope, nameof(IntValue), IntValue);
await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey);
await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue);
await manager.PublishUpdatesAsync(tracer: null);
Dictionary<ScopeKey, PortableValue> exportedState = await manager.ExportStateAsync();
Dictionary<ScopeKey, PortableValue> serializedState = JsonSerializationTests.RunJsonRoundtrip(exportedState);
Checkpoint testCheckpoint = new(0, JsonSerializationTests.CreateTestWorkflowInfo(), new([], [], []), serializedState, []);
manager = new();
await manager.ImportStateAsync(testCheckpoint);
// Act & Assert - Read as the original types
PortableValue? stringAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(StringValue));
VerifyIs(stringAsPV, StringValue);
VerifyIsNot<int>(stringAsPV);
VerifyIsNot<ChatMessage>(stringAsPV);
PortableValue? intAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(IntValue));
VerifyIsNot<string>(intAsPV);
VerifyIs(intAsPV, IntValue);
VerifyIsNot<ChatMessage>(intAsPV);
PortableValue? scopeKeyAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(ScopeKey));
VerifyIsNot<string>(scopeKeyAsPV);
VerifyIsNot<int>(scopeKeyAsPV);
VerifyIs(scopeKeyAsPV, ScopeKey);
VerifyIsNot<PortableValue>(scopeKeyAsPV);
PortableValue? pvAsPV = await manager.ReadStateAsync<PortableValue>(scope, nameof(PortableValueValue));
VerifyIs(pvAsPV, StringValue);
VerifyIsNot<int>(pvAsPV);
VerifyIsNot<ChatMessage>(pvAsPV);
// Check that we don't double-wrap stored PortableValues on the out path
VerifyIsNot<PortableValue>(pvAsPV);
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class StreamingAggregatorsTests
{
private static TResult? ApplyStreamingAggregator<TInput, TResult>(
Func<TResult?, TInput, TResult?> aggregator,
IEnumerable<TInput> inputs,
TResult? runningResult = default)
{
foreach (TInput input in inputs)
{
runningResult = aggregator(runningResult, input);
}
return runningResult!;
}
[Fact]
public void Test_StreamingAggregators_First()
{
IEnumerable<int?> inputs = [1, 2, 3];
Func<int?, int?, int?> aggregator = StreamingAggregators.First<int?>();
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(1);
// Ensure that subsequent inputs do not change the result
ApplyStreamingAggregator(aggregator, inputs.Skip(1), runningResult.Value)
.Should()
.Be(1, "subsequent inputs should not change the result of First aggregator");
}
[Fact]
public void Test_StreamingAggregators_First_WithConversion()
{
IEnumerable<int?> inputs = [2, 4, 6];
Func<int?, int?, int?> aggregator = StreamingAggregators.First<int?, int?>(input => input / 2);
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(1);
// Ensure that subsequent inputs do not change the result
ApplyStreamingAggregator(aggregator, inputs.Skip(1), runningResult.Value)
.Should()
.Be(1, "subsequent inputs should not change the result of First aggregator with conversion");
}
[Fact]
public void Test_StreamingAggregators_Last()
{
IEnumerable<int> inputs = [1, 2, 3];
Func<int, int, int> aggregator = StreamingAggregators.Last<int>();
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(3);
// Ensure that subsequent inputs do change the result
ApplyStreamingAggregator(aggregator, inputs.Take(2), runningResult.Value)
.Should()
.Be(2, "subsequent inputs should change the result of Last aggregator");
}
[Fact]
public void Test_StreamingAggregators_Last_WithConversion()
{
IEnumerable<int> inputs = [2, 4, 6];
Func<int, int, int> aggregator = StreamingAggregators.Last<int, int>(input => input / 2);
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(3);
// Ensure that subsequent inputs do change the result
ApplyStreamingAggregator(aggregator, inputs.Take(2), runningResult.Value)
.Should()
.Be(2, "subsequent inputs should change the result of Last aggregator");
}
[Fact]
public void Test_StreamingAggregators_Union()
{
IEnumerable<int> inputs = [1, 2, 3];
Func<IEnumerable<int>?, int, IEnumerable<int>?> aggregator = StreamingAggregators.Union<int>();
IEnumerable<int>? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().BeEquivalentTo([1, 2, 3], "Union should accumulate all inputs in order");
// Ensure that subsequent inputs concatenate to the existing results
inputs = [4, 5];
ApplyStreamingAggregator(aggregator, inputs, runningResult)
.Should()
.BeEquivalentTo([1, 2, 3, 4, 5], "Union should accumulate all inputs in order including subsequent inputs");
}
[Fact]
public void Test_StreamingAggregators_Union_WithConversion()
{
IEnumerable<int> inputs = [2, 4, 6];
Func<IEnumerable<int>?, int, IEnumerable<int>?> aggregator = StreamingAggregators.Union<int, int>(input => input / 2);
IEnumerable<int>? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().BeEquivalentTo([1, 2, 3],
"Union with conversion should accumulate all converted inputs in order");
// Ensure that subsequent inputs concatenate to the existing results
inputs = [8, 10];
ApplyStreamingAggregator(aggregator, inputs, runningResult)
.Should()
.BeEquivalentTo([1, 2, 3, 4, 5],
"Union with conversion should accumulate all converted inputs in order including subsequent inputs");
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq.Expressions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class SubstitutionVisitor(ParameterExpression parameter, Expression substitution) : ExpressionVisitor
{
private ParameterExpression Parameter => parameter;
private Expression Substitution => substitution;
protected override Expression VisitParameter(ParameterExpression node)
{
if (node.Name == this.Parameter.Name)
{
return this.Substitution;
}
return base.VisitParameter(node);
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal class TestEchoAgent(string? id = null, string? name = null, string? prefix = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name ?? base.Name;
public override async ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return serializedThread.Deserialize<EchoAgentThread>(jsonSerializerOptions) ?? await this.GetNewThreadAsync(cancellationToken);
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) =>
new(new EchoAgentThread());
private static ChatMessage UpdateThread(ChatMessage message, InMemoryAgentThread? thread = null)
{
thread?.MessageStore.Add(message);
return message;
}
private IEnumerable<ChatMessage> EchoMessages(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null)
{
foreach (ChatMessage message in messages)
{
UpdateThread(message, thread as InMemoryAgentThread);
}
IEnumerable<ChatMessage> echoMessages
= from message in messages
where message.Role == ChatRole.User &&
!string.IsNullOrEmpty(message.Text)
select
UpdateThread(new ChatMessage(ChatRole.Assistant, $"{prefix}{message.Text}")
{
AuthorName = this.Name ?? this.Id,
CreatedAt = DateTimeOffset.Now,
MessageId = Guid.NewGuid().ToString("N")
}, thread as InMemoryAgentThread);
return echoMessages.Concat(this.GetEpilogueMessages(options).Select(m => UpdateThread(m, thread as InMemoryAgentThread)));
}
protected virtual IEnumerable<ChatMessage> GetEpilogueMessages(AgentRunOptions? options = null)
{
return [];
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
AgentResponse result =
new(this.EchoMessages(messages, thread, options).ToList())
{
AgentId = this.Id,
CreatedAt = DateTimeOffset.Now,
ResponseId = Guid.NewGuid().ToString("N"),
};
return Task.FromResult(result);
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.EchoMessages(messages, thread, options).ToList())
{
yield return
new(message.Role, message.Contents)
{
AgentId = this.Id,
AuthorName = message.AuthorName,
ResponseId = responseId,
MessageId = message.MessageId,
CreatedAt = message.CreatedAt
};
}
}
private sealed class EchoAgentThread : InMemoryAgentThread;
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
// Checkpointing Types
[JsonSerializable(typeof(TestJsonSerializable))]
[ExcludeFromCodeCoverage]
internal sealed partial class TestJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
internal sealed class TestJsonSerializable
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public override bool Equals(object? obj)
{
if (obj is null)
{
return false;
}
if (obj is not TestJsonSerializable other)
{
return false;
}
return this.Id == other.Id && this.Name == other.Name;
}
public override int GetHashCode() => HashCode.Combine(this.Id, this.Name);
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestRunContext : IRunnerContext
{
private sealed class BoundContext(
string executorId,
TestRunContext runnerContext,
IReadOnlyDictionary<string, string>? traceContext) : IWorkflowContext
{
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
=> runnerContext.AddEventAsync(workflowEvent, cancellationToken);
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
=> this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken);
public ValueTask RequestHaltAsync()
=> this.AddEventAsync(new RequestHaltEvent());
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> default;
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> default;
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> new(default(T?));
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> new([]);
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
=> runnerContext.SendMessageAsync(executorId, message, targetId, cancellationToken);
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
return new(initialStateFactory());
}
public IReadOnlyDictionary<string, string>? TraceContext => traceContext;
public bool ConcurrentRunsEnabled => runnerContext.ConcurrentRunsEnabled;
}
public List<WorkflowEvent> Events { get; } = [];
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken)
{
this.Events.Add(workflowEvent);
return default;
}
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
=> new BoundContext(executorId, this, traceContext);
public List<ExternalRequest> ExternalRequests { get; } = [];
public ValueTask PostAsync(ExternalRequest request)
{
this.ExternalRequests.Add(request);
return default;
}
internal Dictionary<string, List<MessageEnvelope>> QueuedMessages { get; } = [];
internal Dictionary<string, List<object>> QueuedOutputs { get; } = [];
public ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default)
{
if (!this.QueuedMessages.TryGetValue(sourceId, out List<MessageEnvelope>? deliveryQueue))
{
this.QueuedMessages[sourceId] = deliveryQueue = [];
}
deliveryQueue.Add(new(message, sourceId, targetId: targetId));
return default;
}
public ValueTask YieldOutputAsync(string sourceId, object output, CancellationToken cancellationToken = default)
{
if (!this.QueuedOutputs.TryGetValue(sourceId, out List<object>? outputQueue))
{
this.QueuedOutputs[sourceId] = outputQueue = [];
}
outputQueue.Add(output);
return default;
}
ValueTask<StepContext> IRunnerContext.AdvanceAsync(CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Dictionary<string, Executor> Executors { get; set; } = [];
public string StartingExecutorId { get; set; } = string.Empty;
public bool WithCheckpointing => throw new NotSupportedException();
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
new(this.Executors[executorId]);
public ValueTask<IEnumerable<Type>> GetStartingExecutorInputTypesAsync(CancellationToken cancellationToken = default)
{
if (this.Executors.TryGetValue(this.StartingExecutorId, out Executor? executor))
{
return new(executor.InputTypes);
}
throw new InvalidOperationException($"No executor with ID '{this.StartingExecutorId}' is registered in this context.");
}
public ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
=> this.AddEventAsync(workflowEvent, cancellationToken);
ValueTask ISuperStepJoinContext.SendMessageAsync<TMessage>(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellationToken)
=> this.SendMessageAsync(senderId, message, cancellationToken: cancellationToken);
ValueTask ISuperStepJoinContext.YieldOutputAsync<TOutput>(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TOutput output, CancellationToken cancellationToken)
=> this.YieldOutputAsync(senderId, output, cancellationToken);
ValueTask<string> ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => new(string.Empty);
ValueTask<bool> ISuperStepJoinContext.DetachSuperstepAsync(string joinId) => new(false);
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class TestRunState
{
public ConcurrentDictionary<string, ConcurrentQueue<object>> SentMessages = new();
public StateManager StateManager { get; } = new();
public ConcurrentQueue<WorkflowEvent> EmittedEvents { get; } = new();
public ConcurrentDictionary<string, ConcurrentQueue<object>> YieldedOutputs { get; } = new();
private int _haltRequests;
public int HaltRequests
{
get => Volatile.Read(ref this._haltRequests);
}
public void IncrementHaltRequests()
{
Interlocked.Increment(ref this._haltRequests);
}
public TestWorkflowContext ContextFor(string executorId) => new(executorId, this);
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class TestWorkflowContext : IWorkflowContext
{
private readonly string _executorId;
private readonly TestRunState _state;
public TestWorkflowContext(string executorId, TestRunState? state = null, bool concurrentRunsEnabled = false)
{
this._executorId = executorId;
this._state = state ?? new TestRunState();
this.ConcurrentRunsEnabled = concurrentRunsEnabled;
}
public bool ConcurrentRunsEnabled { get; }
public ConcurrentQueue<object> SentMessages => this._state.SentMessages.GetOrAdd(this._executorId, _ => new());
public StateManager StateManager => this._state.StateManager;
public ConcurrentQueue<WorkflowEvent> EmittedEvents => this._state.EmittedEvents;
public ConcurrentQueue<object> YieldedOutputs => this._state.YieldedOutputs.GetOrAdd(this._executorId, _ => new());
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
this.EmittedEvents.Enqueue(workflowEvent);
return default;
}
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
this.YieldedOutputs.Enqueue(output);
return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken);
}
public ValueTask RequestHaltAsync()
{
this._state.IncrementHaltRequests();
return default;
}
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ClearStateAsync(new ScopeId(this._executorId, scopeName));
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.WriteStateAsync(new ScopeId(this._executorId, scopeName), key, value);
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadStateAsync<T>(new ScopeId(this._executorId, scopeName), key);
public ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadOrInitStateAsync(new ScopeId(this._executorId, scopeName), key, initialStateFactory);
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
=> this.StateManager.ReadKeysAsync(new ScopeId(this._executorId, scopeName));
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
this.SentMessages.Enqueue(message);
return default;
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
{
private readonly bool _loop;
private readonly Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] _actions;
private readonly HashSet<CancellationToken> _linkedTokens = [];
private CancellationTokenSource _internalCts = new();
public int Iterations { get; private set; }
public bool AtEnd => this._nextActionIndex >= this._actions.Length;
public bool Completed => !this._loop && this.AtEnd;
protected TestingExecutor(string id, bool loop = false, params Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] actions) : base(id)
{
this._loop = loop;
this._actions = actions;
}
public void UnlinkCancellation(CancellationToken cancellationToken) =>
this._linkedTokens.Remove(cancellationToken);
public void LinkCancellation(CancellationToken cancellationToken)
{
this._linkedTokens.Add(cancellationToken);
CancellationTokenSource tokenSource = CancellationTokenSource.CreateLinkedTokenSource(this._linkedTokens.ToArray());
tokenSource = Interlocked.Exchange(ref this._internalCts, tokenSource);
tokenSource.Dispose();
}
public void SetCancel() =>
Volatile.Read(ref this._internalCts).Cancel();
protected sealed override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TIn, TOut>(this.RouteToActionsAsync);
private int _nextActionIndex;
private ValueTask<TOut> RouteToActionsAsync(TIn message, IWorkflowContext context)
{
if (this.AtEnd)
{
if (this._loop)
{
this.Iterations++;
this._nextActionIndex = 0;
}
else
{
throw new InvalidOperationException("No more actions to execute and looping is disabled.");
}
}
try
{
Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>> action = this._actions[this._nextActionIndex];
return action(message, context, Volatile.Read(ref this._internalCts).Token);
}
finally
{
this._nextActionIndex++;
}
}
~TestingExecutor()
{
this.Dispose(false);
}
protected virtual void Dispose(bool disposing) =>
this._internalCts.Dispose();
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal static partial class ValidationExtensions
{
public static Expression<Func<EdgeConnection, bool>> CreateValidator(this EdgeConnection prototype)
{
return actual => actual.SourceIds.Count == prototype.SourceIds.Count &&
actual.SinkIds.Count == prototype.SinkIds.Count &&
prototype.SourceIds.SequenceEqual(actual.SourceIds) &&
prototype.SinkIds.SequenceEqual(actual.SinkIds);
}
public static Expression<Func<TypeId, bool>> CreateValidator(this TypeId? prototype)
{
return actual => (prototype == null && actual == null)
|| (prototype != null && actual != null
&& actual.AssemblyName == prototype.AssemblyName
&& actual.TypeName == prototype.TypeName);
}
public static Expression<Func<ExecutorInfo, bool>> CreateValidator(this ExecutorInfo prototype)
{
return actual => actual.ExecutorId == prototype.ExecutorId &&
// Rely on the TypeId test to probe TypeId serialization - just validate that we got a functional TypeId
actual.ExecutorType.Equals(prototype.ExecutorType);
}
public static Expression<Func<RequestPortInfo, bool>> CreatePortInfoValidator(this RequestPort prototype)
{
return actual => actual.PortId == prototype.Id &&
// Rely on the TypeId test to probe TypeId serialization - just validate that we got a functional TypeId
actual.RequestType.IsMatch(prototype.Request) &&
actual.ResponseType.IsMatch(prototype.Response);
}
public static Expression<Func<DirectEdgeInfo, bool>> CreateValidator(this DirectEdgeInfo prototype)
{
return actual => actual.Connection == prototype.Connection &&
actual.HasCondition == prototype.HasCondition;
}
public static Expression<Func<FanOutEdgeInfo, bool>> CreateValidator(this FanOutEdgeInfo prototype)
{
return actual => actual.Connection == prototype.Connection &&
actual.HasAssigner == prototype.HasAssigner;
}
public static Expression<Func<FanInEdgeInfo, bool>> CreateValidator(this FanInEdgeInfo prototype)
{
return actual => actual.Connection == prototype.Connection;
}
public static Expression<Func<EdgeInfo, bool>> CreatePolyValidator(this EdgeInfo prototype)
{
switch (prototype.Kind)
{
case EdgeKind.Direct:
{
var innerValidatorExpr = CreateValidator((DirectEdgeInfo)prototype);
// Check that incoming is of the correct type, and if so, chain to the body
Debug.Assert(innerValidatorExpr.Parameters.Count == 1, "Validator is of unexpected arity");
return CreateValidatorExpression(innerValidatorExpr);
}
case EdgeKind.FanOut:
{
var innerValidatorExpr = CreateValidator((FanOutEdgeInfo)prototype);
// Check that incoming is of the correct type, and if so, chain to the body
Debug.Assert(innerValidatorExpr.Parameters.Count == 1, "Validator is of unexpected arity");
return CreateValidatorExpression(innerValidatorExpr);
}
case EdgeKind.FanIn:
{
var innerValidatorExpr = CreateValidator((FanInEdgeInfo)prototype);
// Check that incoming is of the correct type, and if so, chain to the body
Debug.Assert(innerValidatorExpr.Parameters.Count == 1, "Validator is of unexpected arity");
return CreateValidatorExpression(innerValidatorExpr);
}
default:
throw new NotSupportedException($"Unsupported edge type: {prototype.Kind}");
}
Expression<Func<EdgeInfo, bool>> CreateValidatorExpression<TInner>(Expression<Func<TInner, bool>> innerValidator)
where TInner : EdgeInfo
{
var innerParam = innerValidator.Parameters[0];
var innerBody = innerValidator.Body;
var outerParam = Expression.Parameter(typeof(EdgeInfo), "actual");
var convertExpr = Expression.Convert(outerParam, typeof(TInner));
ExpressionVisitor visitor = new SubstitutionVisitor(innerParam, convertExpr);
Expression innerValidatorExpr = visitor.Visit(innerBody);
BinaryExpression bodyExpression = Expression.AndAlso(
Expression.AndAlso(
Expression.Equal(
Expression.Property(outerParam, nameof(EdgeInfo.Kind)),
Expression.Constant(prototype.Kind)
),
Expression.TypeIs(outerParam, typeof(TInner))
),
innerValidatorExpr
);
return Expression.Lambda<Func<EdgeInfo, bool>>(
bodyExpression,
outerParam);
}
}
public static Expression<Func<ScopeId, bool>> CreateValidator(this ScopeId prototype)
{
return actual => actual.ExecutorId == prototype.ExecutorId &&
actual.ScopeName == prototype.ScopeName;
}
public static Expression<Func<ScopeKey, bool>> CreateValidator(this ScopeKey prototype)
{
return actual => actual.Key == prototype.Key &&
actual.ScopeId.ScopeName == prototype.ScopeId.ScopeName &&
actual.ScopeId.ExecutorId == prototype.ScopeId.ExecutorId;
}
public static Expression<Func<ExecutorIdentity, bool>> CreateValidator(this ExecutorIdentity prototype)
{
return actual => actual.Id == prototype.Id;
}
public static Expression<Func<ExternalRequest, bool>> CreateValidator(this ExternalRequest prototype)
{
return actual => actual.RequestId == prototype.RequestId &&
actual.PortInfo == prototype.PortInfo &&
actual.Data == prototype.Data;
}
public static Expression<Func<ExternalResponse, bool>> CreateValidator(this ExternalResponse prototype)
{
return actual => actual.RequestId == prototype.RequestId &&
actual.Data == prototype.Data;
}
public static Expression<Func<ChatMessage, bool>> CreateValidatorCheckingText(this ChatMessage prototype)
{
return actual => actual.Role == prototype.Role &&
actual.AuthorName == prototype.AuthorName &&
actual.CreatedAt == prototype.CreatedAt &&
actual.MessageId == prototype.MessageId &&
actual.Text == prototype.Text;
}
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public partial class WorkflowBuilderSmokeTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
}
private sealed class SomeOtherNoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
}
[Fact]
public void Test_Validation_FailsWhenUnboundExecutors()
{
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.AddEdge(new NoOpExecutor("start"), "unbound")
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_Validation_FailsWhenUnreachableExecutors()
{
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.AddEdge(new NoOpExecutor("unreachable"), new NoOpExecutor("also-unreachable"))
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_Validation_AddEdgesOutOfOrderDoesNotImpactReachability()
{
Workflow workflow = new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.AddEdge(new NoOpExecutor("not-unreachable"), new NoOpExecutor("also-not-unreachable"))
.AddEdge("start", "not-unreachable")
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(3);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings.Should().ContainKey("not-unreachable");
workflow.ExecutorBindings.Should().ContainKey("also-not-unreachable");
workflow.ExecutorBindings.Values.Should().AllSatisfy(binding => binding.ExecutorType.Should().Be<NoOpExecutor>());
}
[Fact]
public void Test_LateBinding_Executor()
{
Workflow workflow = new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_LateImplicitBinding_Executor()
{
NoOpExecutor start = new("start");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, start)
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_RebindToDifferent_Disallowed()
{
NoOpExecutor executor1 = new("start");
SomeOtherNoOpExecutor executor2 = new("start");
Func<Workflow> act = () =>
{
return new WorkflowBuilder("start")
.AddEdge(executor1, executor2)
.Build();
};
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void Test_RebindToSameish_Allowed()
{
NoOpExecutor executor1 = new("start");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(executor1, executor1)
.Build();
workflow.StartExecutorId.Should().Be("start");
workflow.ExecutorBindings.Should().HaveCount(1);
workflow.ExecutorBindings.Should().ContainKey("start");
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
}
[Fact]
public void Test_Workflow_NameAndDescription()
{
// Test with name and description
Workflow workflow1 = new WorkflowBuilder("start")
.WithName("Test Pipeline")
.WithDescription("Test workflow description")
.BindExecutor(new NoOpExecutor("start"))
.Build();
workflow1.Name.Should().Be("Test Pipeline");
workflow1.Description.Should().Be("Test workflow description");
// Test without (defaults to null)
Workflow workflow2 = new WorkflowBuilder("start2")
.BindExecutor(new NoOpExecutor("start2"))
.Build();
workflow2.Name.Should().BeNull();
workflow2.Description.Should().BeNull();
// Test with only name (no description)
Workflow workflow3 = new WorkflowBuilder("start3")
.WithName("Named Only")
.BindExecutor(new NoOpExecutor("start3"))
.Build();
workflow3.Name.Should().Be("Named Only");
workflow3.Description.Should().BeNull();
}
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public sealed class ExpectedException : Exception
{
public ExpectedException(string message)
: base(message)
{
}
public ExpectedException() : base()
{
}
public ExpectedException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
public class WorkflowHostSmokeTests
{
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
{
private sealed class Thread : InMemoryAgentThread
{
public Thread() { }
public Thread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{ }
}
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
return new(new Thread(serializedThread, jsonSerializerOptions));
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
{
return new(new Thread());
}
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return await this.RunStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentResponseAsync(cancellationToken);
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
const string ErrorMessage = "Simulated agent failure.";
if (failByThrowing)
{
throw new ExpectedException(ErrorMessage);
}
yield return new AgentResponseUpdate(ChatRole.Assistant, [new ErrorContent(ErrorMessage)]);
}
}
private static Workflow CreateWorkflow(bool failByThrowing)
{
ExecutorBinding agent = new AlwaysFailsAIAgent(failByThrowing).BindAsExecutor(emitEvents: true);
return new WorkflowBuilder(agent).Build();
}
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptionDetails, bool failByThrowing)
{
string expectedMessage = !failByThrowing || includeExceptionDetails
? "Simulated agent failure."
: "An error occurred while executing the workflow.";
// Arrange is done by the caller.
Workflow workflow = CreateWorkflow(failByThrowing);
// Act
List<AgentResponseUpdate> updates = await workflow.AsAgent("WorkflowAgent", includeExceptionDetails: includeExceptionDetails)
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
.ToListAsync();
// Assert
bool hadErrorContent = false;
foreach (AgentResponseUpdate update in updates)
{
if (update.Contents.Any())
{
// We should expect a single update which contains the error content.
update.Contents.Should().ContainSingle()
.Which.Should().BeOfType<ErrorContent>()
.Which.Message.Should().Be(expectedMessage);
hadErrorContent = true;
}
}
hadErrorContent.Should().BeTrue();
}
}

View File

@@ -0,0 +1,454 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class WorkflowVisualizerTests
{
private sealed class MockExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg));
}
private sealed class ListStrTargetExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<string[]>((msgs, ctx) => ctx.SendMessageAsync(string.Join(",", msgs)));
}
[Fact]
public void Test_WorkflowViz_ToDotString_Basic()
{
// Create a simple workflow
var executor1 = new MockExecutor("executor1");
var executor2 = new MockExecutor("executor2");
var workflow = new WorkflowBuilder("executor1")
.AddEdge(executor1, executor2)
.Build();
var dotContent = workflow.ToDotString();
// Check that the DOT content contains expected elements
dotContent.Should().Contain("digraph Workflow {");
dotContent.Should().Contain("\"executor1\"");
dotContent.Should().Contain("\"executor2\"");
dotContent.Should().Contain("\"executor1\" -> \"executor2\"");
dotContent.Should().Contain("fillcolor=lightgreen"); // Start executor styling
dotContent.Should().Contain("(Start)");
}
[Fact]
public void Test_WorkflowViz_Complex_Workflow()
{
// Test visualization of a more complex workflow
var executor1 = new MockExecutor("start");
var executor2 = new MockExecutor("middle1");
var executor3 = new MockExecutor("middle2");
var executor4 = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(executor1, executor2)
.AddEdge(executor1, executor3)
.AddEdge(executor2, executor4)
.AddEdge(executor3, executor4)
.Build();
var dotContent = workflow.ToDotString();
// Check all executors are present
dotContent.Should().Contain("\"start\"");
dotContent.Should().Contain("\"middle1\"");
dotContent.Should().Contain("\"middle2\"");
dotContent.Should().Contain("\"end\"");
// Check all edges are present
dotContent.Should().Contain("\"start\" -> \"middle1\"");
dotContent.Should().Contain("\"start\" -> \"middle2\"");
dotContent.Should().Contain("\"middle1\" -> \"end\"");
dotContent.Should().Contain("\"middle2\" -> \"end\"");
// Check start executor has special styling
dotContent.Should().Contain("fillcolor=lightgreen");
}
[Fact]
public void Test_WorkflowViz_Conditional_Edge()
{
// Test that conditional edges are rendered dashed with a label
var start = new MockExecutor("start");
var mid = new MockExecutor("mid");
var end = new MockExecutor("end");
// Condition that is never used during viz, but presence should mark the edge
static bool OnlyIfFoo(string? msg) => msg == "foo";
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, mid, OnlyIfFoo)
.AddEdge(mid, end)
.Build();
var dotContent = workflow.ToDotString();
// Conditional edge should be dashed and labeled
dotContent.Should().Contain("\"start\" -> \"mid\" [style=dashed, label=\"conditional\"];");
// Non-conditional edge should be plain
dotContent.Should().Contain("\"mid\" -> \"end\"");
dotContent.Should().NotContain("\"mid\" -> \"end\" [style=dashed");
}
[Fact]
public void Test_WorkflowViz_FanIn_EdgeGroup()
{
// Test that fan-in edges render an intermediate node with label and routed edges
var start = new MockExecutor("start");
var s1 = new MockExecutor("s1");
var s2 = new MockExecutor("s2");
var t = new ListStrTargetExecutor("t");
// Build a connected workflow: start fans out to s1 and s2, which then fan-in to t
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, [s1, s2])
.AddFanInEdge([s1, s2], t) // AddFanInEdge(target, sources)
.Build();
var dotContent = workflow.ToDotString();
// There should be a single fan-in node with special styling and label
var lines = dotContent.Split('\n');
var fanInLines = Array.FindAll(lines, line =>
line.Contains("shape=ellipse") && line.Contains("label=\"fan-in\""));
fanInLines.Should().HaveCount(1);
// Extract the intermediate node id from the line
var fanInLine = fanInLines[0];
var firstQuote = fanInLine.IndexOf('"');
var secondQuote = fanInLine.IndexOf('"', firstQuote + 1);
firstQuote.Should().BeGreaterThan(-1);
secondQuote.Should().BeGreaterThan(-1);
var fanInNodeId = fanInLine.Substring(firstQuote + 1, secondQuote - firstQuote - 1);
fanInNodeId.Should().NotBeNullOrEmpty();
// Edges should be routed through the intermediate node, not direct to target
dotContent.Should().Contain($"\"s1\" -> \"{fanInNodeId}\";");
dotContent.Should().Contain($"\"s2\" -> \"{fanInNodeId}\";");
dotContent.Should().Contain($"\"{fanInNodeId}\" -> \"t\";");
// Ensure direct edges are not present
dotContent.Should().NotContain("\"s1\" -> \"t\"");
dotContent.Should().NotContain("\"s2\" -> \"t\"");
}
// Note: Sub-workflow tests are commented out as the current implementation
// of TryGetNestedWorkflow returns false. These can be enabled once
// WorkflowExecutor detection is implemented.
/*
[Fact]
public void Test_WorkflowViz_SubWorkflow_Digraph()
{
// Test that WorkflowViz can visualize sub-workflows in DOT format
// This test would require WorkflowExecutor implementation
// Currently TryGetNestedWorkflow always returns false
}
[Fact]
public void Test_WorkflowViz_Nested_SubWorkflows()
{
// Test visualization of deeply nested sub-workflows
// This test would require WorkflowExecutor implementation
// Currently TryGetNestedWorkflow always returns false
}
*/
[Fact]
public void Test_WorkflowViz_FanOut_Edges()
{
// Test fan-out edge visualization
var start = new MockExecutor("start");
var target1 = new MockExecutor("target1");
var target2 = new MockExecutor("target2");
var target3 = new MockExecutor("target3");
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, [target1, target2, target3])
.Build();
var dotContent = workflow.ToDotString();
// Check all fan-out edges are present
dotContent.Should().Contain("\"start\" -> \"target1\"");
dotContent.Should().Contain("\"start\" -> \"target2\"");
dotContent.Should().Contain("\"start\" -> \"target3\"");
}
[Fact]
public void Test_WorkflowViz_Mixed_EdgeTypes()
{
// Test workflow with mixed edge types (direct, conditional, fan-out, fan-in)
var start = new MockExecutor("start");
var a = new MockExecutor("a");
var b = new MockExecutor("b");
var c = new MockExecutor("c");
var end = new ListStrTargetExecutor("end");
static bool Condition(string? msg) => msg?.Contains("test") ?? false;
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, a, Condition) // Conditional edge
.AddFanOutEdge(a, [b, c]) // Fan-out
.AddFanInEdge([b, c], end) // Fan-in - AddFanInEdge(target, sources)
.Build();
var dotContent = workflow.ToDotString();
// Check conditional edge
dotContent.Should().Contain("\"start\" -> \"a\" [style=dashed, label=\"conditional\"];");
// Check fan-out edges
dotContent.Should().Contain("\"a\" -> \"b\"");
dotContent.Should().Contain("\"a\" -> \"c\"");
// Check fan-in (should have intermediate node)
dotContent.Should().Contain("shape=ellipse");
dotContent.Should().Contain("label=\"fan-in\"");
}
[Fact]
public void Test_WorkflowViz_SingleNode_Workflow()
{
// Test visualization of a single-node workflow
var executor = new MockExecutor("single");
var workflow = new WorkflowBuilder("single")
.BindExecutor(executor)
.Build();
var dotContent = workflow.ToDotString();
// Check single node is present with start styling
dotContent.Should().Contain("\"single\"");
dotContent.Should().Contain("fillcolor=lightgreen");
dotContent.Should().Contain("(Start)");
}
[Fact]
public void Test_WorkflowViz_SelfLoop_Edge()
{
// Test visualization of self-loop edge
var executor = new MockExecutor("loop");
static bool LoopCondition(string? msg) => (msg?.Length ?? 0) < 10;
var workflow = new WorkflowBuilder("loop")
.AddEdge<string>(executor, executor, LoopCondition)
.Build();
var dotContent = workflow.ToDotString();
// Check self-loop edge is present and conditional
dotContent.Should().Contain("\"loop\" -> \"loop\" [style=dashed, label=\"conditional\"];");
}
[Fact]
public void Test_WorkflowViz_ToMermaidString_Basic()
{
// Test that WorkflowViz can generate a Mermaid diagram
var executor1 = new MockExecutor("executor1");
var executor2 = new MockExecutor("executor2");
var workflow = new WorkflowBuilder("executor1")
.AddEdge(executor1, executor2)
.Build();
var mermaidContent = workflow.ToMermaidString();
// Check that the Mermaid content contains expected elements
mermaidContent.Should().Contain("flowchart TD");
mermaidContent.Should().Contain("executor1[\"executor1 (Start)\"]");
mermaidContent.Should().Contain("executor2[\"executor2\"]");
mermaidContent.Should().Contain("executor1 --> executor2");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Conditional_Edge()
{
// Test that conditional edges are rendered with dotted lines and labels in Mermaid
var start = new MockExecutor("start");
var mid = new MockExecutor("mid");
var end = new MockExecutor("end");
static bool OnlyIfFoo(string? msg) => msg == "foo";
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, mid, OnlyIfFoo)
.AddEdge(mid, end)
.Build();
var mermaidContent = workflow.ToMermaidString();
// Conditional edge should be dotted with label
mermaidContent.Should().Contain("start -. conditional .--> mid");
// Non-conditional edge should be solid
mermaidContent.Should().Contain("mid --> end");
mermaidContent.Should().NotContain("end -. conditional");
}
[Fact]
public void Test_WorkflowViz_Mermaid_FanIn_EdgeGroup()
{
// Test that fan-in edges render an intermediate node with label and routed edges in Mermaid
var start = new MockExecutor("start");
var s1 = new MockExecutor("s1");
var s2 = new MockExecutor("s2");
var t = new ListStrTargetExecutor("t");
var workflow = new WorkflowBuilder("start")
.AddFanOutEdge(start, [s1, s2])
.AddFanInEdge([s1, s2], t)
.Build();
var mermaidContent = workflow.ToMermaidString();
// There should be a fan-in node with special styling
var lines = mermaidContent.Split('\n');
var fanInLines = Array.FindAll(lines, line => line.Contains("((fan-in))"));
fanInLines.Should().HaveCount(1);
// Extract the intermediate node id from the line
var fanInLine = fanInLines[0].Trim();
var fanInNodeId = fanInLine.Substring(0, fanInLine.IndexOf("((fan-in))", StringComparison.Ordinal)).Trim();
fanInNodeId.Should().NotBeNullOrEmpty();
// Edges should be routed through the intermediate node
mermaidContent.Should().Contain($"s1 --> {fanInNodeId}");
mermaidContent.Should().Contain($"s2 --> {fanInNodeId}");
mermaidContent.Should().Contain($"{fanInNodeId} --> t");
// Ensure direct edges are not present
mermaidContent.Should().NotContain("s1 --> t");
mermaidContent.Should().NotContain("s2 --> t");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Complex_Workflow()
{
// Test Mermaid visualization of a more complex workflow
var executor1 = new MockExecutor("start");
var executor2 = new MockExecutor("middle1");
var executor3 = new MockExecutor("middle2");
var executor4 = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(executor1, executor2)
.AddEdge(executor1, executor3)
.AddEdge(executor2, executor4)
.AddEdge(executor3, executor4)
.Build();
var mermaidContent = workflow.ToMermaidString();
// Check all executors are present
mermaidContent.Should().Contain("start[\"start (Start)\"]");
mermaidContent.Should().Contain("middle1[\"middle1\"]");
mermaidContent.Should().Contain("middle2[\"middle2\"]");
mermaidContent.Should().Contain("end[\"end\"]");
// Check all edges are present
mermaidContent.Should().Contain("start --> middle1");
mermaidContent.Should().Contain("start --> middle2");
mermaidContent.Should().Contain("middle1 --> end");
mermaidContent.Should().Contain("middle2 --> end");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Mixed_EdgeTypes()
{
// Test Mermaid workflow with mixed edge types (direct, conditional, fan-out, fan-in)
var start = new MockExecutor("start");
var a = new MockExecutor("a");
var b = new MockExecutor("b");
var c = new MockExecutor("c");
var end = new ListStrTargetExecutor("end");
static bool Condition(string? msg) => msg?.Contains("test") ?? false;
var workflow = new WorkflowBuilder("start")
.AddEdge<string>(start, a, Condition) // Conditional edge
.AddFanOutEdge(a, [b, c]) // Fan-out
.AddFanInEdge([b, c], end) // Fan-in
.Build();
var mermaidContent = workflow.ToMermaidString();
// Check conditional edge
mermaidContent.Should().Contain("start -. conditional .--> a");
// Check fan-out edges
mermaidContent.Should().Contain("a --> b");
mermaidContent.Should().Contain("a --> c");
// Check fan-in (should have intermediate node)
mermaidContent.Should().Contain("((fan-in))");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe()
{
// Test that pipe characters in labels are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "High | Low Priority")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape pipe character
mermaidContent.Should().Contain("start -->|High &#124; Low Priority| end");
// Should not contain unescaped pipe that would break syntax
mermaidContent.Should().NotContain("-->|High | Low");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars()
{
// Test that special characters are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Score >= 90 & < 100")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape special characters
mermaidContent.Should().Contain("&amp;");
mermaidContent.Should().Contain("&gt;");
mermaidContent.Should().Contain("&lt;");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline()
{
// Test that newlines are converted to <br/>
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Line 1\nLine 2")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should convert newline to <br/>
mermaidContent.Should().Contain("Line 1<br/>Line 2");
// Should not contain literal newline in the label (but the overall output has newlines between statements)
mermaidContent.Should().NotContain("Line 1\nLine 2");
}
}