diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs index a6a69f258f..1a6a55dd3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs @@ -13,7 +13,13 @@ internal sealed class JsonMarshaller : IWireMarshaller 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()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 686cdea308..c2a538b302 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -672,4 +672,104 @@ public async Task Test_InMemoryCheckpointManager_JsonRoundTripAsync() ValidateCheckpoint(retrievedCheckpoint, prototype); } + + /// + /// 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 + /// + [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(reorderedElement); + + act.Should().Throw(); + } + + /// + /// 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 + /// + [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(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 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 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()); + } }