C# SDK: re-land x-opaque-json → JsonElement mapping with object boundary at RPC params#1359
Draft
SteveSandersonMS wants to merge 13 commits into
Draft
C# SDK: re-land x-opaque-json → JsonElement mapping with object boundary at RPC params#1359SteveSandersonMS wants to merge 13 commits into
SteveSandersonMS wants to merge 13 commits into
Conversation
stephentoub
reviewed
May 21, 2026
| { | ||
| null => null, | ||
| JsonElement je => je, | ||
| _ => JsonSerializer.SerializeToElement(value, value.GetType(), SerializerOptionsForMessageFormatter) |
Collaborator
There was a problem hiding this comment.
You can avoid the need for the suppress messages by passing in a JsonTypeInfo instead of Type, e.g. value.GetType() => SerializerOptionsForMessageFormatter.GetTypeInfo(value.GetType())
C# codegen now hard-errors instead of falling back to `object` for unmappable schema nodes. Schemas tagged with `x-opaque-json: true` map to `JsonElement`/`JsonElement?`. Hand-written types that round-trip arbitrary JSON (ToolInvocation.Arguments, ToolResultObject.ToolTelemetry, hook *Input* ToolArgs/ToolResult) are retyped to JsonElement to match the generated wire types. Boundary code in Session/Client/SessionFsProvider converts between the hand-written user-facing object?-typed dictionaries (ElicitationSchema.Properties, ElicitationResult.Content) and the wire JsonElement form. This is the SDK side of github/copilot-agent-runtime#8375 — replays the non-Generated/* changes from the reverted PR #1343. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous change forced consumers to pass JsonElement for RPC method parameters whose schema is opaque-JSON (e.g. Rpc.Tools.HandlePendingToolCallAsync result, McpConfigAddRequest config). This was a UX regression vs. accepting 'object?' as before, since most callers naturally have a typed CLR value (string, dictionary, generated DTO) and would otherwise need to manually SerializeToElement before each call. Boundary special-case in csharp.ts: when the natural C# type for a top-level RPC method parameter is JsonElement/JsonElement?, emit 'object'/'object?' at the public surface and convert in the generated body via a new helper CopilotClient.ToJsonElementForWire(object?). The helper short-circuits when the value is already a JsonElement and otherwise serializes the runtime type using the chained source-generated JsonSerializerContexts that the SDK configures for JSON-RPC message formatting (matching pre-revert behaviour). DTO field types are unaffected — opaque-JSON properties on any generated class continue to be JsonElement(?). The asymmetry is intentional: inbound opaque-JSON (events, hook inputs, tool args) stays as JsonElement so consumers can introspect directly; outbound opaque-JSON (consumer-constructed) accepts 'object' for ergonomics. ToolResultObject.ToolTelemetry reverted to IDictionary<string, object>? for the same reason — it is a consumer-constructed outbound type. Tests constructing ToolResultObject updated accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Properties annotated with .asInternal() in the runtime Zod schema now emit as `internal` with [JsonInclude] (required for STJ source-gen to (de)serialize non-public members). Drop the `required` modifier when a property is internal: CS9032 forbids `internal required` members in a public class. The runtime keeps the field's semantic intent (must be present on the wire) but the SDK side no longer requires external callers to initialize a member they cannot see. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirror the property-level internal handling for experimental: emit [Experimental(Diagnostics.Experimental)] on properties whose runtime Zod schema is marked .asExperimental(), and drop the `required` modifier in that case to avoid CS9042 (required + obsolete-family attribute on a non-experimental containing class). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The runtime schema generator now rejects required properties marked internal/experimental whose containing type isn't, so the C# codegen no longer needs to silently drop \ equired\ to avoid CS9032/CS9042. The property-level marker support stays - it's still useful for optional properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 3 callsites converting Dictionary<string, object> to Dictionary<string, JsonElement> (SessionFsProvider rows, MCP elicitation response Content, elicitation schema Properties) were using TypesJsonContext.Default.Object, which restricts type resolution to the Types source-gen context. This is too narrow — at this boundary the runtime type can be any primitive, dictionary, or generated DTO from any of the chained contexts (Client, Types, Session, SessionEvents, Rpc). Route all 3 through CopilotClient.ToJsonElementForWire, which uses the same chained serializer options that the JSON-RPC message formatter uses, matching pre-opaque-JSON-PR serialization behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…experimental Mirrors the existing C# codegen support for property-level markers: - isSchemaInternal -> emit #[doc(hidden)] above the field - isSchemaExperimental -> emit the same /// **Experimental.** ... doc block used at the type level, indented to the field Imports isSchemaInternal from ./utils.js (previously only the type-level isSchemaExperimental was used). Regenerated rust/src/generated/* against the current runtime schemas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…bility=experimental Adds a shared schema walker in utils.ts that appends @internal / @experimental tags to the description of any property carrying visibility:internal or stability:experimental inline. The typescript codegen calls this on each schemaForCompile before json-schema-to-typescript compiles it, so the tags appear naturally inside the generated JSDoc block for that property (no regex post-processing required). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…y=experimental Mirrors the existing type-level support: where a property's inline schema carries visibility:internal or stability:experimental, the dataclass field gets the corresponding `# Internal:` / `# Experimental:` comment line. Adds a shared pushPyFieldMarkers helper so the comment block isn't duplicated between the ordinary dataclass emitter and the flat discriminated-union emitter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mirrors the existing C#/Rust/TypeScript/Python prop-marker treatment: when a field carries x-internal or x-experimental in the JSON Schema, emit a `// Internal: ...` / `// Experimental: ...` doc comment above the Go struct field. Factor the four near-identical field-emission sites in go.ts through a new `pushGoFieldMarkers` helper that consolidates Deprecated/Experimental/ Internal `pushGo*` calls in one place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Commit b484198 introduced 590 UTF-8 double-encoded byte sequences in scripts/codegen/csharp.ts (box-drawing characters in section banners and em-dashes in comments). This commit restores the file to a clean UTF-8 encoding and reapplies the intended logic change (removing the unnecessary !propInternal && !propExperimental guards from the reqMod expressions in the four sites that had it). While here, factor the duplicated propInternal + [JsonInclude] + visibility pattern into a small pushCSharpInternalAttribute helper used by all six property-emission sites in this file, and delete the dead propExperimental declarations that were left over from the original revert hack. Generated output (SessionEvents.cs, Rpc.cs) is byte-identical to before this commit; the change is purely TypeScript-side cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Refreshes generated output (C# Rpc.cs, TS rpc.ts, Python rpc.py, Go zrpc.go/zsession_events.go, Rust *.rs) to pick up two changes that landed on main while the branch was open: - namespace renamed from GitHub.Copilot.SDK.* to GitHub.Copilot.* - improved description wording for AccountQuotaSnapshot.overage fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
78d5ff2 to
12d3837
Compare
This comment has been minimized.
This comment has been minimized.
Per @stephentoub: passing JsonTypeInfo (looked up via the configured resolver chain) instead of Type avoids the need for IL2026/IL3050 suppressions because the typed overload is statically analyzable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
Cross-SDK Consistency Review ✅This PR maintains strong cross-SDK consistency. Here's a summary of what was verified: Changes and their cross-SDK status
Notes
No consistency gaps found.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
1. Codegen:
x-opaque-json→JsonElement(scripts/codegen/csharp.ts)The runtime schema generator now stamps
x-opaque-json: trueon every field/type that legitimately holds free-form JSON (tool args, hook payloads, telemetry, form schemas, etc.) and rejects everything else at build time. The codegen consumes that marker:x-opaque-json: true→JsonElement/JsonElement?(wasobject?).objectfallbacks removed from the codegen; reaching an unmappable shape is now a hard error.ToolInvocation.Arguments, hook in/out, related serialization plumbing inSession.cs,SessionFsProvider.cs,Client.cs) switched toJsonElementon the wire. NewTestJsonContextfor tests.2. RPC-boundary special-case (the bit that made #1343 painful)
The reverted PR forced consumers to pass
JsonElementfor outbound RPC method parameters (e.g.Rpc.Tools.HandlePendingToolCallAsync(... JsonElement result, ...)). That's worse UX than the oldobject?surface: callers naturally hold a typed CLR value (string, dictionary, generated DTO) and don't want to serialize before each call.Fixed by introducing an asymmetric boundary:
Inbound opaque-JSON (events, hook inputs, tool args, generated event DTOs, DTO fields) stays
JsonElement(?)— consumers can introspect the wire payload directly.Outbound opaque-JSON at top-level RPC method parameters now accepts
object/object?. The codegen generates a call to a new helper:which short-circuits if
valueis already aJsonElement, otherwise serializes the runtime type usingSerializerOptionsForMessageFormatter(the chained source-genJsonSerializerContextsthe SDK uses for all JSON-RPC traffic). This matches pre-revert serialization exactly.DTO field types stay
JsonElement(?)— theobjectonly appears at the public RPC surface. A generated call site looks like:ToolResultObject.ToolTelemetryreverted toIDictionary<string, object>?for the same reason (it's a consumer-constructed outbound type). Tests updated.Validation
dotnet buildclean across net472 / net8.0 / net10.0.ConnectionTokenTestsrely on the test-harness proxy subprocess and aren't relevant to this change).Dependencies
This PR requires runtime changes to be merged first (or for the runtime's regenerated
generated/*.schema.jsonto ship a copy). The branch already contains regenerated artifacts from the runtime PR's schema; once the runtime merges and re-emits, no codegen changes will be needed here.