From 1cff86b4ff9817bedda83e5c21cfbd574e609e6a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:07:13 +0100 Subject: [PATCH] enable enum serialization as strings and relaxed json escaping (#1267) --- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 11 ++- .../AgentJsonUtilitiesTests.cs | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentJsonUtilitiesTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index 38ce925d5a..3ee7f65009 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -37,10 +38,17 @@ internal static partial class AgentJsonUtilities private static JsonSerializerOptions CreateDefaultOptions() { // Copy the configuration from the source generated context. - JsonSerializerOptions options = new(JsonContext.Default.Options); + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as in AgentAbstractionsJsonUtilities and AIJsonUtilities + }; // Chain with all supported types from Microsoft.Extensions.AI.Abstractions. options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + if (JsonSerializer.IsReflectionEnabledByDefault) + { + options.Converters.Add(new JsonStringEnumConverter()); + } options.MakeReadOnly(); return options; @@ -48,6 +56,7 @@ internal static partial class AgentJsonUtilities // Keep in sync with CreateDefaultOptions above. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString)] diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentJsonUtilitiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentJsonUtilitiesTests.cs new file mode 100644 index 0000000000..0be7b91dea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentJsonUtilitiesTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +#pragma warning disable CA1812 // Avoid uninstantiated internal classes + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Tests for +/// +public class AgentJsonUtilitiesTests +{ + [Fact] + public void DefaultOptions_HasExpectedConfiguration() + { + var options = AgentJsonUtilities.DefaultOptions; + + // Must be read-only singleton. + Assert.NotNull(options); + Assert.Same(options, AgentJsonUtilities.DefaultOptions); + Assert.True(options.IsReadOnly); + + // Must conform to JsonSerializerDefaults.Web + Assert.Equal(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy); + Assert.True(options.PropertyNameCaseInsensitive); + Assert.Equal(JsonNumberHandling.AllowReadingFromString, options.NumberHandling); + + // Additional settings + Assert.Equal(JsonIgnoreCondition.WhenWritingNull, options.DefaultIgnoreCondition); + Assert.Same(JavaScriptEncoder.UnsafeRelaxedJsonEscaping, options.Encoder); + } + + [Theory] + [InlineData("", "")] + [InlineData("""{"forecast":"sunny", "temperature":"75"}""", """{\"forecast\":\"sunny\", \"temperature\":\"75\"}""")] + [InlineData("""{"message":"Πάντα ῥεῖ."}""", """{\"message\":\"Πάντα ῥεῖ.\"}""")] + [InlineData("""{"message":"七転び八起き"}""", """{\"message\":\"七転び八起き\"}""")] + [InlineData("""☺️🤖🌍𝄞""", """☺️\uD83E\uDD16\uD83C\uDF0D\uD834\uDD1E""")] + public void DefaultOptions_UsesExpectedEscaping(string input, string expectedJsonString) + { + var options = AgentJsonUtilities.DefaultOptions; + string json = JsonSerializer.Serialize(input, options); + Assert.Equal($@"""{expectedJsonString}""", json); + } + + [Fact] + public void DefaultOptions_UsesReflectionWhenDefault() + { + Type anonType = new { Name = 42 }.GetType(); + Assert.Equal(JsonSerializer.IsReflectionEnabledByDefault, AgentJsonUtilities.DefaultOptions.TryGetTypeInfo(anonType, out _)); + } + + [Fact] + public void DefaultOptions_AllowsReadingNumbersFromStrings_AndOmitsNulls() + { + var obj = JsonSerializer.Deserialize( + "{\"value\":\"42\",\"optional\":null}", // value as string, optional null + AgentJsonUtilities.DefaultOptions); + Assert.NotNull(obj); + Assert.Equal(42, obj!.Value); + Assert.Null(obj.Optional); + Assert.Equal("{\"value\":42}", + JsonSerializer.Serialize(obj, AgentJsonUtilities.DefaultOptions)); // null omitted + } + + [Fact] + public void DefaultOptions_SerializesEnumsAsStrings() + { + Assert.Equal("\"Monday\"", JsonSerializer.Serialize(DayOfWeek.Monday, AgentJsonUtilities.DefaultOptions)); + } + + [Fact] + public void DefaultOptions_UsesCamelCasePropertyNames_ForAgentRunResponse() + { + var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + string json = JsonSerializer.Serialize(response, AgentJsonUtilities.DefaultOptions); + Assert.Contains("\"messages\"", json); + Assert.DoesNotContain("\"Messages\"", json); + } + + private sealed class NumberContainer + { + public int Value { get; set; } + public string? Optional { get; set; } + } +}