.NET: Fix: Checkpoint Deserialization breaks when JSON metadata properties are out of order (#3442)

* Fix checkpoint JSON deserialization with out-of-order metadata properties (#2962)

* Simplify: propagate AllowOutOfOrderMetadataProperties from incoming JsonSerializerOptions
This commit is contained in:
Jacob Alber
2026-02-10 10:50:57 -05:00
committed by GitHub
Unverified
parent 6c37ce8450
commit e489ac0fa3
2 changed files with 107 additions and 1 deletions
@@ -13,7 +13,13 @@ internal sealed class JsonMarshaller : IWireMarshaller<JsonElement>
public JsonMarshaller(JsonSerializerOptions? serializerOptions = null)
{
this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions);
this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions)
{
// Propagate from the user-provided options if set; enables support for databases
// like PostgreSQL jsonb that do not preserve property order.
AllowOutOfOrderMetadataProperties = serializerOptions?.AllowOutOfOrderMetadataProperties is true,
};
this._internalOptions.Converters.Add(new PortableValueConverter(this));
this._internalOptions.Converters.Add(new ExecutorIdentityConverter());
this._internalOptions.Converters.Add(new ScopeKeyConverter());
@@ -672,4 +672,104 @@ public class JsonSerializationTests
ValidateCheckpoint(retrievedCheckpoint, prototype);
}
/// <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());
}
}