diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5b7474a64..803f57471 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -14,6 +14,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; using GitHub.Copilot.SDK.Rpc; using System.Globalization; @@ -1226,6 +1227,12 @@ private static JsonSerializerOptions CreateSerializerOptions() options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); options.TypeInfoResolverChain.Add(SDK.Rpc.RpcJsonContext.Default); + // StreamJsonRpc's RequestId needs serialization when CancellationToken fires during + // JSON-RPC operations. Its built-in converter (RequestIdSTJsonConverter) is internal, + // and [JsonSerializable] can't source-gen for it (SYSLIB1220), so we provide our own + // AOT-safe resolver + converter. + options.TypeInfoResolverChain.Add(new RequestIdTypeInfoResolver()); + options.MakeReadOnly(); return options; @@ -1640,6 +1647,50 @@ private static LogLevel MapLevel(TraceEventType eventType) [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; + /// + /// AOT-safe type info resolver for . + /// StreamJsonRpc's own RequestIdSTJsonConverter is internal (SYSLIB1220/CS0122), + /// so we provide our own converter and wire it through + /// to stay fully AOT/trimming-compatible. + /// + private sealed class RequestIdTypeInfoResolver : IJsonTypeInfoResolver + { + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) + { + if (type == typeof(RequestId)) + return JsonMetadataServices.CreateValueInfo(options, new RequestIdJsonConverter()); + return null; + } + } + + private sealed class RequestIdJsonConverter : JsonConverter + { + public override RequestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.TryGetInt64(out long val) + ? new RequestId(val) + : new RequestId(reader.HasValueSequence + ? Encoding.UTF8.GetString(reader.ValueSequence) + : Encoding.UTF8.GetString(reader.ValueSpan)), + JsonTokenType.String => new RequestId(reader.GetString()!), + JsonTokenType.Null => RequestId.Null, + _ => throw new JsonException($"Unexpected token type for RequestId: {reader.TokenType}"), + }; + } + + public override void Write(Utf8JsonWriter writer, RequestId value, JsonSerializerOptions options) + { + if (value.Number.HasValue) + writer.WriteNumberValue(value.Number.Value); + else if (value.String is not null) + writer.WriteStringValue(value.String); + else + writer.WriteNullValue(); + } + } + [GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)] private static partial Regex ListeningOnPortRegex(); } diff --git a/dotnet/test/SerializationTests.cs b/dotnet/test/SerializationTests.cs new file mode 100644 index 000000000..6fb266be1 --- /dev/null +++ b/dotnet/test/SerializationTests.cs @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; +using System.Text.Json; +using System.Text.Json.Serialization; +using StreamJsonRpc; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Tests for JSON serialization compatibility, particularly for StreamJsonRpc types +/// that are needed when CancellationTokens fire during JSON-RPC operations. +/// This test suite verifies the fix for https://github.com/PureWeen/PolyPilot/issues/319 +/// +public class SerializationTests +{ + /// + /// Verifies that StreamJsonRpc.RequestId can be round-tripped using the SDK's configured + /// JsonSerializerOptions. This is critical for preventing NotSupportedException when + /// StandardCancellationStrategy fires during JSON-RPC operations. + /// + [Fact] + public void RequestId_CanBeSerializedAndDeserialized_WithSdkOptions() + { + var options = GetSerializerOptions(); + + // Long id + var jsonLong = JsonSerializer.Serialize(new RequestId(42L), options); + Assert.Equal("42", jsonLong); + Assert.Equal(new RequestId(42L), JsonSerializer.Deserialize(jsonLong, options)); + + // String id + var jsonStr = JsonSerializer.Serialize(new RequestId("req-1"), options); + Assert.Equal("\"req-1\"", jsonStr); + Assert.Equal(new RequestId("req-1"), JsonSerializer.Deserialize(jsonStr, options)); + + // Null id + var jsonNull = JsonSerializer.Serialize(RequestId.Null, options); + Assert.Equal("null", jsonNull); + Assert.Equal(RequestId.Null, JsonSerializer.Deserialize(jsonNull, options)); + } + + [Theory] + [InlineData(0L)] + [InlineData(-1L)] + [InlineData(long.MaxValue)] + public void RequestId_NumericEdgeCases_RoundTrip(long id) + { + var options = GetSerializerOptions(); + var requestId = new RequestId(id); + var json = JsonSerializer.Serialize(requestId, options); + Assert.Equal(requestId, JsonSerializer.Deserialize(json, options)); + } + + /// + /// Verifies the SDK's options can resolve type info for RequestId, + /// ensuring AOT-safe serialization without falling back to reflection. + /// + [Fact] + public void SerializerOptions_CanResolveRequestIdTypeInfo() + { + var options = GetSerializerOptions(); + var typeInfo = options.GetTypeInfo(typeof(RequestId)); + Assert.NotNull(typeInfo); + Assert.Equal(typeof(RequestId), typeInfo.Type); + } + + private static JsonSerializerOptions GetSerializerOptions() + { + var prop = typeof(CopilotClient) + .GetProperty("SerializerOptionsForMessageFormatter", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + var options = (JsonSerializerOptions?)prop?.GetValue(null); + Assert.NotNull(options); + return options; + } +}