Skip to content

Add a first-party .NET client (proposal)#206

Draft
joshmouch wants to merge 11 commits into
microsoft:mainfrom
joshmouch:pr/dotnet-client
Draft

Add a first-party .NET client (proposal)#206
joshmouch wants to merge 11 commits into
microsoft:mainfrom
joshmouch:pr/dotnet-client

Conversation

@joshmouch

@joshmouch joshmouch commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

A complete .NET / NuGet client for AHP 0.3.0, at full cross-language parity with the Rust / Kotlin / Swift / TypeScript / Go clients — including the annotations channel (#195).

Opening as a draft: a 6th first-party client is a maintenance + ownership commitment only you can sign off on, so I'd love a scope decision before you spend review time. I'm happy to keep it updated as the protocol evolves.

What's here

  • Microsoft.AgentHostProtocol.Abstractions (generated wire types + transport/serializer interfaces), Microsoft.AgentHostProtocol (reducers + AhpClient + MultiHostClient), Microsoft.AgentHostProtocol.WebSockets.
  • Codegen-derived from the TS protocol via a new scripts/generate-csharp.ts (wired into generate.ts + the CI generated-source-freshness check) — a protocol change is one regen, not N hand-edits.
  • 312 tests on net8.0 + net9.0, driving the shared cross-language reducer + round-trip fixtures (same vectors as the other clients), incl. the full annotations channel.

Built like GitHub's own generated .NET client

GitHub independently ships a generated .NET client for its agent protocol (github/copilot-sdk, dotnet/), built — like this one — by a bespoke TypeScript codegen (scripts/codegen/csharp.ts, not Kiota) emitting System.Text.Json types from a schema. The two independently converged on the same architecture: TS-codegen → a single generated .cs, STJ, required, file-scoped namespaces, JsonIgnore(WhenWritingNull). That convergence is some outside validation of the shape. Where this client deliberately differs: AHP requires unknown union discriminants to round-trip verbatim, so it hand-factors a union converter rather than [JsonPolymorphic] (which drops unknown variants); and it favors a JsonNamingPolicy + records for concision.

Notes for review

  • Publishing is intentionally left to you — I removed the GitHub-Actions NuGet-push workflow, since you publish TS/Kotlin via your own ADO/ESRP pipeline; you'd wire Microsoft.AgentHostProtocol.* publishing your way if you adopt this.
  • Shared fixtures: this branch bundles the round-trip fixtures so it builds standalone; they're byte-identical to Add a shared round-trip corpus + fix the wire-fidelity bugs it caught (SessionStatus bitset, changeset range, origin) #204, so they deduplicate on merge and the two land cleanly in either order. This PR's unique change is clients/dotnet/ + the codegen / CI / release wiring.
  • No per-client gate infra: the earlier .NET-only test-parity gate is gone — the suite is data-driven off the shared fixtures, same as the other clients.

Your call: do you want a first-party .NET client? If yes — who owns/maintains it, and should it publish via your ESRP pipeline under Microsoft.AgentHostProtocol.*?

@joshmouch joshmouch force-pushed the pr/dotnet-client branch 6 times, most recently from b72eeed to ca6e1a3 Compare June 10, 2026 14:07
joshmouch added 11 commits June 10, 2026 17:09
…diting the generated file

Add annotations/updated to ACTION_VARIANTS in scripts/generate-csharp.ts
so the generator emits the AnnotationsUpdatedAction record and its
StateActionConverter map entry. The previous commit hand-edited the
generated file directly, making it non-reproducible. Running
npm run generate:dotnet now produces the same output, closing the
CI "generated sources up to date" gate.

Also corrects three minor gaps the hand-edit introduced: the full TS
JSDoc is now used verbatim, all optional fields carry the correct
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] attribute,
and the converter map entry appears in declaration order.
Upstream (microsoft#209) added ResourceWatchState to the SnapshotState discriminated
union. The generator matched that union by exact string, so the new member fell
through and emitted raw TypeScript (invalid C#). Match any union whose members
are all *State types structurally, so future state variants regenerate cleanly.
The generated SnapshotStateConverter only shape-probed four of the six
SnapshotState variants (session/terminal/changeset/root), so a snapshot
carrying ResourceWatchState or AnnotationsState fell through to the
RootState fallback branch and either threw on the missing required
'agents' field or silently mis-typed as RootState.

Extend the SnapshotStateConverter template in the generator: add the
ResourceWatch (probe: root + recursive) and Annotations (probe:
annotations, ordered after the session probe so a session summary's
nested annotations field cannot shadow it) variants to the union class
and to both the Read and Write sides. Regenerate State.generated.cs and
add a round-trip test covering all six variants.
Adopt the shared round-trip corpus's input/acceptableOutputs format and fix the
wire-fidelity drift it surfaces, plus propagate tool-call action metadata.

- TypesRoundTripFixtures: decode each fixture's `input` into the real generated
  type, re-encode, and assert structural equality with the single canonical
  acceptableOutputs[0] (key-order-independent, key-presence-sensitive). Add a
  standalone ProtocolVersionConstants test in place of the removed 021-023.
- generate-csharp.ts: ChangesetOperationRangeTarget.Range is now the canonical
  TextRange (was a flat {Start,End} pair); ActionEnvelope.Origin omits when
  absent instead of serializing null. Regenerated.
- Reducers: the six tool-call lifecycle handlers propagate the action's _meta
  onto the tool-call state (parity with upstream microsoft#211).

320/320 tests pass on net8.0 and net9.0.
- WireEnum: merge the two identical reflection loops (BuildToWire / BuildFromWire)
  into one static constructor that populates both maps in a single pass.
- AhpClient, MultiHostClient: import System.Threading.Channels and drop the
  fully-qualified System.Threading.Channels.* / System.Collections.Generic.List<>
  prefixes, matching the files' existing using-directive style.

No behavior or public-API change; 320/320 tests pass on net8.0 and net9.0.
Add clients/dotnet/.editorconfig + a `dotnet format whitespace --verify-no-changes`
CI step (the C# analog of the Go job's gofmt check), and normalize the whitespace
it flagged (a misindented reducer-case block in Reducers.cs + spacing in
AhpClient.cs). Whitespace-only — no behavior change; 320/320 tests still pass on
net8.0 and net9.0.
The net9 target existed solely to alias the `lock` fields to
System.Threading.Lock (~25% faster than Monitor under contention). But the lock
sites are near-zero-contention small in-memory sections, so that optimization
never materializes in practice, and net9 is now out of support. Collapse to
net8.0 (runs on any .NET 8+ runtime via forward compat): drop the GlobalUsings
type alias and lock on plain objects, single-target the four projects, lower
LangVersion to 12, and update the sync ADR + AGENTS docs. 320/320 tests pass on
net8.0.
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.

1 participant