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
28 changes: 18 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
15 changes: 12 additions & 3 deletions clients/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,16 +143,25 @@ 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();
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

Expand Down
60 changes: 49 additions & 11 deletions clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -55,14 +56,17 @@ internal static JsonSerializerOptions CreateOptions(JsonSerializerOptions? sourc
/// </summary>
public sealed class SystemTextJsonAhpSerializer : IAhpSerializer
{
private static readonly JsonElement s_jsonNull = CreateJsonNull();
private readonly JsonSerializerOptions _options;

/// <summary>Creates the serializer.</summary>
/// <param name="options">
/// Override options; defaults to <see cref="AhpJson.Options"/>. Custom options
/// Custom options; defaults to <see cref="AhpJson.Options"/>. 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 <see cref="System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver"/>
/// flight. The AHP camel-case naming and null-handling settings are always
/// enforced. Add a custom
/// <see cref="System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver"/>
/// to serialize non-AHP types when reflection is disabled.
/// </param>
public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null)
Expand All @@ -80,11 +84,33 @@ public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null)
public static SystemTextJsonAhpSerializer Default { get; } = new();

/// <inheritdoc />
public string Serialize<T>(T value) => JsonSerializer.Serialize(value, GetTypeInfo<T>());
public string Serialize<T>(T value)
{
if (typeof(T) != typeof(object))
{
return JsonSerializer.Serialize(value, GetTypeInfo<T>());
}

return value is null
? "null"
: JsonSerializer.Serialize(value, GetTypeInfo(value.GetType()));
}

/// <inheritdoc />
public JsonElement SerializeToElement<T>(T value) =>
JsonSerializer.SerializeToElement(value, GetTypeInfo<T>());
public JsonElement SerializeToElement<T>(T value)
{
if (typeof(T) != typeof(object))
{
return JsonSerializer.SerializeToElement(value, GetTypeInfo<T>());
}

if (value is not null)
{
return JsonSerializer.SerializeToElement(value, GetTypeInfo(value.GetType()));
}

return s_jsonNull;
}

/// <inheritdoc />
public T Deserialize<T>(string json) =>
Expand Down Expand Up @@ -112,8 +138,20 @@ public TransportMessage EncodeMessage(JsonRpcMessage message) =>
TransportMessage.FromText(Serialize(message));

private JsonTypeInfo<T> GetTypeInfo<T>() =>
_options.GetTypeInfo(typeof(T)) as JsonTypeInfo<T>
GetTypeInfo(typeof(T)) as JsonTypeInfo<T>
?? 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@
<IsPackable>false</IsPackable>
<IsPublishable>true</IsPublishable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<UsePackedPackages Condition="'$(UsePackedPackages)' == ''">false</UsePackedPackages>
</PropertyGroup>

<ItemGroup>
<ItemGroup Condition="'$(UsePackedPackages)' != 'true'">
<ProjectReference Include="../../src/AgentHostProtocol/AgentHostProtocol.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(UsePackedPackages)' == 'true'">
<PackageReference Include="Microsoft.AgentHostProtocol" Version="$(Version)" />
</ItemGroup>

</Project>
Loading