Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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>
Expand All @@ -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)]

Expand All @@ -65,5 +81,5 @@ private static JsonSerializerOptions CreateDefaultOptions()
[JsonSerializable(typeof(InMemoryChatMessageStore.StoreState))]

[ExcludeFromCodeCoverage]
internal sealed partial class JsonContext : JsonSerializerContext;
private sealed partial class JsonContext : JsonSerializerContext;
Comment thread
stephentoub marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Loading