From d57fd5c2b472f41ee7cdd08dfc3bd5d1d07a4edf Mon Sep 17 00:00:00 2001 From: David Fowler Date: Mon, 24 Aug 2026 14:53:23 -0700 Subject: [PATCH] fix(dotnet): harden Native AOT serialization Preserve canonical AHP wire settings with custom metadata resolvers and serialize erased inbound handler results using runtime metadata. Expand reflection-disabled Native AOT coverage across packed-package protocol flows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 28 +- clients/dotnet/README.md | 15 +- .../SystemTextJsonAhpSerializer.cs | 60 ++- .../AgentHostProtocol.AotSmoke.csproj | 7 +- .../AgentHostProtocol.AotSmoke/Program.cs | 447 ++++++++++++++---- .../ApiQualityTests.cs | 20 + docs/.changes/20260824-dotnet-native-aot.json | 5 + 7 files changed, 467 insertions(+), 115 deletions(-) create mode 100644 docs/.changes/20260824-dotnet-native-aot.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83ed8b17..72a7c5f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,15 +306,6 @@ jobs: - name: Build .NET solution run: dotnet build --no-restore --configuration Release - - name: Publish and run .NET Native AOT smoke test - run: | - dotnet publish tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj \ - --configuration Release \ - --runtime linux-x64 \ - --self-contained true \ - --output artifacts/aot-smoke - ./artifacts/aot-smoke/AgentHostProtocol.AotSmoke - - name: Test .NET solution run: | timeout --kill-after=30s 6m \ @@ -323,4 +314,21 @@ jobs: --long-running 30 - name: Pack .NET solution - run: dotnet pack --no-build --configuration Release + run: dotnet pack --no-build --configuration Release --output artifacts/packages + + - name: Publish and run .NET Native AOT package smoke test + run: | + dotnet restore tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj \ + --runtime linux-x64 \ + --packages artifacts/package-consumer-cache \ + --source artifacts/packages \ + --source https://api.nuget.org/v3/index.json \ + -p:UsePackedPackages=true + dotnet publish tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj \ + --no-restore \ + --configuration Release \ + --runtime linux-x64 \ + --self-contained true \ + --output artifacts/aot-smoke \ + -p:UsePackedPackages=true + ./artifacts/aot-smoke/AgentHostProtocol.AotSmoke diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index c14ac6d4..c0b7b5e6 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -143,7 +143,8 @@ context's hundreds of implementation-detail properties public API. Custom values passed through `IAhpSerializer` need their own metadata when reflection is disabled. Add the application's context to the options before constructing the serializer; the serializer copies and freezes the options and -adds the AHP context: +adds the AHP context. Protocol camel-case naming and null handling remain fixed; +other caller settings and resolvers are preserved: ```csharp var options = new JsonSerializerOptions(); @@ -151,8 +152,16 @@ options.TypeInfoResolverChain.Add(MyApplicationJsonContext.Default); var serializer = new SystemTextJsonAhpSerializer(options); ``` -CI publishes and runs `tests/AgentHostProtocol.AotSmoke` as a native executable -with `JsonSerializerIsReflectionEnabledByDefault=false`. +This includes non-null application-defined values returned from +`SetServerRequestHandler`: the serializer resolves metadata for the value's +runtime type because the handler contract returns `object`. A null handler +result is emitted as JSON `null` without requiring metadata for `object`. + +CI packs both libraries, restores `tests/AgentHostProtocol.AotSmoke` from those +local NuGet packages, and runs it as a native executable with +`JsonSerializerIsReflectionEnabledByDefault=false`. The smoke covers +initialization, reconnect replay, subscriptions and reducers, custom generic +requests and notifications, and typed and raw inbound request handling. ## Releasing diff --git a/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs index 1e5fcf7a..c9c3ce4a 100644 --- a/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs +++ b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs @@ -23,13 +23,14 @@ public static class AhpJson internal static JsonSerializerOptions CreateOptions(JsonSerializerOptions? source = null) { JsonSerializerOptions options = source is null - ? new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - } + ? new JsonSerializerOptions() : new JsonSerializerOptions(source); + // These settings define the AHP wire contract. Caller options may add + // resolvers, converters, encoders, and other behavior, but cannot change + // generated property names or required-null handling. + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.DefaultIgnoreCondition = JsonIgnoreCondition.Never; options.TypeInfoResolverChain.Insert(0, AhpJsonMetadata.Default); if (CreateReflectionFallback() is { } reflectionResolver) { @@ -55,14 +56,17 @@ internal static JsonSerializerOptions CreateOptions(JsonSerializerOptions? sourc /// public sealed class SystemTextJsonAhpSerializer : IAhpSerializer { + private static readonly JsonElement s_jsonNull = CreateJsonNull(); private readonly JsonSerializerOptions _options; /// Creates the serializer. /// - /// Override options; defaults to . Custom options + /// Custom options; defaults to . Custom options /// are copied, extended with the generated AHP metadata, and frozen so later /// caller mutation cannot change serializer behavior while requests are in - /// flight. Add a custom + /// flight. The AHP camel-case naming and null-handling settings are always + /// enforced. Add a custom + /// /// to serialize non-AHP types when reflection is disabled. /// public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null) @@ -80,11 +84,33 @@ public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null) public static SystemTextJsonAhpSerializer Default { get; } = new(); /// - public string Serialize(T value) => JsonSerializer.Serialize(value, GetTypeInfo()); + public string Serialize(T value) + { + if (typeof(T) != typeof(object)) + { + return JsonSerializer.Serialize(value, GetTypeInfo()); + } + + return value is null + ? "null" + : JsonSerializer.Serialize(value, GetTypeInfo(value.GetType())); + } /// - public JsonElement SerializeToElement(T value) => - JsonSerializer.SerializeToElement(value, GetTypeInfo()); + public JsonElement SerializeToElement(T value) + { + if (typeof(T) != typeof(object)) + { + return JsonSerializer.SerializeToElement(value, GetTypeInfo()); + } + + if (value is not null) + { + return JsonSerializer.SerializeToElement(value, GetTypeInfo(value.GetType())); + } + + return s_jsonNull; + } /// public T Deserialize(string json) => @@ -112,8 +138,20 @@ public TransportMessage EncodeMessage(JsonRpcMessage message) => TransportMessage.FromText(Serialize(message)); private JsonTypeInfo GetTypeInfo() => - _options.GetTypeInfo(typeof(T)) as JsonTypeInfo + GetTypeInfo(typeof(T)) as JsonTypeInfo ?? throw new NotSupportedException( $"No JSON metadata is registered for {typeof(T)}. " + $"Add a JsonSerializerContext for custom types to {nameof(JsonSerializerOptions.TypeInfoResolverChain)}."); + + private JsonTypeInfo GetTypeInfo(Type type) => + _options.GetTypeInfo(type) + ?? throw new NotSupportedException( + $"No JSON metadata is registered for {type}. " + + $"Add a JsonSerializerContext for custom types to {nameof(JsonSerializerOptions.TypeInfoResolverChain)}."); + + private static JsonElement CreateJsonNull() + { + using JsonDocument document = JsonDocument.Parse("null"); + return document.RootElement.Clone(); + } } diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj index 15c7dfed..54ddcf10 100644 --- a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj +++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj @@ -11,10 +11,15 @@ false true false + false - + + + + + diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs index 59fc7660..b61c619e 100644 --- a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs +++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs @@ -1,126 +1,393 @@ -using Microsoft.AgentHostProtocol; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using Microsoft.AgentHostProtocol; -var serializer = SystemTextJsonAhpSerializer.Default; - -var initialize = new InitializeParams +internal static class Program { - Channel = "ahp-root://", - ClientId = "native-aot-smoke", - ProtocolVersions = new List { "0.1.0" }, - InitialSubscriptions = new List { "ahp-root://" }, -}; + public static async Task Main() + { + if (JsonSerializer.IsReflectionEnabledByDefault) + { + throw new InvalidOperationException("Reflection-based JSON serialization must be disabled."); + } -var initializeJson = serializer.Serialize(initialize); -var initializeRoundTrip = serializer.Deserialize(initializeJson); -Require(initializeRoundTrip.ClientId == initialize.ClientId, "InitializeParams round trip failed."); + var customOptions = new JsonSerializerOptions(); + customOptions.TypeInfoResolverChain.Add(SmokeJsonContext.Default); + var serializer = new SystemTextJsonAhpSerializer(customOptions); -var request = new JsonRpcMessage -{ - Request = new JsonRpcRequest - { - Id = 1, - Method = "initialize", - Params = serializer.SerializeToElement(initialize), - }, -}; -var decodedRequest = serializer.DecodeMessage(serializer.EncodeMessage(request)); -Require(decodedRequest.Request?.Method == "initialize", "JSON-RPC framing round trip failed."); - -var action = new StateAction( - new SessionIsReadChangedAction - { - Type = ActionType.SessionIsReadChanged, - IsRead = true, - }); -var actionJson = serializer.Serialize(action); -var actionRoundTrip = serializer.Deserialize(actionJson); -Require( - actionRoundTrip.Value is SessionIsReadChangedAction { IsRead: true }, - "Discriminated action union round trip failed."); -Require( - actionJson.Contains("\"type\":\"session/isReadChanged\"", StringComparison.Ordinal), - "Wire enum conversion failed."); - -var snapshot = new Snapshot -{ - Resource = "ahp-root://", - FromSeq = 42, - State = new SnapshotState + var action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "AOT", + }); + var envelope = new ActionEnvelope + { + Channel = "ahp-session:/native-aot", + ServerSeq = 1, + Action = action, + }; + + string json = serializer.Serialize(envelope); + ActionEnvelope roundTrip = serializer.Deserialize(json); + Ensure( + roundTrip.Action.Value is SessionTitleChangedAction { Title: "AOT" }, + $"StateAction round-trip failed: {json}; actual={roundTrip.Action.Value?.GetType()}."); + + TransportMessage encoded = serializer.EncodeMessage(new JsonRpcMessage + { + Request = new JsonRpcRequest + { + Id = 1, + Method = "smoke", + Params = serializer.SerializeToElement(envelope), + }, + }); + JsonRpcMessage decoded = serializer.DecodeMessage(encoded); + Ensure(decoded.Request is { Method: "smoke" }, "JSON-RPC message round-trip failed."); + + var snapshot = new SnapshotState + { + Root = new RootState + { + Agents = new List(), + }, + }; + string snapshotJson = serializer.Serialize(snapshot); + SnapshotState snapshotRoundTrip = serializer.Deserialize(snapshotJson); + Ensure(snapshotRoundTrip.Root is not null, "SnapshotState round-trip failed."); + + StringOrMarkdown markdown = StringOrMarkdown.FromMarkdown("**native**"); + string markdownJson = serializer.Serialize(markdown); + StringOrMarkdown markdownRoundTrip = serializer.Deserialize(markdownJson); + Ensure(markdownRoundTrip.Markdown == "**native**", "StringOrMarkdown round-trip failed."); + + var custom = new SmokePayload { Value = "custom-context" }; + string customJson = serializer.Serialize(custom); + SmokePayload customRoundTrip = serializer.Deserialize(customJson); + Ensure(customRoundTrip.Value == custom.Value, "Custom resolver composition failed."); + + Ensure( + AhpJson.Options.GetTypeInfo(typeof(ActionEnvelope)) is not null, + "Generated AHP metadata resolver did not provide ActionEnvelope metadata."); + + await RunTransportScenarioAsync(serializer); + + Console.WriteLine( + "Native AOT smoke passed: initialize, reconnect, ping, subscribe/action/reducer/unsubscribe, " + + "custom request/notification metadata, and typed/raw/null inbound request results."); + } + + static async Task RunTransportScenarioAsync(SystemTextJsonAhpSerializer serializer) { - Root = new RootState { Agents = new List() }, - }, -}; -var snapshotRoundTrip = serializer.Deserialize(serializer.Serialize(snapshot)); -Require(snapshotRoundTrip.State.Root?.Agents.Count == 0, "Snapshot union round trip failed."); + const string envelopeChannel = "ahp-session:/native-aot"; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + CancellationToken cancellationToken = cts.Token; + var (clientSide, serverSide) = DuplexTransport.CreatePair(); + await using var server = serverSide; + await using var client = AhpClient.Connect(clientSide, serializer: serializer); -var plainText = serializer.Deserialize("\"hello\""); -Require(plainText.AsText() == "hello", "StringOrMarkdown scalar round trip failed."); + Task initializeResponse = RespondToRequestAsync( + serverSide, + serializer, + "initialize", + new InitializeResult + { + ProtocolVersion = ProtocolVersion.Current, + ServerSeq = 4, + Snapshots = new List(), + }, + cancellationToken); + InitializeResult initializeResult = await client.InitializeAsync("native-aot-client", cancellationToken: cancellationToken); + JsonRpcRequest initializeRequest = await initializeResponse; + Ensure(initializeResult.ProtocolVersion == ProtocolVersion.Current, "Initialize result failed."); + Ensure( + initializeRequest.Params?.GetProperty("clientId").GetString() == "native-aot-client", + "Initialize params failed."); -Require( - AhpJson.Options.GetTypeInfo(typeof(ActionEnvelope)) is not null, - "Generated metadata is missing ActionEnvelope."); + Task reconnectResponse = RespondToRequestAsync( + serverSide, + serializer, + "reconnect", + new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, + Actions = new List(), + Missing = new List(), + }), + cancellationToken); + ReconnectResult reconnectResult = await client.ReconnectAsync( + "native-aot-client", + lastSeenServerSeq: 4, + subscriptions: new[] { envelopeChannel }, + cancellationToken); + JsonRpcRequest reconnectRequest = await reconnectResponse; + Ensure(reconnectResult.Value is ReconnectReplayResult, "Reconnect replay result failed."); + Ensure( + reconnectRequest.Params?.GetProperty("lastSeenServerSeq").GetInt64() == 4, + "Reconnect params failed."); -var pingTransport = new PingLoopbackTransport(serializer); -await using (var pingClient = AhpClient.Connect(pingTransport)) -{ - await pingClient.PingAsync(); - await pingClient.ShutdownAsync(); -} -Require(pingTransport.PingReceived, "PingAsync did not send a ping request."); + Task pingResponse = RespondToRequestAsync( + serverSide, + serializer, + "ping", + null, + cancellationToken); + await client.PingAsync(cancellationToken); + await pingResponse; + + var initialState = new SessionState + { + Provider = "smoke", + Title = "Before", + Lifecycle = SessionLifecycle.Ready, + ActiveClients = new List(), + Chats = new List(), + }; + Task subscribeResponse = RespondToRequestAsync( + serverSide, + serializer, + "subscribe", + new SubscribeResult + { + Snapshot = new Snapshot + { + Resource = envelopeChannel, + FromSeq = 4, + State = new SnapshotState { Session = initialState }, + }, + }, + cancellationToken); + (SubscribeResult subscribeResult, Subscription subscription) = await client.SubscribeAsync( + envelopeChannel, + new SubscriptionDeliveryOptions { MaxLatencyMs = 0 }, + cancellationToken); + await subscribeResponse; -Console.WriteLine("Native AOT serialization and client ping smoke test passed."); + await serverSide.SendAsync( + serializer.EncodeMessage(new JsonRpcMessage + { + Notification = new JsonRpcNotification + { + Method = "action", + Params = serializer.SerializeToElement(new ActionEnvelope + { + Channel = envelopeChannel, + ServerSeq = 5, + Action = new StateAction(new SessionTitleChangedAction + { + Type = ActionType.SessionTitleChanged, + Title = "After", + }), + }), + }, + }), + cancellationToken); + SubscriptionEvent subscriptionEvent = await subscription.Events.ReadAsync(cancellationToken); + Ensure(subscriptionEvent is SubscriptionEventAction, "Subscription action delivery failed."); + var actionEvent = (SubscriptionEventAction)subscriptionEvent; + SessionState reducedState = subscribeResult.Snapshot?.State.Session + ?? throw new InvalidOperationException("Subscribe snapshot was missing session state."); + Ensure( + Reducers.ApplyToSession(reducedState, actionEvent.Envelope.Action) == ReduceOutcome.Applied + && reducedState.Title == "After", + "Reducer application failed."); -static void Require(bool condition, string message) -{ - if (!condition) + Task customRequestResponse = RespondToRequestAsync( + serverSide, + serializer, + "smoke/echo", + new SmokePayload { Value = "custom-response" }, + cancellationToken); + SmokePayload? customResult = await client.RequestAsync( + "smoke/echo", + new SmokePayload { Value = "custom-request" }, + cancellationToken); + JsonRpcRequest customRequest = await customRequestResponse; + Ensure(customResult?.Value == "custom-response", "Custom request result failed."); + Ensure( + serializer.Deserialize(customRequest.Params!.Value).Value == "custom-request", + "Custom request params failed."); + + Task customNotificationReceive = ReceiveMessageAsync(serverSide, serializer, cancellationToken); + await client.NotifyAsync( + "smoke/notify", + new SmokePayload { Value = "custom-notification" }, + cancellationToken); + JsonRpcMessage customNotification = await customNotificationReceive; + Ensure( + customNotification.Notification is { Method: "smoke/notify", Params: { } notificationParams } + && serializer.Deserialize(notificationParams).Value == "custom-notification", + "Custom notification failed."); + + Task unsubscribeReceive = ReceiveMessageAsync(serverSide, serializer, cancellationToken); + await client.UnsubscribeAsync(envelopeChannel, cancellationToken); + JsonRpcMessage unsubscribe = await unsubscribeReceive; + Ensure(unsubscribe.Notification is { Method: "unsubscribe" }, "Unsubscribe notification failed."); + + client.SetResourceRequestHandlers(new ResourceRequestHandlers + { + OnResourceRead = parameters => Task.FromResult(new ResourceReadResult + { + Data = parameters.Uri, + Encoding = ContentEncoding.Utf8, + ContentType = "text/plain", + }), + }); + JsonElement resourceResult = await InvokeClientRequestAsync( + serverSide, + serializer, + id: 100, + method: "resourceRead", + parameters: new ResourceReadParams + { + Channel = ProtocolVersion.RootResourceUri, + Uri = "virtual://native-aot/resource", + }, + cancellationToken); + ResourceReadResult resourceRead = serializer.Deserialize(resourceResult); + Ensure(resourceRead.Data == "virtual://native-aot/resource", "Typed inbound resource request failed."); + + client.SetServerRequestHandler((method, _) => + Task.FromResult( + method == "smoke/server" + ? new SmokePayload { Value = "raw-handler-result" } + : null)); + JsonElement rawResult = await InvokeClientRequestAsync( + serverSide, + serializer, + id: 101, + method: "smoke/server", + parameters: new SmokePayload { Value = "raw-handler-request" }, + cancellationToken); + Ensure( + serializer.Deserialize(rawResult).Value == "raw-handler-result", + "Raw inbound request result failed."); + + JsonElement nullResult = await InvokeClientRequestAsync( + serverSide, + serializer, + id: 102, + method: "smoke/null", + parameters: new SmokePayload { Value = "raw-handler-null" }, + cancellationToken); + Ensure(nullResult.ValueKind == JsonValueKind.Null, "Null inbound request result failed."); + } + + static async Task RespondToRequestAsync( + DuplexTransport server, + SystemTextJsonAhpSerializer serializer, + string expectedMethod, + TResult result, + CancellationToken cancellationToken) { - throw new InvalidOperationException(message); + JsonRpcMessage message = await ReceiveMessageAsync(server, serializer, cancellationToken); + JsonRpcRequest request = message.Request + ?? throw new InvalidOperationException($"Expected {expectedMethod} request."); + Ensure(request.Method == expectedMethod, $"Expected {expectedMethod}, received {request.Method}."); + await server.SendAsync( + serializer.EncodeMessage(new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = request.Id, + Result = serializer.SerializeToElement(result), + }, + }), + cancellationToken); + return request; } -} -sealed class PingLoopbackTransport(IAhpSerializer serializer) : ITransport -{ - private readonly Channel _responses = Channel.CreateUnbounded(); + static async Task InvokeClientRequestAsync( + DuplexTransport server, + SystemTextJsonAhpSerializer serializer, + ulong id, + string method, + TParams parameters, + CancellationToken cancellationToken) + { + await server.SendAsync( + serializer.EncodeMessage(new JsonRpcMessage + { + Request = new JsonRpcRequest + { + Id = id, + Method = method, + Params = serializer.SerializeToElement(parameters), + }, + }), + cancellationToken); + JsonRpcMessage response = await ReceiveMessageAsync(server, serializer, cancellationToken); + Ensure(response.SuccessResponse?.Id == id, $"Inbound {method} request did not succeed."); + return response.SuccessResponse!.Result; + } - public bool PingReceived { get; private set; } + static async Task ReceiveMessageAsync( + DuplexTransport transport, + SystemTextJsonAhpSerializer serializer, + CancellationToken cancellationToken) => + serializer.DecodeMessage(await transport.ReceiveAsync(cancellationToken)); - public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + static void Ensure(bool condition, string message) { - var request = serializer.DecodeMessage(message).Request - ?? throw new InvalidOperationException("Expected a JSON-RPC request."); - if (request.Method != "ping") + if (!condition) { - throw new InvalidOperationException($"Expected ping, received {request.Method}."); + throw new InvalidOperationException(message); } + } +} - PingReceived = true; - using var nullDocument = System.Text.Json.JsonDocument.Parse("null"); - var response = new JsonRpcMessage - { - SuccessResponse = new JsonRpcSuccessResponse - { - Id = request.Id, - Result = nullDocument.RootElement.Clone(), - }, - }; - return _responses.Writer.WriteAsync(serializer.EncodeMessage(response), cancellationToken); +internal sealed class DuplexTransport : ITransport +{ + private readonly ChannelReader _incoming; + private readonly ChannelWriter _outgoing; + + private DuplexTransport( + ChannelReader incoming, + ChannelWriter outgoing) + { + _incoming = incoming; + _outgoing = outgoing; } + public static (DuplexTransport First, DuplexTransport Second) CreatePair() + { + Channel firstToSecond = Channel.CreateUnbounded(); + Channel secondToFirst = Channel.CreateUnbounded(); + return ( + new DuplexTransport(secondToFirst.Reader, firstToSecond.Writer), + new DuplexTransport(firstToSecond.Reader, secondToFirst.Writer)); + } + + public ValueTask SendAsync( + TransportMessage message, + CancellationToken cancellationToken = default) => + _outgoing.WriteAsync(message, cancellationToken); + public ValueTask ReceiveAsync(CancellationToken cancellationToken = default) => - _responses.Reader.ReadAsync(cancellationToken); + _incoming.ReadAsync(cancellationToken); public ValueTask CloseAsync(CancellationToken cancellationToken = default) { - _responses.Writer.TryComplete(); + _outgoing.TryComplete(); return default; } public ValueTask DisposeAsync() { - _responses.Writer.TryComplete(); + _outgoing.TryComplete(); return default; } } + +internal sealed record SmokePayload +{ + public required string Value { get; init; } +} + +[JsonSerializable(typeof(SmokePayload))] +internal sealed partial class SmokeJsonContext : JsonSerializerContext; diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs index 12dfb7c5..6e6bf70b 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs @@ -71,6 +71,26 @@ public void SystemTextJsonAhpSerializer_SnapshotsCallerOptions() serializer.Serialize(new Implementation { Name = "test", Version = "1.0" })); } + [Fact] + public void SystemTextJsonAhpSerializer_EnforcesProtocolWireSettings() + { + var serializer = new SystemTextJsonAhpSerializer(new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }); + + string json = serializer.Serialize(new InitializeParams + { + Channel = ProtocolVersion.RootResourceUri, + ProtocolVersions = new List { ProtocolVersion.Current }, + ClientId = "wire-settings", + }); + + Assert.Contains("\"clientId\":\"wire-settings\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"client_id\"", json, StringComparison.Ordinal); + } + [Fact] public void StringOrMarkdown_FactoriesRejectNull() { diff --git a/docs/.changes/20260824-dotnet-native-aot.json b/docs/.changes/20260824-dotnet-native-aot.json new file mode 100644 index 00000000..0fc8e949 --- /dev/null +++ b/docs/.changes/20260824-dotnet-native-aot.json @@ -0,0 +1,5 @@ +{ + "type": "fixed", + "message": "The .NET client now preserves protocol wire settings with custom JSON metadata, serializes inbound request handler results without reflection, and validates packed packages through broader Native AOT flows.", + "targets": ["dotnet"] +}