From 3ae9065b6b38c43968aff065414df1c1e5b03c0d Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 2 Oct 2025 01:19:27 -0400 Subject: [PATCH] Tweak AgentAbstractionsJsonUtilities to better match AIJsonUtilities (#1094) Functional change includes setting the encoder used by the options and employing JsonStringEnumConverter. --- .../AgentAbstractionsJsonUtilities.cs | 22 ++++- .../AgentAbstractionsJsonUtilitiesTests.cs | 91 +++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentAbstractionsJsonUtilitiesTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentAbstractionsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentAbstractionsJsonUtilities.cs index f7bdea3f9a..d1d35c21bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentAbstractionsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentAbstractionsJsonUtilities.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; using Microsoft.Extensions.AI; @@ -26,6 +27,10 @@ public static partial class AgentAbstractionsJsonUtilities /// Enables defaults. /// Enables as the default ignore condition for properties. /// Enables as the default number handling for number types. + /// + /// Enables when escaping JSON strings. + /// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats, such as HTML and XML. + /// /// /// /// @@ -40,17 +45,28 @@ public static partial class AgentAbstractionsJsonUtilities 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 AIJsonUtilities + }; // Chain with all supported types from Microsoft.Extensions.AI.Abstractions. + // If reflection-based serialization is enabled by default, this includes + // the default type info resolver that utilizes reflection, but we need to manually + // apply the same converter AIJsonUtilities adds for string-based enum serialization, + // as that's not propagated as part of the resolver. options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); + if (JsonSerializer.IsReflectionEnabledByDefault) + { + options.Converters.Add(new JsonStringEnumConverter()); + } options.MakeReadOnly(); return options; } - // Keep in sync with CreateDefaultOptions above. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString)] @@ -65,5 +81,5 @@ public static partial class AgentAbstractionsJsonUtilities [JsonSerializable(typeof(InMemoryChatMessageStore.StoreState))] [ExcludeFromCodeCoverage] - internal sealed partial class JsonContext : JsonSerializerContext; + private sealed partial class JsonContext : JsonSerializerContext; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentAbstractionsJsonUtilitiesTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentAbstractionsJsonUtilitiesTests.cs new file mode 100644 index 0000000000..82e4dab0ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentAbstractionsJsonUtilitiesTests.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.Abstractions.UnitTests; + +/// +/// Tests for +/// +public class AgentAbstractionsJsonUtilitiesTests +{ + [Fact] + public void DefaultOptions_HasExpectedConfiguration() + { + var options = AgentAbstractionsJsonUtilities.DefaultOptions; + + // Must be read-only singleton. + Assert.NotNull(options); + Assert.Same(options, AgentAbstractionsJsonUtilities.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 = AgentAbstractionsJsonUtilities.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, AgentAbstractionsJsonUtilities.DefaultOptions.TryGetTypeInfo(anonType, out _)); + } + + [Fact] + public void DefaultOptions_AllowsReadingNumbersFromStrings_AndOmitsNulls() + { + var obj = JsonSerializer.Deserialize( + "{\"value\":\"42\",\"optional\":null}", // value as string, optional null + AgentAbstractionsJsonUtilities.DefaultOptions); + Assert.NotNull(obj); + Assert.Equal(42, obj!.Value); + Assert.Null(obj.Optional); + Assert.Equal("{\"value\":42}", + JsonSerializer.Serialize(obj, AgentAbstractionsJsonUtilities.DefaultOptions)); // null omitted + } + + [Fact] + public void DefaultOptions_SerializesEnumsAsStrings() + { + Assert.Equal("\"Monday\"", JsonSerializer.Serialize(DayOfWeek.Monday, AgentAbstractionsJsonUtilities.DefaultOptions)); + } + + [Fact] + public void DefaultOptions_UsesCamelCasePropertyNames_ForAgentRunResponse() + { + var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + string json = JsonSerializer.Serialize(response, AgentAbstractionsJsonUtilities.DefaultOptions); + Assert.Contains("\"messages\"", json); + Assert.DoesNotContain("\"Messages\"", json); + } + + private sealed class NumberContainer + { + public int Value { get; set; } + public string? Optional { get; set; } + } +}