Tweak AgentAbstractionsJsonUtilities to better match AIJsonUtilities (#1094)

Functional change includes setting the encoder used by the options and employing JsonStringEnumConverter.
This commit is contained in:
Stephen Toub
2025-10-02 01:19:27 -04:00
committed by GitHub
Unverified
parent 07db91174b
commit 3ae9065b6b
2 changed files with 110 additions and 3 deletions
@@ -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
/// <item>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</item>
/// <item>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</item>
/// <item>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</item>
/// <item>
/// Enables <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> 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.
/// </item>
/// </list>
/// </para>
/// </remarks>
@@ -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;
}
@@ -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;
/// <summary>
/// Tests for <see cref="AgentAbstractionsJsonUtilities"/>
/// </summary>
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("<script>alert('XSS')</script>", "<script>alert('XSS')</script>")]
[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<NumberContainer>(
"{\"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; }
}
}