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; }
+ }
+}