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
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:
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 | 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("&");
|
||||
mermaidContent.Should().Contain(">");
|
||||
mermaidContent.Should().Contain("<");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user