From b7cfc4a18a2963c3aea947439f628055351e4b12 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 26 Jan 2026 11:53:34 -0500 Subject: [PATCH 1/2] Fix checkpoint JSON deserialization with out-of-order metadata properties (#2962) --- .../CheckpointManager.cs | 5 +- .../JsonCheckpointManagerOptions.cs | 27 +++++ .../Checkpointing/JsonMarshaller.cs | 10 +- .../JsonSerializationTests.cs | 100 ++++++++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index c50283e728..13db71fb4b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs @@ -42,10 +42,11 @@ internal CheckpointManager(ICheckpointManager impl) /// The checkpoint store to use for persisting and retrieving checkpoint data as JSON elements. Cannot be null. /// Optional custom JSON serializer options to use for serialization and deserialization. Must be provided if /// using custom types in messages or state. + /// Optional checkpoint manager options to configure JSON serialization behavior. /// A CheckpointManager instance configured to serialize checkpoint data as JSON. - public static CheckpointManager CreateJson(ICheckpointStore store, JsonSerializerOptions? customOptions = null) + public static CheckpointManager CreateJson(ICheckpointStore store, JsonSerializerOptions? customOptions = null, JsonCheckpointManagerOptions? checkpointOptions = null) { - JsonMarshaller marshaller = new(customOptions); + JsonMarshaller marshaller = new(customOptions, checkpointOptions); return new(CreateImpl(marshaller, store)); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs new file mode 100644 index 0000000000..2cc0bb426d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Checkpointing; + +/// +/// Options for configuring JSON serialization behavior in the checkpoint manager. +/// +public sealed class JsonCheckpointManagerOptions +{ + /// + /// Gets or sets a value indicating whether JSON deserialization should allow + /// metadata properties (such as $type) to appear in any position within a JSON object. + /// + /// + /// + /// When set to , the JSON deserializer will accept metadata properties + /// regardless of their position in the JSON object. This is useful when working with databases + /// like PostgreSQL that use jsonb columns, which do not preserve property order. + /// + /// + /// The default value is , which requires metadata properties to appear + /// first in the JSON object as per the System.Text.Json default behavior. + /// + /// + /// + public bool AllowOutOfOrderMetadataProperties { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs index a6a69f258f..47eae1d342 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs @@ -11,9 +11,15 @@ internal sealed class JsonMarshaller : IWireMarshaller private readonly JsonSerializerOptions _internalOptions; private readonly JsonSerializerOptions? _externalOptions; - public JsonMarshaller(JsonSerializerOptions? serializerOptions = null) + public JsonMarshaller(JsonSerializerOptions? serializerOptions = null, JsonCheckpointManagerOptions? checkpointOptions = null) { - this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions); + this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions) + { + // We only set this to true if the user explicitly opts into it via checkpoint options. If the options + // are not supplied, or the property is false, we leave the default behavior (false). + AllowOutOfOrderMetadataProperties = checkpointOptions?.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..fc90371a63 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 + JsonCheckpointManagerOptions options = new() { AllowOutOfOrderMetadataProperties = true }; + JsonMarshaller marshallerWithOption = new(serializerOptions: null, 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()); + } } From b5f3ddf89ef8fa6723a2b1590b0a0827bf37fde8 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 27 Jan 2026 16:37:02 -0500 Subject: [PATCH 2/2] Simplify: propagate AllowOutOfOrderMetadataProperties from incoming JsonSerializerOptions --- .../CheckpointManager.cs | 5 ++-- .../JsonCheckpointManagerOptions.cs | 27 ------------------- .../Checkpointing/JsonMarshaller.cs | 8 +++--- .../JsonSerializationTests.cs | 6 ++--- 4 files changed, 9 insertions(+), 37 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index 13db71fb4b..c50283e728 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs @@ -42,11 +42,10 @@ internal CheckpointManager(ICheckpointManager impl) /// The checkpoint store to use for persisting and retrieving checkpoint data as JSON elements. Cannot be null. /// Optional custom JSON serializer options to use for serialization and deserialization. Must be provided if /// using custom types in messages or state. - /// Optional checkpoint manager options to configure JSON serialization behavior. /// A CheckpointManager instance configured to serialize checkpoint data as JSON. - public static CheckpointManager CreateJson(ICheckpointStore store, JsonSerializerOptions? customOptions = null, JsonCheckpointManagerOptions? checkpointOptions = null) + public static CheckpointManager CreateJson(ICheckpointStore store, JsonSerializerOptions? customOptions = null) { - JsonMarshaller marshaller = new(customOptions, checkpointOptions); + JsonMarshaller marshaller = new(customOptions); return new(CreateImpl(marshaller, store)); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs deleted file mode 100644 index 2cc0bb426d..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonCheckpointManagerOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Workflows.Checkpointing; - -/// -/// Options for configuring JSON serialization behavior in the checkpoint manager. -/// -public sealed class JsonCheckpointManagerOptions -{ - /// - /// Gets or sets a value indicating whether JSON deserialization should allow - /// metadata properties (such as $type) to appear in any position within a JSON object. - /// - /// - /// - /// When set to , the JSON deserializer will accept metadata properties - /// regardless of their position in the JSON object. This is useful when working with databases - /// like PostgreSQL that use jsonb columns, which do not preserve property order. - /// - /// - /// The default value is , which requires metadata properties to appear - /// first in the JSON object as per the System.Text.Json default behavior. - /// - /// - /// - public bool AllowOutOfOrderMetadataProperties { get; set; } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs index 47eae1d342..1a6a55dd3e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/JsonMarshaller.cs @@ -11,13 +11,13 @@ internal sealed class JsonMarshaller : IWireMarshaller private readonly JsonSerializerOptions _internalOptions; private readonly JsonSerializerOptions? _externalOptions; - public JsonMarshaller(JsonSerializerOptions? serializerOptions = null, JsonCheckpointManagerOptions? checkpointOptions = null) + public JsonMarshaller(JsonSerializerOptions? serializerOptions = null) { this._internalOptions = new JsonSerializerOptions(WorkflowsJsonUtilities.DefaultOptions) { - // We only set this to true if the user explicitly opts into it via checkpoint options. If the options - // are not supplied, or the property is false, we leave the default behavior (false). - AllowOutOfOrderMetadataProperties = checkpointOptions?.AllowOutOfOrderMetadataProperties is true, + // 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)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index fc90371a63..c2a538b302 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -720,9 +720,9 @@ public void Test_OutOfOrderMetadataProperties_WithOptionEnabled_Succeeds() string reorderedJson = ReorderJsonPropertiesToMoveTypeDiscriminatorLast(json); JsonElement reorderedElement = JsonDocument.Parse(reorderedJson).RootElement; - // Act - Deserialize with AllowOutOfOrderMetadataProperties enabled - JsonCheckpointManagerOptions options = new() { AllowOutOfOrderMetadataProperties = true }; - JsonMarshaller marshallerWithOption = new(serializerOptions: null, options); + // Act - Deserialize with AllowOutOfOrderMetadataProperties enabled via JsonSerializerOptions + JsonSerializerOptions options = new() { AllowOutOfOrderMetadataProperties = true }; + JsonMarshaller marshallerWithOption = new(options); EdgeInfo deserialized = marshallerWithOption.Marshal(reorderedElement); // Assert