Skip to content

fix(dotnet): preserve unknown values of open protocol enums - #462

Merged
Connor Peet (connor4312) merged 9 commits into
microsoft:mainfrom
sukanth:fix-dotnet-open-enum-forward-compat
Oct 1, 2026
Merged

Connor Peet (connor4312) merged 9 commits into
microsoft:mainfrom
sukanth:fix-dotnet-open-enum-forward-compat

Conversation

@sukanth

@sukanth Sukanth Gunda (sukanth) commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Teaches the C# generator the @exhaustive / @nonexhaustive enum contract that already exists in types/, so the .NET client stops failing to decode enum values added by newer peers. Also adds ConfigPropertySchema.minItems / maxItems.

Closes #366
Closes #432

Why

types/ requires every protocol enum to declare @exhaustive or @nonexhaustive (scripts/enum-compatibility.ts throws otherwise), and versioning.md guarantees that adding a value to an open enum is an additive, PATCH-compatible change that an older peer must relay intact.

Support for that contract turned out to be uneven:

Client Honors @nonexhaustive? How
Rust yes Unknown(String) variant
Swift yes case unknown(String)
Kotlin yes value class … (val rawValue: String)
Go tolerant by construction type X string + consts
TypeScript tolerant by construction string unions, no runtime decoder
.NET no generate-csharp.ts never consulted it

Every C# string enum was emitted as a closed enum behind WireEnumConverter<T>, whose Read throws JsonException on an unrecognized value. ToolCallStatus and CustomizationType are @nonexhaustive in types/, so a host adding a value to either in a PATCH release causes a whole-message decode failure in the .NET client — exactly the 1.0.0 blocker #366 describes, still live for this one client.

It went unnoticed because the corpus never covered this case. Fixture 003 covers an unknown union variant (allowUnknown → raw JsonElement) and fixture 024 covers an unknown value of an open string field — both mechanisms .NET already handled. Nothing covered an unknown value of an open enum on a directly-typed field, which is the case a closed language enum cannot represent.

