mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
23921c0f6e
* Handle external input request and response conversion for workflow as agent scenario * Remove unnecessary test comment * Fix PR comments * Updated to fix edge cases, and add more tests. * Update pending requests to use typed properties instead of relying on StateBag. replying to PR feedback. * Fixed external response de-dup and updated possible brittle test. * Address PR comments on sending turn token for normal messages and handle contentId collision by source agent * Remove unnecessary serialization element and address pr comment on intercepted outgoing requests * Updated MEAI changes for UserInput request and response abstractions.
825 lines
32 KiB
C#
825 lines
32 KiB
C#
// 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);
|
|
}
|
|
|
|
[Fact]
|
|
public void Test_SessionState_JsonRoundtrip_WithPendingRequests()
|
|
{
|
|
// Arrange
|
|
Dictionary<string, ExternalRequest> pendingRequests = new()
|
|
{
|
|
["call-1"] = TestExternalRequest,
|
|
["call-2"] = ExternalRequest.Create(TestPort, "Request2", "OtherData"),
|
|
};
|
|
|
|
WorkflowSession.SessionState prototype = new(
|
|
sessionId: "test-session-123",
|
|
lastCheckpoint: TestParentCheckpointInfo,
|
|
pendingRequests: pendingRequests);
|
|
|
|
// Act
|
|
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
|
|
|
// Assert
|
|
result.SessionId.Should().Be(prototype.SessionId);
|
|
result.LastCheckpoint.Should().Be(prototype.LastCheckpoint);
|
|
result.StateBag.Should().NotBeNull();
|
|
result.PendingRequests.Should().NotBeNull()
|
|
.And.HaveCount(pendingRequests.Count);
|
|
|
|
foreach (string key in pendingRequests.Keys)
|
|
{
|
|
result.PendingRequests.Should().ContainKey(key);
|
|
ValidateExternalRequest(result.PendingRequests![key], pendingRequests[key]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Test_SessionState_JsonRoundtrip_WithoutPendingRequests()
|
|
{
|
|
// Arrange
|
|
WorkflowSession.SessionState prototype = new(
|
|
sessionId: "test-session-456",
|
|
lastCheckpoint: null);
|
|
|
|
// Act
|
|
WorkflowSession.SessionState result = RunJsonRoundtrip(prototype);
|
|
|
|
// Assert
|
|
result.SessionId.Should().Be(prototype.SessionId);
|
|
result.LastCheckpoint.Should().BeNull();
|
|
result.PendingRequests.Should().BeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the default behavior (without AllowOutOfOrderMetadataProperties) fails
|
|
/// when $type metadata is not the first property, demonstrating the PostgreSQL jsonb issue.
|
|
/// See: https://github.com/microsoft/agent-framework/issues/2962
|
|
/// </summary>
|
|
[Fact]
|
|
public void Test_OutOfOrderMetadataProperties_WithoutOption_Fails()
|
|
{
|
|
// Arrange
|
|
JsonMarshaller marshaller = new();
|
|
EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition;
|
|
|
|
// Serialize to JSON
|
|
JsonElement serialized = marshaller.Marshal(edgeInfo);
|
|
string json = serialized.GetRawText();
|
|
|
|
// Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first
|
|
string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json);
|
|
|
|
// Act & Assert - Without the option, deserialization should fail
|
|
JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement;
|
|
Action act = () => marshaller.Marshal<EdgeInfo>(reorderedElement);
|
|
|
|
act.Should().Throw<JsonException>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates PostgreSQL jsonb behavior where property order is not preserved,
|
|
/// causing $type metadata to not be the first property.
|
|
/// This test verifies that deserialization works when AllowOutOfOrderMetadataProperties is enabled.
|
|
/// See: https://github.com/microsoft/agent-framework/issues/2962
|
|
/// </summary>
|
|
[Fact]
|
|
public void Test_OutOfOrderMetadataProperties_WithOptionEnabled_Succeeds()
|
|
{
|
|
// Arrange
|
|
EdgeInfo edgeInfo = TestEdgeInfo_DirectNoCondition;
|
|
|
|
// Serialize to JSON using standard marshaller
|
|
JsonMarshaller marshaller = new();
|
|
JsonElement serialized = marshaller.Marshal(edgeInfo);
|
|
string json = serialized.GetRawText();
|
|
|
|
// Simulate PostgreSQL jsonb behavior: reorder properties so $type is not first
|
|
string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json);
|
|
JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement;
|
|
|
|
// Act - Deserialize with AllowOutOfOrderMetadataProperties enabled via JsonSerializerOptions
|
|
JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true };
|
|
JsonMarshaller marshallerWithOption = new(options);
|
|
EdgeInfo deserialized = marshallerWithOption.Marshal<EdgeInfo>(reorderedElement);
|
|
|
|
// Assert
|
|
deserialized.Should().Match(edgeInfo.CreatePolyValidator());
|
|
}
|
|
|
|
private static string ReorderJsonPropertiesToMoveTypeDiscriminatorLast(string json)
|
|
{
|
|
// Parse JSON, extract $type, rebuild with $type at end
|
|
using JsonDocument doc = JsonDocument.Parse(json);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
Dictionary<string, JsonElement> properties = [];
|
|
JsonElement? typeValue = null;
|
|
|
|
foreach (JsonProperty prop in root.EnumerateObject())
|
|
{
|
|
if (prop.Name == "$type")
|
|
{
|
|
typeValue = prop.Value.Clone();
|
|
}
|
|
else
|
|
{
|
|
properties[prop.Name] = prop.Value.Clone();
|
|
}
|
|
}
|
|
|
|
// Rebuild JSON with $type last
|
|
using System.IO.MemoryStream ms = new();
|
|
using (Utf8JsonWriter writer = new(ms))
|
|
{
|
|
writer.WriteStartObject();
|
|
foreach (KeyValuePair<string, JsonElement> kvp in properties)
|
|
{
|
|
writer.WritePropertyName(kvp.Key);
|
|
kvp.Value.WriteTo(writer);
|
|
}
|
|
|
|
if (typeValue.HasValue)
|
|
{
|
|
writer.WritePropertyName("$type");
|
|
typeValue.Value.WriteTo(writer);
|
|
}
|
|
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
|
|
}
|
|
}
|