How

  • Open enums now generate as a readonly struct wrapping the raw wire string, with the known values as static members and a generated per-type converter. This mirrors Kotlin's value class and Rust's Unknown(String). Because the generated converters replace the reflective WireEnumConverter<T> on this path, the open-enum surface is also trimming/AOT-friendly (a step toward .NET client: support trimming and Native AOT #410).
  • Closed enums keep the existing enum + [WireValue] + WireEnumConverter<T> mapping, which correctly rejects a value the contract says is invalid.
  • Discriminated unions derive allowUnknown from the discriminator enum's annotation via the shared discriminatedUnionAllowsUnknown helper, instead of a hand-maintained per-union flag. The other five generators already did this; C# was the only one keeping the flag by hand, and it had drifted in both directions (see below).
  • GeneratedActionMetadata.GetWireName collapses to ActionType.Value, so it stays correct for an action type this build doesn't know (it previously threw ArgumentOutOfRangeException).
  • ConfigPropertySchema.minItems / maxItems — purely additive, matching the JSON Schema fields the type already carries (items, required, additionalProperties, readOnly).

No hand-written .NET source needed to change. The only constant-pattern usages of an affected enum were 98 lines in one generated file; the one protocol enum used in a hand-written switch (PendingMessageKind) is @exhaustive and is untouched.

Review follow-up (f133ae1)

Copilot's reviewer caught a real gap: SessionOriginKind and CustomizationEnablementKind are @nonexhaustive and union discriminators, so their UnionConverters rejected an added wire value before the new struct converters ever ran. Deriving allowUnknown from the annotation fixes that, and also corrects the inverse drift the hand-maintained flags had accumulated: TerminalClaim, ChatInputAnswer, and CustomizationLoadState were wrongly permissive, accepting discriminators their @exhaustive contracts call invalid. .NET now matches Rust exactly on every union. ChangesetOperationTarget is derived now too.

Also from review: minItems / maxItems are tagged @integer + @minimum 0, so the schemas constrain them the way every client already models them.

Tests / conformance

Three new round-trip fixtures, each verified as a real regression guard by reverting the relevant generator change and confirming it fails:

  • 045-message-origin-unknown-kind-preserved.json — an open enum on a plain (non-discriminant) field.
  • 046-config-schema-array-cardinality.json — the new minItems / maxItems through SessionModelInfo.configSchema. Uses minItems: 0, the value most likely to be silently dropped as falsy.
  • 047-session-origin-unknown-kind-preserved.json — an unknown discriminator on an open union, which fixture 045 does not reach.
Suite Result
Root npm test 450 pass, 100% reducer branch coverage, all verify gates incl. generated-freshness
.NET 544 pass (was 543)
Rust 88 pass
Kotlin BUILD SUCCESSFUL, incl. fixture 045
Go pass
TypeScript 72 pass
Swift swift build clean — see caveat

Caveat: Swift's XCTest suite could not run in my environment (XCTest ships with full Xcode; only CommandLineTools was usable). The Swift library compiles cleanly, and its only change is the two additive generated fields that Go/Kotlin/TypeScript verified green — worth a CI confirmation.

Compatibility

No wire-format change — the JSON is identical in both directions. This is a source-breaking change for .NET consumers who switch over an affected enum with constant patterns, which is precisely why it belongs before the 1.0 guarantee takes effect (the .NET client is on 0.9.0). PROTOCOL_VERSION is unchanged.

Happy to split the minItems/maxItems addition into its own PR if you'd prefer to review the two independently.

Out of scope

`types/` requires every protocol enum to declare `@exhaustive` or
`@nonexhaustive`, and `versioning.md` guarantees that a value added to an
open enum is a PATCH-compatible, additive change that an older peer must
relay intact. The Rust, Swift and Kotlin generators honor that contract, and
the Go and TypeScript mirrors are tolerant by construction — but
`generate-csharp.ts` never consulted it. Every string enum became a closed
C# `enum` behind `WireEnumConverter<T>`, which throws on an unrecognized
wire value, so a host adding (for example) a `ToolCallStatus` value in a
PATCH release caused a whole-message decode failure in the .NET client.

Open enums are now generated as a `readonly struct` wrapping the raw wire
string, with the known values as static members and a generated per-type
converter — mirroring Kotlin's `value class` and Rust's `Unknown(String)`.
Closed enums keep the existing `enum` + `WireEnumConverter<T>` mapping,
which correctly rejects a value the contract says is invalid. Because the
generated converters replace the reflective one on this path, the open-enum
surface is also trimming- and AOT-friendly.

`GeneratedActionMetadata.GetWireName` collapses to `ActionType.Value`, so it
stays correct for an action type this build does not know.

Also adds `ConfigPropertySchema.minItems` / `maxItems` so an array-typed
config property can express its cardinality, matching the JSON Schema fields
the type already carries (`items`, `required`, `additionalProperties`).

Round-trip fixture 045 locks the behavior: `MessageOrigin.kind` is an open
enum on a directly-typed field — the one case neither the unknown-union
fixture (003) nor the open-string-field fixture (024) covered, and the case
a closed language enum cannot represent. Verified to fail before this change
and pass after.

Closes microsoft#366
Closes microsoft#432

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Open-enum unions still reject unknown discriminants, and cardinality fields lack required schema constraints.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds .NET forward compatibility for @nonexhaustive enums and configuration array cardinality metadata.

Changes:

  • Generates raw-string C# structs and converters for open enums.
  • Adds minItems and maxItems across schemas and clients.
  • Adds unknown-enum conformance coverage and documentation.
File Review
types/​test-cases/​round-trips/​KNOWN-FIDELITY-GAPS.md Documents enum fidelity coverage.
types/​test-cases/​round-trips/​045-message-origin-unknown-kind-preserved.json Tests unknown enum preservation.
types/​common/​state.ts Adds cardinality fields. Moderate: constrain both to non-negative integers. Nit: add shared round-trip coverage.
scripts/​generate-csharp.ts Generates open enums. Moderate: unknown union discriminants remain unsupported for SessionOriginKind, CustomizationEnablementKind, ChangesetOperationTargetKind, and ChatSourceKind.
schema/​state.schema.json Adds cardinality schema fields.
schema/​notifications.schema.json Regenerates shared schema definitions.
schema/​errors.schema.json Regenerates shared schema definitions.
schema/​commands.schema.json Regenerates shared schema definitions.
schema/​actions.schema.json Regenerates shared schema definitions.
docs/​guide/​state-model.md Documents cardinality fields.
docs/​.changes/​20260923-dotnet-open-enum-forward-compat.json Records the .NET fix. Nit: align its compatibility claim with versioning.md.
docs/​.changes/​20260923-config-array-cardinality.json Records the cardinality addition.
clients/​swift/​AgentHostProtocol/​Sources/​AgentHostProtocol/​Generated/​State.generated.swift Adds Swift cardinality fields.
clients/​swift/​AgentHostProtocol/​Sources/​AgentHostProtocol/​Generated/​Commands.generated.swift Updates generated Swift models.
clients/​rust/​crates/​ahp-types/​src/​state.rs Adds Rust cardinality fields.
clients/​kotlin/​src/​main/​kotlin/​com/​microsoft/​agenthostprotocol/​generated/​State.generated.kt Adds Kotlin cardinality fields.
clients/​kotlin/​src/​main/​kotlin/​com/​microsoft/​agenthostprotocol/​generated/​Commands.generated.kt Updates generated Kotlin models.
clients/​go/​ahptypes/​state.generated.go Adds Go cardinality fields.
clients/​dotnet/​src/​AgentHostProtocol/​Generated/​ActionMetadata.generated.cs Uses raw action wire values.
clients/​dotnet/​src/​AgentHostProtocol.Abstractions/​Generated/​State.generated.cs Adds open enums and cardinality fields.
clients/​dotnet/​src/​AgentHostProtocol.Abstractions/​Generated/​Notifications.generated.cs Generates an open notification enum.
clients/​dotnet/​src/​AgentHostProtocol.Abstractions/​Generated/​Commands.generated.cs Generates open command enums.
clients/​dotnet/​src/​AgentHostProtocol.Abstractions/​Generated/​Actions.generated.cs Generates open action types.
clients/​dotnet/​AGENTS.md Documents .NET enum generation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/generate-csharp.ts
Comment thread types/common/state.ts Outdated
Comment thread types/common/state.ts
Addresses review feedback on microsoft#462.

The open-enum fix covered enums used as plain field types, but two open
enums are also union discriminators: `SessionOriginKind` and
`CustomizationEnablementKind`. Their generated `UnionConverter`s hardcoded
`allowUnknown: false`, so an added wire value was rejected by the union
decoder before the new struct converters ever ran — the whole-message
decode failure the original fix set out to remove.

The other five generators already derive this from the discriminator enum's
`@exhaustive` / `@nonexhaustive` annotation via the shared
`discriminatedUnionAllowsUnknown` helper; only C# maintained the flag by
hand. The C# generator now calls the same helper, so union tolerance stays
in lockstep with the protocol declaration instead of drifting.

This also corrects the inverse drift the hand-maintained flags had
accumulated: `TerminalClaim`, `ChatInputAnswer`, and `CustomizationLoadState`
were wrongly permissive, accepting discriminators their `@exhaustive`
contracts say are invalid. .NET now matches Rust exactly on every union.

`ChangesetOperationTarget` (also `@nonexhaustive`) is derived now too, and
the `StateAction` and `CustomizationEnablement` unions name their
discriminator enum explicitly because their variants are synthesized or
inline and carry no resolvable discriminator property.

Marks `minItems` / `maxItems` as `@integer` with `@minimum 0`, so the
generated schemas constrain them the way every client already models them.

Fixtures: 046 covers the new config cardinality fields end to end, and 047
covers an unknown discriminator on an open union — verified to fail before
this change and pass after.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The critical zero-value regression and moderate union-generation drift must be resolved before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (3)

Comment thread scripts/generate-csharp.ts
Comment thread scripts/generate-csharp.ts
Addresses review feedback on microsoft#462.

Generated records left their literal discriminator without an initializer
and relied on the property's zero value. That was already wrong before this
PR: a closed C# enum's zero value is its *first* member, so `new
SessionReadyAction()` silently serialized `"root/agentsChanged"` — the
correct discriminator on exactly one of the 117 action records and wrong on
the other 116. Moving `ActionType` to an open-enum struct changed the
symptom to an empty wire string but not the underlying defect.

Rather than restore the previous zero-value semantics, this pins each
record's discriminator to its own literal, which is what the property's type
already says it must be. `new SessionReadyAction()` now serializes
`"session/ready"`. The generator derives the initializer from the
discriminant it already resolves, and the hand-written session-action
records — whose wire types are aliases with no `ActionType` member — pin
theirs through the open enum's raw constructor.

`default(ActionType)` on its own still yields an empty value. That is
deliberate: it is an explicit programmer error rather than a decoded
payload, and an obviously-invalid discriminator is safer than one that is
silently valid but wrong.

Also derives the hand-written `ChatOrigin` union's `allowUnknown` from
`ChatOriginKind` instead of hardcoding it, so no union in the generator
maintains that flag by hand any more.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Required open-enum defaults and Kotlin constructor compatibility have unresolved moderate issues.

Review effort: Balanced
Findings: None

Resolved since last review (2)

Upstream landed microsoft#450 (typed file-edit models), which added round-trip
fixtures 045-049 — colliding with the 045/046/047 numbers this branch had
already taken. Git merges both sets without a textual conflict, so the
collision surfaces as duplicate numeric prefixes rather than a failure:
every fixture still runs and passes, but two different files share each of
three numbers, and the corpus docs referencing "fixture 045" become
ambiguous.

Renumbers this branch's three fixtures to 050-052, above upstream's
highest, and updates the two places that referenced them by number
(KNOWN-FIDELITY-GAPS.md and fixture 052's own cross-reference).

microsoft#450 also edited `scripts/generate-csharp.ts`, the same file this branch
changes. The merge is clean and `npm run generate` produces no drift on the
merged tree, so the two sets of generator changes compose.

Verified on the merged tree: root suite 451 pass, .NET 0 failed, plus Rust,
Go, TypeScript, Kotlin, and Swift green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Upstream landed microsoft#471 (blocking MCP server startup), which adds a new
`ActionType` member and touches `scripts/generate-csharp.ts` — the same
generator this branch rewrites. The canonical sources (`types/`, `scripts/`)
merged cleanly; the only conflicts were in three generated .NET mirrors:

  Actions.generated.cs, State.generated.cs, ActionMetadata.generated.cs

Generated files are never hand-edited, so the resolution is to regenerate
them from the merged canonical sources rather than reconcile the markers by
hand. `npm run generate` produces output containing both sides: upstream's
new `session/mcpServerBackgroundRequested` action and this branch's
open-enum representation.

Worth noting the two changes compose correctly — the new action record picks
up this branch's pinned discriminator automatically
(`= ActionType.SessionMcpServerBackgroundRequested`), so it does not
reintroduce the zero-value defect on a record added after that fix.

Verified on the merged tree: root suite 454 pass (up from 451 with
upstream's three new reducer fixtures), .NET 0 failed, plus Rust, Go,
TypeScript, Kotlin, and Swift green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Upstream landed microsoft#461 (scheduled automation run limits), which again touches
`scripts/generate-csharp.ts` and adds round-trip fixtures numbered 045-050.
The merge itself is clean, and regeneration shows the two generator changes
still compose: upstream's new `AutomationAfterRunsCondition` /
`AutomationAfterDateCondition` records pick up this branch's pinned
discriminator automatically, so types added after that fix do not
reintroduce the zero-value defect.

Last tick this branch renumbered its fixtures to dodge a collision with
microsoft#450. That turns out to be a treadmill worth stepping off: `upstream/main`
already carries nine duplicate numeric prefixes (019, 030, 031, 041, and
045-049), several created by microsoft#461 and microsoft#450 colliding with each other. Prefixes
are assigned per-branch, so any parallel PR can take a number, and chasing
that costs a rename plus a full CI run every time.

Rather than renumber again, this makes the references stable: the corpus doc
and the fixture cross-references now name fixtures by filename instead of
"fixture NNN", with a note recording why. The fixture files themselves are
left alone, matching how the repository already tolerates shared prefixes.

Verified on the merged tree: root suite 471 pass, .NET 0 failed, plus Rust,
Go, TypeScript, Kotlin, and Swift green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Upstream landed microsoft#483 (per-chat change summaries), which touches
`types/channels-chat/state.ts`, `types/channels-session/state.ts`, the
schemas, and every client's generated mirror — including the two .NET
mirrors this branch also modifies. The merge is clean and regeneration
produces no drift, so the new chat-summary fields and this branch's
open-enum work compose without interference.

Kept rather than deferred: the overlap is in
`Actions.generated.cs` / `State.generated.cs`, so leaving it unmerged would
have surfaced as a regenerate-to-resolve conflict on a later tick.

Also picks up microsoft#477, a Dependabot lockfile bump under `plugins/`, which is
outside the protocol surface.

Verified on the merged tree: root suite 471 pass with the generated-output
freshness check green, .NET 0 failed, plus Rust, Go, TypeScript, Kotlin, and
Swift. Every action record still pins its own discriminator (0 unpinned).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Upstream landed microsoft#484 (durable chat move and reorder contracts), which adds a
`ChatMoveDestination` union and edits `scripts/generate-csharp.ts` — the same
generator this branch rewrites. Unlike the previous merges, this one
conflicted in hand-written source rather than only in generated output.

The conflict was a clean semantic overlap on adjacent lines: this branch
threads `project` into `generateDiscriminatedUnion` so union tolerance is
derived from the discriminator annotation, while upstream appends a third
union to the same call block. Taking either side alone would have dropped
the other, so the resolution keeps both — all three unions now generate
through the project-aware signature.

The two conflicted .NET mirrors are regenerated from the merged sources
rather than hand-resolved. The result confirms the changes compose:
`ChatMoveDestinationConverter` derives `allowUnknown: true` from
`ChatMoveDestinationKind`'s `@nonexhaustive` annotation via the shared
helper, instead of the hardcoded flag it would have carried.

Verified on the merged tree: root suite 499 pass with generated-output
freshness green, .NET 0 failed (including upstream's new
ChatMoveDestinationTests), plus Rust, Go, TypeScript, Kotlin, and Swift.
Every action record still pins its own discriminator (0 unpinned).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@connor4312
Connor Peet (connor4312) merged commit b6e76b9 into microsoft:main Oct 1, 2026
9 checks passed
@sukanth
Sukanth Gunda (sukanth) deleted the fix-dotnet-open-enum-forward-compat branch October 1, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support minItems and maxItems in config array schemas 1.0.0 blocker: unknown enum values fail to decode, contradicting the additive-change guarantee

4 participants